Compare commits

...
117 Commits
Author SHA1 Message Date
kyle 387d9d6dea Bumping minor version.
A lot of bugfixes went out.
2026-09-24 17:30:33 -07:00
kyleandClaude Opus 5.5 b1576c4228 Remove what only the Qt frontend used
- The theme/font request globals (gThemeChange*, gFont*Request,
  gFontChangePending, gCurrentFont*, gFontDialogRequested): only Qt
  read them.
- The font-picker-toggle command: it only set gFontDialogRequested, so
  it did nothing in kge or kte.
- The terminal :font and :font-size recorded requests nothing read and
  answered "Font requested"; they now say fonts are not available in
  the terminal.
- kte::DisplayColumns, added for Qt's drawing and now unused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:41:12 -07:00
kyleandClaude Opus 5.5 501245da3d Audit: macOS build follow-up and the Qt removal
SDL3 bundling, the setuid fix, the libc++ std::regex test issues and
the C-k modifier fix; the std::regex fallback's libc++ bugs listed as a
known limitation; B10 notes the Qt removal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:32:58 -07:00
kyleandClaude Opus 5.5 2125583407 Remove the Qt frontend
The Qt GUI was unused: out of make-app-release since March, not built by
the gate, and its app bundle could not start (no platform plugin). Every
renderer and input change had to be made in it separately. kge is the
ImGui frontend only.

Removes QtFrontend/QtInputHandler/QtRenderer, the KTE_USE_QT option and
its #if branches (Command.cc, GUITheme.h, main.cc), the Nix qt package
and dev shell, the commented-out Qt release steps, the Qt frontend plan,
and Qt mentions in AGENTS.md, DEVELOPER_GUIDE.md, REWRITE.md and code
comments. Git history keeps the code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:32:58 -07:00
kyleandClaude Opus 5.5 082f4facd3 Save: keep setuid/setgid when not running as root
Without privilege, write() clears S_ISUID/S_ISGID, and the save applied
the original mode before writing the data, so the bits were lost; a
root-run test environment hid it. Apply ownership and mode after the
data is written (still before fsync and rename), and restore the mode
after an in-place write (files with hard links).

The test now expects the mode read back after chmod: BSD silently drops
setgid when the file's group, inherited from its directory, is not one
of the caller's groups. A hard-link variant covers the in-place path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:28:28 -07:00
kyleandClaude Opus 5.5 72286ebb8b ImGui: a bare modifier key does not end the C-k prefix
After C-k, pressing Shift (for C-k C, C-k ^ or a shifted symbol), or
pressing Ctrl again for C-k C-d, reached the k-prefix handler as a key
with no ASCII value, which reported an unknown command and left the
prefix; the suffix was then inserted as text. Modifier keys pressed on
their own are now ignored while the prefix waits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:26:53 -07:00
kyleandClaude Opus 5.5 c9e8b7dbf2 Tests: don't use libc++ std::regex as a reference where it is wrong
libc++'s std::regex ignores match_prev_avail (and match_not_bol with
it) for '^', matching it wherever a search resumes, and misses '\b' at
the end of the subject. Search_NextPrevVisitReferenceMatches now builds
its reference with std::cregex_iterator, which gets '^' right, and the
'\b' replace case runs only against libstdc++. A new test checks that
kte::Regex matches '^' only at the start of the subject; it is skipped
for std::regex builds on libc++, which have the bug.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:26:53 -07:00
kyleandClaude Opus 5.5 fe4e2abb3a Bundle SDL3 for Homebrew's sdl2-compat
Homebrew's sdl2 is now sdl2-compat, which dlopen()s SDL3 at startup
instead of linking it, so fixup_bundle never copied SDL3 into kge.app
and kge aborted at launch with "Failed loading SDL3 library". When the
bundled libSDL2 is sdl2-compat, copy SDL3 to
Contents/Frameworks/libSDL3.dylib, the first place it looks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:26:53 -07:00
Claude f4032dcf25 AGENTS.md: update for this branch's API changes
Rows() is gone; how GetLineView behaves now; buffers are main-thread
only; the KTE_USE_PCRE2 option; test name filters; test_search.cc 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 3be85ef4ad Review fixes: exception-safe AdoptOriginal; Qt draws cells straddling the left edge
AdoptOriginal cleared the table before allocating its piece list, so a
bad_alloc left a live buffer with the file's size and no pieces;
OpenFromFile no longer caught it either. The piece list is now built
before anything is touched (the rest cannot throw) and OpenFromFile
again reports "File too large to load" with the buffer unchanged.

Qt skipped every character starting left of the horizontal scroll
offset, so a tab or wide character straddling it left blank columns
while selection and cursor still used the real ones. Such a character
is now drawn at its own column and clipped by the viewport.

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 e1ebf5900b TerminalRenderer: restart the span cursor on any backward lookup
The restart test compared against the previous span's start, so a lookup
that moved back into an earlier span returned the default kind. Not
reachable today (drawing only moves forward); found in review.

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 8f9a99899b Audit: record the debt and performance pass; B7-B10 and P6 status
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 2da35f57d1 ImGui: a suppressed text event no longer eats the next character (B8)
Ctrl+V, the zoom chords and new-window set a one-shot flag to drop the
text event their key press would produce, but SDL sends none for Ctrl
chords, so the flag dropped the next character typed instead (typing
"xyz" after Ctrl+V gave "yz", reproduced under Xvfb). The flag now
applies only until the next key press. C-k and Meta suffixes are still
suppressed (checked: C-k s saves without inserting "s"; M-f moves
without inserting "f").

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 c5df5035fa Draw non-ASCII text whole in the GUIs (B7); one span sanitizer
Highlighters emit one span per byte of a non-ASCII character, and the
ImGui and Qt renderers decoded each span on its own, so every such
character was drawn as replacement glyphs (reproduced under Xvfb:
"héllo" showed as "h??llo"). kte::SanitizeSpans (Highlight.h) widens
spans to whole UTF-8 characters and removes overlaps (the earlier span
wins), and replaces the three renderers' copies of the clamp/sort code.

The terminal renderer's token lookup rescanned the spans from the first
for every byte drawn; it now walks them with a cursor.

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 d490245f30 Highlighter engine returns cached spans by reference; drop unneeded mutexes
HighlighterEngine::GetLine copied the span vector on every call (twice
on a miss) and did a dynamic_cast per call; it now returns the cached
entry (valid until the next call) and dispatches on Stateful().

PieceTable and HighlighterEngine took a mutex in their read paths while
their writes took none, and PieceTable.h claimed concurrent reads were
safe; they are not (reads update caches). Every Buffer is used from the
main thread only (the swap writer thread receives copies; the regex
large-stack thread runs while its caller waits), so the mutexes are
removed and the headers say so. ASan and the threaded tests under TSan
are clean.

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 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 95bcf8ef09 PieceTable: binary-search piece lookup; lazy line-start shift
Each edit shifted every later line start in the line index, O(lines) per
keystroke (0.5 ms near the top of a 1M-line file). The shift is now kept
pending ("from entry k, add d") and applied on read; further edits at the
same line only adjust it, and it is folded in when an edit lands
elsewhere.

locate() walked the piece list for every lookup (two per rendered line
per frame on an edited buffer); it now binary-searches prefix sums
rebuilt once per modification (keyed on version_, which every mutation
bumps; AdoptOriginal now bumps it after installing its piece).

Typing on line 10 of a 1M-line file with a frame's worth of line reads
per keystroke: 0.52 ms -> 0.017 ms. A new randomized test checks every
read path against a string model with consolidation forced; it fails
under each of three deliberate breakages of the new bookkeeping.

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 29d0ee8244 Remove Buffer::Rows() and the Line wrapper
The row cache rebuilt a string for every line of the file after each edit
and had one production caller left (filetype detection in the ImGui
frontend, which needs only the first line). Removed with it: rows_,
nrows_, the dirty flag set on every edit, ensure_rows_cache and a mutex
guarding only that cache. Tests read lines with GetLineString, Nrows or,
where they compare every line, a test-only LinesForTests().

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
kyleandClaude Fable 5.1 2496722b43 Project guide: AGENTS.md as the single guide, gate target, Junie pointer
CLAUDE.md is a one-line @AGENTS.md import; make gate configures, builds, and runs kte_tests. CMakeLists.txt registers the tests with CTest. .junie/guidelines.md points Junie at AGENTS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 09:23:27 -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 a33c1759e7 Swap: chunked checkpoints for large buffers; scaled thresholds; single replay
- A checkpoint larger than one record (16 MiB) is written as CHKPT_BEGIN
  [size u64, CRC-32], CHKPT_DATA chunks and CHKPT_END. Replay assembles it
  and applies it only when complete with the size and CRC matching; an
  unfinished one is a torn tail (valid_bytes stops before it). Buffers
  over 16 MiB could not be checkpointed before: a lost record left their
  journal behind for good, and they were never compacted. Records are
  written as segments straight from the snapshot. Header version 2;
  version 1 journals are still read.
- Checkpoint thresholds scale with the buffer size (edits of half its
  size; the timer only after a sixteenth; compaction at twice its size;
  gap retries spaced by size), and a checkpoint that would compact is
  written straight into the compacted journal instead of being appended
  and then copied.
- The recovery prompt keeps the content it replayed for its preview and
  installs it on 'y', instead of replaying the journal a second time.
- A base file over 64 MiB gets its CRC from the writer thread before it
  creates the journal (it had none, so touching such a file made its
  journal unusable for recovery).
- kte_tests takes optional name substrings to run a subset of tests.

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 404b983009 Undo: hold large texts as storage spans; byte budget with pruning
- Undo texts of 4 KiB or more (Insert/Paste from the buffer, Delete from
  the spans the piece table remembers for its last large deletion) are
  held as TextSpan references into the piece table's append-only
  storage, after checking they hold exactly the recorded bytes. Undo and
  redo insert them with Buffer::insert_spans, which adds pieces over the
  stored bytes instead of copying them into storage again (the journal
  still gets the bytes, as one record). A replace-all session on a 26 MB
  file peaks at 181 MB instead of 332 MB; undo/redo cycles of a 6.6 MB
  kill no longer grow memory by 6.6 MB each.
- UndoSystem keeps a byte budget (default max(64 MiB, twice the buffer
  size); SetByteBudget). Over it, history is pruned to 75% of the budget:
  branches off the current path, then the oldest edits (an undo group
  goes whole, and never the current state's group), then the redo line.
  When the saved state is dropped the buffer stays modified until saved.
- replace_all_bytes clears undo history, which was recorded against (and
  may reference the storage of) the previous content.

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 7165bb286b Audit doc: ninth round (no serious findings); P4 status
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 b9f91b0ed2 Round-seven fixes: swap name room for .tmp; input and notice details
- The shortened fallback swap name was capped at exactly NAME_MAX, so
  compaction's '<journal>.tmp' failed with ENAMETOOLONG on every
  checkpoint and the journal grew by a full copy of the buffer each
  time. Cap it at NAME_MAX - 4.
- A lone UTF-8 lead byte (8-bit Meta terminals) waited a second for
  continuation bytes on every key; wait 250 ms. An invalid byte now ends
  a pending ESC/C-k prefix instead of leaving it for the next key.
- Journal notices are shown after a deferred open's own status (which
  used to overwrite them), all pending ones at once, and the lock-out
  notice is not repeated after every save while the lock is still held.

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 78d78f6f54 Swap: tell the user when a journal is locked out or failing; long names
- SwapManager queues user notices when a journal is held by another kte
  (this session's edits are then not journaled) and, once per gap, when
  journal writes fail. Editor shows them in the status line from its
  per-frame hook. Both went only to the error log, so crash recovery was
  silently off or incomplete.
- The fallback swap name '<basename>.<hash>.swp' exceeded NAME_MAX for
  basenames over ~233 bytes, so such files were never journaled; the
  basename is shortened (on a UTF-8 boundary) only in that case.
- Audit doc: sixth-round findings.

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 1e312fcd0b Swap: retry a journal's recovery checkpoint while idle
After a lost record the journal drops position-based records until a
full checkpoint. That checkpoint was only requested from later edits,
rate-limited to once a second; when the writer handled the failing
record before its edit asked for a checkpoint, the checkpoint failed as
well and consumed the slot, so edits made within the next second were
not recovered and, if the user stopped typing, the journal stayed behind
indefinitely. SwapManager::RetryGapCheckpoints(), called once per frame
from Editor::ProcessPendingOpens, requests it once the interval has
passed. This also made SwapReplay_LostRecord_ResyncsWithCheckpoint flaky
under load; the test now exercises the retry.

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 ca3a6e675b Audit doc: record the fourth review round's fixes
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 a0441e4c25 Swap: fsync without blocking edits; base identity off the lock; races
- The writer's periodic fsync now holds a separate io_mtx_, taken also
  (before mtx_) by the main-thread paths that close journal fds, instead
  of mtx_. Every keystroke takes mtx_ to enqueue its record, so a slow
  fsync (writeback pressure, NFS) froze typing.
- A journal's base identity (stat + content CRC) is computed before
  taking mtx_ and applied under it; the CRC is skipped above 64 MiB
  (size and mtime still identify the file). It re-read the whole file
  under the lock on every open and save.
- A buffer that was never journaled (a virtual buffer saved under a
  real name) is attached by NotifyFilenameChanged.
- ThreadSanitizer: open_ctx (writer thread) no longer writes ctx.path,
  and locked_out is recorded by the caller under mtx_; both raced with
  main-thread reads.

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 13f1b7281f Load/save robustness: OOM-safe load, guarded open loop, save fallbacks
- OpenFromFile builds the new content in a separate PieceTable and
  swaps it in only when complete. A reload that ran out of memory used
  to leave the buffer empty (filename, journal and dirty state intact),
  and the next save truncated the file.
- Editor::ProcessPendingOpens catches exceptions from opening (it runs
  in every frontend's loop, outside command dispatch, so bad_alloc on a
  huge file ended the editor); the terminal draw is guarded too.
- The hard-link save copy uses a unique mkstemp name, so a leftover
  copy no longer blocks every later save; if no copy can be created the
  file is still written in place.
- When no temp file can be created in the directory (file writable,
  directory not), an existing regular file is written in place instead
  of the save failing.

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 58706489bb docs: record follow-up review rounds and known limitations
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 1005f5dbe8 Save metadata and journal staleness: second-pass fixes
- Ownership is applied before permissions: chown clears setuid/setgid,
  so saving a 06755 file left it 0755.
- The umask is read once during static initialisation. Reading it at
  save time needed umask(0)/umask(old), briefly changing it for the swap
  writer thread creating files.
- Before rewriting a hard-linked file in place, the new content is
  written and synced to a copy beside it; if the in-place write fails
  (ENOSPC, EIO) the copy is kept and named in the error.
- Journal headers also record a CRC-32 of the base file (flags bit 1,
  bytes 40..43). A changed mtime with the same size and content
  (touch, checkout back and forth) no longer marks a journal stale.

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 cb9b6bdf45 Swap: lock journals to one session; record the base file in the header
- open_ctx takes a non-blocking flock on the journal. A journal locked
  by another kte process is left alone: this session reports it once
  and does not write (records from two sessions used to interleave in
  one file). Opening a file whose journal is live in another session
  skips recovery instead of replaying or removing that journal.
- The header records the size and mtime of the file the journal's
  records apply to (flags bit 0, bytes 24..39; previously reserved, so
  no version change). Before offering recovery, a journal whose base no
  longer matches the file on disk (changed after a crash, or the crash
  fell between a save's rename and the journal reset) is treated as
  stale and offered for deletion, instead of replaying its edits onto
  different text. Journals from older kte (flag clear) replay as before.

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 eaa605f548 Buffer: fix Release build (unused fchown result)
glibc marks fchown warn_unused_result; with optimisation and
_FORTIFY_SOURCE a (void) cast does not silence it, and -Werror failed
the Release build. Check the result explicitly instead.

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 0075c7aa86 PieceTable: keep the line index across consolidation; reset moved-from
Consolidation rewrites pieces without changing content or offsets but
invalidated the line index, so once a buffer passed the piece limit
every edit paid a full rescan again. A moved-from table kept the index
of the text it gave away, and Insert now updates that index in place,
producing ranges past the end of its buffer.

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 7b8ccbb8db docs: update audit fix status
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 f853fa60b1 Highlighting: invalidate from the edited row, not the whole buffer
Every edit bumped the buffer version (through SetDirty) and called
InvalidateFrom(0), so all cached spans and line states were discarded
and stateful highlighters re-lexed from row 0 to the viewport on each
keystroke.

Buffer mutators now report the row they changed (edited_at_), which
bumps the version and tells the engine to drop only rows from there
down; a row's highlighting depends only on rows above it. A version
change that arrives without that notice still clears everything, so a
missed path cannot show stale spans. The engine keeps states as a
contiguous vector and caches spans for every row it lexes. SetDirty no
longer touches the version: the edits themselves do, including undo
and redo back to the saved state (D9 still holds; its test now checks
the highlighting itself).

Typing near the end of a 100k-line C++ file: 46.3 ms -> 0.14 ms of
highlighting work per keystroke.

Audit finding P3.

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 4c42761fc0 Perf: update the line index in place; renderers read only visible rows
- PieceTable::Insert/Delete adjust a valid line index directly (shift
  later line starts, add or drop starts for inserted or deleted
  newlines) instead of invalidating it, which forced a byte-by-byte
  rescan of the whole buffer on the next line query. On a 38 MB,
  1M-line buffer, insert + line query drops from 14.7 ms to 0.28 ms.
- The terminal and ImGui renderers fetch only the rows they draw
  (Nrows + GetLineString) instead of Buffer::Rows(), which rebuilt a
  string for every line of the file on the first frame after each edit.
  Qt's line-count uses switch to Nrows().

ImGuiRenderer.cc was syntax-checked with g++ and clang++ against the
vendored ImGui headers; the GUI targets could not be built here (no
SDL2 or Qt6).

Audit findings 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
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 f861e42c2c Terminal input: consume the ESC-as-Meta flag on every key
ESC sets a flag that makes the next key a Meta command, but arrows,
mouse events, Enter, Tab and control chords returned before the flag
was checked and left it set. A later plain key then ran as a Meta
command: ESC, Right, d deleted a word instead of inserting 'd'. The
flag is now consumed up front, and the Meta lookup runs before the
control-chord and Enter handling so ESC ^H (meta-backspace) and ESC
Enter (SmartNewline) reach their bindings.

Verified by driving kte in a pty: ESC, Right, d on 'hello world' now
gives 'hdello world' (was 'hworld').

Audit finding B3.

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 aa53d75f96 Swap: keep the journal replayable across failures and crashes
- Replay recovers every complete record before a torn final record (the
  normal result of a crash mid-append) and reports it through err,
  instead of rejecting the whole journal. SwapReplay_TruncatedLog is
  updated to the new behaviour.
- A record that cannot be written (open/write failure, breaker open)
  marks the journal as having a gap. Later position-based records are
  dropped until a checkpoint re-establishes the content; recorders
  request one on the next edit (at most once per second).
- A failed append is truncated back off the file, and a failed header
  write closes the file so the next attempt starts fresh.
- Inserts over the 24-bit record limit are split into consecutive
  records; buffers too large to checkpoint are no longer snapshotted
  and rejected on every checkpoint.
- Declining to delete an unreadable swap moves it to .corrupt so new
  edits are not appended behind the bad data.
- NotifyFilenameChanged no longer writes a full checkpoint into the
  journal it is about to delete.

Audit findings D7, P5.

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 8cac2e9af8 Editor: share the SwapManager along with shared buffers
A secondary GUI window shared the primary's buffer vector but attached
buffers through its own SwapManager. Closing the window destroyed that
manager while buffers still held its recorders, so the next edit called
into freed memory; Rehome through the wrong manager also dropped
journaling for the primary's buffers.

Audit finding M3.

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 fc9f83966c HighlighterEngine: remove background warm-up thread
The worker read the Buffer (PieceTable pieces and add buffer) while the
main thread edited it with no shared lock, and kept a raw Buffer* that
dangled once the buffer vector reallocated or shifted. PrefetchViewport
now only highlights the visible rows synchronously.

Audit finding M2.

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 66259db177 tests: add regression tests for audit fixes; record fix status
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
Claude 00098fee16 Terminal: apply ESC delay; stop treating resize as C-g
set_escdelay is an ncurses function, not a macro, so the #ifdef guard
compiled it out and ESC waited the 1 s default. KEY_RESIZE was mapped
to Refresh, the C-g cancel handler, which cleared the mark and
cancelled open prompts (including swap recovery) on every resize.

Audit findings B1, B2.

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 1bdc04ce1a CircuitBreaker: measure the failure window from the previous failure
last_failure_time_ was updated before IsWindowExpired() read it, so the
window never expired and widely spaced failures still opened the
breaker.

Audit finding D8.

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 f36aa9f456 Buffer: bump version on every content change
SetDirty(false) did not bump the version, so undo back to the saved
state (and reload, and replace_all_bytes) left the highlighter serving
spans for the old text. Reloading a file deleted on disk now also
clears undo history recorded against the old content.

Audit finding D9.

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 52089d3564 Undo: free and search the undo tree iteratively
Recursion depth equalled history length, so destroying or clearing a
buffer with a few hundred thousand undo steps overflowed the stack.
UndoSystem::clear() also freed only the first root branch, leaking the
root's siblings; it now frees the whole forest.

Audit finding M4.

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 0f2cc41ed1 PieceTable: invalidate line index on assignment and merged appends
Copy/move assignment kept the destination's line index, so closing a
buffer (vector erase move-assigns later buffers down) left the shifted
buffer with its predecessor's line offsets and out-of-bounds line views.
Appends that merged into an existing piece also skipped invalidation.

Audit findings M1, B12.

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 64139b4868 docs: add 2026-09-23 bug and performance audit
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
kyle d8d2ae8a31 Install Linux desktop entry and icons for kge 2026-09-17 06:00:12 -07:00
kyleandClaude Opus 5 18ce005ec0 Fix macOS build: use CURSES_INCLUDE_DIRS and enable wide-char decls
Two latent defects broke the kte target on macOS once TerminalInputHandler
switched from getch() to get_wch().

include_directories() referenced CURSES_INCLUDE_DIR, a name FindCurses never
sets (it exports CURSES_INCLUDE_DIRS). The variable expanded to nothing, so
no curses include path was added and the compiler silently fell back to the
macOS SDK's narrow-char header on the default include path.

Both the SDK and Homebrew headers declare get_wch and friends only when
NCURSES_WIDECHAR is set, defaulting it to 0 absent a feature-test macro, so
the build failed with "use of undeclared identifier 'get_wch'". We always
request and link the wide library, so define NCURSES_WIDECHAR=1; redefining
it identically is benign on platforms whose headers already set it.

kte and kge now compile and link against libncursesw, and the app bundle
vendors it. Full test suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:22:34 -07:00
kyleandClaude Opus 5 22f7573361 Fix macOS release build: locate Homebrew's wide-char ncurses
CMake 4.x FindCurses with CURSES_NEED_WIDE finds the macOS SDK's
libcurses.tbd but no matching wide-char header, so configure failed with
"Could NOT find Curses (missing: CURSES_INCLUDE_PATH)". Homebrew's
ncurses is keg-only, so it is not on the default search path either.

Existing build directories only worked by accident, via a hand-passed
-DCURSES_INCLUDE_PATH left in their CMakeCache; a clean configure failed,
which broke make-app-release.

Query `brew --prefix ncurses` on APPLE and prepend it to CMAKE_PREFIX_PATH
when present. A clean configure now succeeds with no manual flags and
links the real libncursesw.dylib instead of the SDK's narrow libcurses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:17:34 -07:00
kyleandClaude Opus 5 96e82cc6dd Bump version to 1.12.0
Releases the undo/crash-recovery/syntax-highlighting correctness work and
the GUI CRLF paste fix accumulated since v1.11.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:56:29 -07:00
kyleandClaude Opus 4.8 480f1390c7 Fix GUI paste of CRLF/CR text: split all line endings
The ImGui Cmd+V/Ctrl+V paste handler split clipboard text only on '\n',
leaving '\r' inside each segment. Pasting text with CRLF (Windows) or bare
CR (classic-Mac / some macOS apps) line endings enqueued an InsertText arg
containing '\r', which the InsertText command rejects with "InsertText arg
must not contain newlines".

Extract the paste-to-commands logic into a pure, SDL-free helper
(PasteSplit.h) that treats '\n', '\r\n', and bare '\r' each as one line
break, and add unit tests covering all line-ending forms plus blank-line
and trailing-newline preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 00:20:36 -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.7 b60a8dc491 Optimize ImGui renderer: viewport culling and width caching
Large files made idle CPU spike because every frame re-measured every
line, recompiled the search regex per line, and ran full per-line
rendering work even for off-screen rows.

- Render only visible rows (with a small margin for smooth scrolling)
  and advance the layout cursor to preserve total content height.
- Hoist the search std::regex compilation out of the per-line loop.
- Cache the max line width on the renderer; reset only when the buffer
  or font changes, not on every edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:12:44 -07:00
kyleandClaude Opus 4.6 056c9af38e Fix macOS GUI file loading: resolve CLI paths before chdir
On macOS GUI builds, chdir(HOME) runs before deferred file opens are
processed, breaking relative paths passed on the command line. Resolve
each argv path to absolute immediately during argument parsing.

Bump version to 1.11.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:20:36 -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
kyle d1a45581bf Bump patch version. 2026-03-31 23:48:01 -07:00
kyleandClaude Opus 4.6 81a5c25071 Fix proportional font rendering with pixel-based horizontal scroll
The GUI renderer had two competing horizontal scroll systems: a
character-based one (coloffs × space_w) and a pixel-based one. For
proportional fonts the character-based system used "M" width to
calculate viewport columns, triggering premature scrolling at ~50%
of the actual display width.

Switch the GUI renderer to purely pixel-based horizontal scrolling:
- Remove coloffs↔ImGui scroll_x bidirectional sync
- Measure rx_to_px from column 0 (absolute) instead of from coloffs
- Draw full expanded lines; let ImGui clip via its scroll viewport
- Report content width via SetCursorPosX+Dummy for the scrollbar
- Use average character width for cols estimate (not "M" width)

The terminal renderer continues using coloffs correctly—no changes
needed there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:18:53 -07:00
kyleandClaude Opus 4.6 5667a6d7bd Fix Linux build: default static linking off, add Clang link flag
KTE_STATIC_LINK defaulted to ON, which fails on systems where ncurses
is only available as a shared library. The Nix build already passed
-DKTE_STATIC_LINK=OFF explicitly; this makes the default match.

Also add add_link_options("-stdlib=libc++") for Clang builds — without
it, compilation uses libc++ but the linker defaults to libstdc++,
causing undefined symbol errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:18:26 -07:00
kyleandClaude Opus 4.6 99c4bb2066 Fix font atlas crash and make Nix build first-class
Defer edit-mode font switch to next frame via RequestLoadFont() to
avoid modifying the locked ImFontAtlas between NewFrame() and Render().

Rework Nix packaging: split nativeBuildInputs/buildInputs correctly,
add devShells for nix develop, desktop file for kge, per-variant pname
and meta.mainProgram, and an overlay for NixOS configs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:18:26 -07:00
kyle 953fee97d7 Bump patch. 2026-03-25 07:43:49 -07:00
kyleandClaude Opus 4.6 d7e35727f1 Fix segfault from mid-frame font atlas rebuild
The edit-mode font switcher called LoadFont() directly between
NewFrame() and Render(), invalidating the font atlas ImGui was
actively using. Use RequestLoadFont() to defer the change to
the safe inter-frame point, matching the existing zoom pattern.

Also default code_font/writing_font to the main font when not
explicitly configured, preventing a mismatch that triggered the
switch on every first frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 02:07:31 -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 0585edad9e Disable Qt build in make-app-release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:22:11 -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
kyleandClaude Opus 4.6 34eaa72033 Bump patch version to 1.8.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:36:21 -07:00
kyleandClaude Opus 4.6 f49f1698f4 Add Tufte theme with light and dark variants
Warm cream paper, near-black ink, zero rounding, minimal chrome,
restrained dark red and navy accents following Tufte's design principles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:34:23 -07:00
kyle f4b3188069 Forgot to bump patch version. 2026-03-17 17:28:57 -07:00
kyle 2571ab79c1 build now works on nix
1. Static linking - Added KTE_STATIC_LINK CMake option and
   disabled it in default.nix to avoid the "attempted
   static link of dynamic object" error

2. Missing include - Added <cstring> to
   test_swap_edge_cases.cc for std::memset/std::memcpy (GCC
   14 is stricter about transitive includes)
2026-03-17 17:15:16 -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 11c523ad52 Bump patch version. 2026-02-26 13:27:13 -08:00
kyle c261261e26 Initialize ErrorHandler early and ensure immediate log file creation
- Added early initialization of `ErrorHandler` in `main.cc` for robust error handling.
- Modified `ErrorHandler` to create the log file immediately, ensuring its presence in the state directory.
- Simplified conditional checks for log file operations and updated timestamp handling to use `system_clock`.
2026-02-26 13:25:57 -08: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 690c51b0f3 MacOS: remove static linking. Bump minor version. 2026-02-19 21:00:29 -08:00
kyle 0d87bc0b25 Introduce error recovery mechanisms with retry logic and circuit breaker integration.
- Added `ErrorRecovery.cc` and `ErrorRecovery.h` for retry and circuit breaker implementations.
- Enhanced swap file handling with transient error retries and exponential backoff (e.g., ENOSPC, EDQUOT).
- Integrated circuit breaker into SwapManager to gracefully handle repeated failures, prevent system overload, and enable automatic recovery.
- Updated `DEVELOPER_GUIDE.md` with comprehensive documentation on error recovery patterns and graceful degradation strategies.
- Refined fsync, temp file creation, and swap file logic with retry-on-failure mechanisms for improved resilience.
2026-02-17 21:38:40 -08:00
kyle daeeecb342 Standardize error handling patterns and improve ErrorHandler integration.
- Added a comprehensive error propagation standardization report detailing dominant patterns, inconsistencies, and recommended remediations (`docs/audits/error-propagation-standardization.md`).
- Integrated `ErrorHandler` into key components, including `main.cc` for robust exception reporting, and added centralized logging to a user state path.
- Introduced EINTR-safe syscall wrappers (`SyscallWrappers.h`, `.cc`) to improve resilience of file and metadata operations.
- Enhanced `DEVELOPER_GUIDE.md` with an error handling conventions section, covering pattern guidelines and best practices.
- Identified gaps in `PieceTable` and internal helpers; deferred fixes with detailed recommendations for improved memory allocation error reporting.
2026-02-17 21:25:19 -08:00
kyle a428b204a0 Improve exception robustness.
- Introduced `test_swap_edge_cases.cc` with extensive tests for minimum payload sizes, truncated payloads, data overflows, unsupported encoding versions, CRC mismatches, and mixed valid/invalid records to ensure reliability under complex scenarios.
- Enhanced `main.cc` with a top-level exception handler to prevent data loss and ensure cleanup during unexpected failures.
2026-02-17 20:12:09 -08:00
kyle a21409e689 Remove PID from unnamed buffer swap names. 2026-02-17 17:17:55 -08:00
kyle b0b5b55dce Switch Docker to Alpine and build kge.
Update build environment to Alpine, enable GUI support, and refine developer guide

- Migrated Dockerfile base image from Ubuntu 22.04 to Alpine 3.19 for a smaller and faster container.
- Added dependencies for GUI support (SDL2, OpenGL/Mesa, Freetype, etc.) and updated CMake options.
- Enhanced `DEVELOPER_GUIDE.md` with new instructions for GUI builds, updated dependencies, and simplified custom build workflows.
- Addressed Alpine-specific ncurses library path issues in CMake configuration.
2026-02-17 16:53:12 -08:00
kyle 422b27b1ba Add Docker support for Linux build testing
- Introduced a `Dockerfile` for setting up a minimal Ubuntu-based build environment with required dependencies.
- Added `docker-build.sh` script to simplify Linux build and test execution using Docker or Podman.
- Updated `DEVELOPER_GUIDE.md` with instructions for using Docker/Podman for Linux builds, including CI/CD integration examples.
2026-02-17 16:35:52 -08:00
kyle 9485d2aa24 Linux fixup. 2026-02-17 16:13:28 -08:00
kyle 8a6b7851d5 Bump patch version. 2026-02-17 16:08:53 -08:00
kyle 8ec0d6ac41 Add benchmarks, migration tests, and dev guide
Add benchmarks for core operations, migration edge case tests, improved
buffer I/O tests, and developer guide

- Introduced `test_benchmarks.cc` for performance benchmarking of key
  operations in `PieceTable` and `Buffer`, including syntax highlighting
  and iteration patterns.
- Added `test_migration_coverage.cc` to provide comprehensive tests for
  migration of `Buffer::Rows()` to `PieceTable` APIs, with edge cases,
  boundary handling, and consistency checks.
- Enhanced `test_buffer_io.cc` with additional cases for save/load
  workflows, file handling, and better integration with the core API.
- Documented architectural details and core concepts in a new
  `DEVELOPER_GUIDE.md`. Highlighted design principles, code
  organization, and contribution workflows.
2026-02-17 16:08:23 -08:00
kyle 337b585ba0 Reformat code. 2026-02-17 13:44:36 -08:00
kyle 95a588b0df Add test for Git editor swap cleanup and improve swap file handling
- Added `test_swap_git_editor.cc` to verify proper swap file cleanup during Git editor workflows. Ensures no stale swap files are left after editor closure.
- Updated swap handling logic in `Editor.cc` to always remove swap files on buffer closure during normal exit, preventing accumulation of leftover files.
- Bumped version to 1.6.5 in `CMakeLists.txt`.
2026-02-17 13:10:01 -08:00
173 changed files with 74589 additions and 21987 deletions
+2 -58
View File
@@ -1,58 +1,2 @@
# Project Guidelines Read AGENTS.md at the repository root before anything else. It is the only
project guide; this file exists because Junie cannot import it.
kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
It
replaces the earlier C implementation, ke (see the ke manual in
`docs/ke.md`). The
design draws inspiration from Antirez' kilo, with keybindings rooted in
the
WordStar/VDE family and emacs. The spiritual parent is `mg(1)`.
These guidelines summarize the goals, interfaces, key operations, and
current
development practices for kte.
## Goals
- Keep the core small, fast, and understandable.
- Provide an ncurses-based terminal-first editing experience, with an
additional ImGui GUI.
- Preserve familiar keybindings from ke while modernizing the internals.
- Favor simple data structures (e.g., piece table) and incremental
evolution.
Project entry point: `main.cpp`
## Core Components (current codebase)
- Buffer: editing model and file I/O (`Buffer.h/.cpp`).
- PieceTable: editable in-memory text representation (
`PieceTable.h/.cpp`).
- InputHandler: interface for handling text input (`InputHandler.h/`),
along
with `TerminalInputHandler` (ncurses-based) and `GUIInputHandler`.
- Renderer: interface for rendering text (`Renderer.h`), along with
`TerminalRenderer` (ncurses-based) and `GUIRenderer`.
- Editor: top-level editor state (`Editor.h/.cpp`).
- Command: command model (`Command.h/.cpp`).
- General purpose editor functionality (`Editing.h/.cpp`)
## Keybindings (inherited from ke)
The file `docs/ke.md` contains the canonical reference for keybindings.
## Contributing/Development Notes
- C++ standard: C++20.
- Keep dependencies minimal.
- Prefer small, focused changes that preserve ke’s UX unless explicitly
changing
behavior.
## References
- Previous editor manual: `ke.md` (canonical keybinding/spec reference
for now).
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
+106
View File
@@ -0,0 +1,106 @@
# kte
kte (Kyle's Text Editor) is a C++20 text editor with a terminal-first design (ncurses) and an optional ImGui GUI frontend (SDL2/OpenGL/Freetype); it uses a WordStar/VDE-style command model. Standards: none. The Metacircular service standards do not apply here.
## Bootstrap (run once per checkout, idempotent)
nix develop (default shell; also provides a `terminal` devshell) or system SDL2/Freetype/ncurses if not using nix.
## Gate (run before saying "done"; must exit 0)
make gate # what it runs: configure (cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON -DBUILD_TESTS=ON) -> build (cmake --build build) -> test (./build/kte_tests)
Fast check while editing: cmake --build build --target kte_tests && ./build/kte_tests
Also run clang-tidy on changed files via build/compile_commands.json (not part of `make gate`; clang-tidy is not on this Mac's PATH).
## Run
Terminal editor: `./build/kte <file>` (always built).
GUI editor: `./build/kge <file>` (built when `-DBUILD_GUI=ON`; requires SDL2/OpenGL/Freetype).
Docker (cross-platform Linux testing): `docker build -t kte-linux . && docker run --rm -v "$(pwd):/kte" kte-linux`.
## Sensors the harness watches
- `make gate` exit status and last 40 lines; `kte_tests` runs via ctest too (`ctest --test-dir build`)
- clang-tidy warning count on the diff (when clang-tidy is available)
- for GUI bugs: launch `./build/kge` and report what was seen; a green test suite is not proof
## Rules (do not re-litigate)
- Release: bump CMakeLists version, checkpoint, `./make-release.sh`, report the SHA256 it prints; a GitHub push failure at the same commit is fine; then update `/opt/homebrew/Library/Taps/kisom/homebrew-tap`.
- All text mutations must go through the PieceTable API (`insert_text`, `delete_text`) so undo and swap recording work.
- Read lines with `Nrows()` and `GetLineString()` (a copy, cheap on any buffer) or `GetLineView()` (zero-copy when the line lies within one piece; a line straddling an edit materializes the whole buffer); `GetLineView()` and `ContentView()` are valid only until the next buffer modification.
- C++20, compiled with `-Wall -Wextra -Werror -pedantic`; Clang uses `-stdlib=libc++`.
- Naming: PascalCase for classes/methods, snake_case for variables, trailing underscore for private members (e.g. `pieces_`); indentation is tabs.
- Fallible ops use `bool func(args..., std::string &err)`: clear `err` at start, capture `errno` immediately after syscall failure, use EINTR-safe wrappers from `SyscallWrappers.h` instead of raw syscalls.
- After editing ops, call `ensure_cursor_visible()` to update the viewport.
## Docs that govern
- `docs/ke.md` — canonical keybinding/spec reference, inherited from the predecessor editor `ke`
- `REWRITE.md`, `ROADMAP.md`, `CONFIG.md` — design rationale, roadmap, and config reference
- `README.md` — project overview
## Remote target
None: kte builds and runs locally (or in the Docker/CI Linux image); it has no remote deployment target.
## Architecture
Three-layer design with strict frontend independence:
```
Frontend Layer (Terminal / ImGui / Test)
InputHandler.h, Renderer.h, Frontend.h interfaces
↓
Command Layer
CommandId enum → CommandRegistry → handler functions in Command.cc
↓
Core Model Layer
Editor → Buffer → PieceTable
UndoSystem (tree-based, records at PieceTable level)
SwapManager (crash recovery journal per buffer)
```
### Core Components
- **PieceTable** (`PieceTable.h/.cc`) - Text storage. Lazy materialization; most ops work on the piece list directly. Line index and materialization caches must be invalidated on content changes.
- **Buffer** (`Buffer.h/.cc`) - Wraps PieceTable.
- **Editor** (`Editor.h/.cc`) - Top-level state container. Primarily getters/setters; editing logic lives in commands.
- **Command** (`Command.h/.cc`) - 120+ editing commands. This is the main place to add new editing operations. Register via `CommandRegistry::Register()` in `InstallDefaultCommands()`.
- **UndoSystem/UndoTree/UndoNode** - Tree-based undo with branching. Group related ops with `buf.Undo()->BeginGroup()` / `EndGroup()`.
- **Swap** (`Swap.h/.cc`) - Append-only crash recovery journal. Uses circuit breaker pattern for resilience. Files in `~/.local/state/kte/`.
- **Syntax highlighting** (`syntax/`) - Pluggable per-language highlighters registered in `HighlighterRegistry`. Per-line caching with buffer version tracking.
### Frontend Implementations
Each frontend implements three interfaces (`Frontend.h`, `InputHandler.h`, `Renderer.h`):
- **Terminal**: ncurses-based (always built)
- **ImGui**: SDL2+OpenGL+Freetype (built with `-DBUILD_GUI=ON`)
- **Test**: Programmatic frontend for testing (always built, no UI deps)
### Key CMake Options
| Flag | Default | Purpose |
|------|---------|---------|
| `BUILD_GUI` | ON | Build `kge` (ImGui GUI) |
| `BUILD_TESTS` | ON | Build test suite |
| `ENABLE_ASAN` | OFF | AddressSanitizer |
| `KTE_STATIC_LINK` | OFF | Static linking (Linux only) |
| `KTE_ENABLE_TREESITTER` | OFF | Tree-sitter syntax highlighting |
| `KTE_USE_PCRE2` | ON | Regex search/replace via PCRE2 when `libpcre2-8` is found (std::regex otherwise; see `RegexEngine.h`) |
## Testing
Tests live in `tests/test_*.cc`, run by the single `kte_tests` binary (also registered with ctest as `add_test(NAME kte_tests COMMAND kte_tests)`); `./build/kte_tests Swap_ Undo_` runs only the tests whose names contain one of the arguments. Tests use a minimal custom framework in `tests/Test.h` with `TEST()`, `ASSERT_EQ()`, `ASSERT_TRUE()`, `EXPECT_TRUE()` macros. Use `TestFrontend`/`TestInputHandler`/`TestRenderer` for integration tests that exercise the full Editor+Buffer+Command stack without UI dependencies.
Key test files by area:
- PieceTable: `test_piece_table.cc`
- Buffer I/O: `test_buffer_io.cc`
- Commands: `test_command_semantics.cc`
- Search/replace: `test_search_replace_flow.cc`
- Undo: `test_undo.cc`
- Swap (crash recovery): `test_swap_*.cc` (7 files)
- Reflow: `test_reflow_paragraph.cc`, `test_reflow_indented_bullets.cc`
- Integration: `test_daily_workflows.cc`
## Important Caveats
- `GetLineView()` and `ContentView()` return a `string_view` valid only until the next buffer modification; views of different lines need not be contiguous.
- Buffers, PieceTable and HighlighterEngine are used from the main thread only (the swap writer thread receives copies of the bytes).
- All source files are in the project root (no `src/` directory); tests are in `tests/`; syntax highlighters in `syntax/`; themes in `themes/`; embedded fonts in `fonts/`.
- External deps: `ext/imgui/` (Dear ImGui), `ext/tomlplusplus/` (TOML parser).
- GUI config: `~/.config/kte/kge.toml` (TOML preferred over legacy INI).
- ErrorHandler: centralized logging to `~/.local/state/kte/error.log` with severity levels (Info/Warning/Error/Critical).
+413 -179
View File
@@ -18,6 +18,9 @@
#include "SwapRecorder.h" #include "SwapRecorder.h"
#include "UndoSystem.h" #include "UndoSystem.h"
#include "UndoTree.h" #include "UndoTree.h"
#include "ErrorHandler.h"
#include "SyscallWrappers.h"
#include "ErrorRecovery.h"
// For reconstructing highlighter state on copies // For reconstructing highlighter state on copies
#include "syntax/HighlighterRegistry.h" #include "syntax/HighlighterRegistry.h"
#include "syntax/NullHighlighter.h" #include "syntax/NullHighlighter.h"
@@ -96,12 +99,18 @@ Buffer::RefreshOnDiskIdentity()
} }
// The bytes to save, as views of the piece table's storage in order (saving
// never makes a contiguous copy of the buffer).
using ByteChunks = std::vector<std::string_view>;
static bool static bool
write_all_fd(int fd, const char *data, std::size_t len, std::string &err) write_all_fd(int fd, const ByteChunks &chunks, std::string &err)
{ {
for (const std::string_view chunk: chunks) {
std::size_t off = 0; std::size_t off = 0;
while (off < len) { while (off < chunk.size()) {
ssize_t n = ::write(fd, data + off, len - off); ssize_t n = ::write(fd, chunk.data() + off, chunk.size() - off);
if (n < 0) { if (n < 0) {
if (errno == EINTR) if (errno == EINTR)
continue; continue;
@@ -110,6 +119,7 @@ write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
} }
off += static_cast<std::size_t>(n); off += static_cast<std::size_t>(n);
} }
}
return true; return true;
} }
@@ -122,20 +132,144 @@ best_effort_fsync_dir(const std::string &path)
std::filesystem::path dir = p.parent_path(); std::filesystem::path dir = p.parent_path();
if (dir.empty()) if (dir.empty())
return; return;
int dfd = ::open(dir.c_str(), O_RDONLY); int dfd = kte::syscall::Open(dir.c_str(), O_RDONLY);
if (dfd < 0) if (dfd < 0)
return; return;
(void) ::fsync(dfd); (void) kte::syscall::Fsync(dfd);
(void) ::close(dfd); (void) kte::syscall::Close(dfd);
} catch (...) { } catch (...) {
// best-effort // best-effort
} }
} }
// The process umask, read once during static initialisation (before any
// thread exists): reading it later needs umask(0) + umask(old), which briefly
// changes it for the swap writer thread creating files concurrently.
static const mode_t g_process_umask = [] {
const mode_t m = ::umask(0);
(void) ::umask(m);
return m;
}();
// Rewrite `path` in place (truncate, write, fsync). Used for files with
// several hard links, which a temp-file-and-rename save would split.
static bool static bool
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err) write_in_place(const std::string &path, const ByteChunks &data, std::string &err)
{ {
int flags = O_WRONLY | O_TRUNC;
#ifdef O_CLOEXEC
flags |= O_CLOEXEC;
#endif
const int fd = kte::syscall::Open(path.c_str(), flags);
if (fd < 0) {
err = std::string("Failed to open file for writing: ") + std::strerror(errno);
return false;
}
struct stat before{};
const bool have_mode = ::fstat(fd, &before) == 0;
bool ok = write_all_fd(fd, data, err);
// Without privilege, writing clears setuid/setgid; put them back.
if (ok && have_mode)
(void) kte::syscall::Fchmod(fd, before.st_mode & 07777);
if (ok && kte::syscall::Fsync(fd) != 0) {
err = std::string("fsync failed: ") + std::strerror(errno);
ok = false;
}
(void) kte::syscall::Close(fd);
return ok;
}
// Create a new file named from `tmpl` (a mkstemp template, which receives
// the chosen name) holding the content, fsynced.
static bool
write_new_file(std::string &tmpl, const ByteChunks &data, std::string &err, int &create_errno)
{
create_errno = 0;
std::vector<char> name(tmpl.begin(), tmpl.end());
name.push_back('\0');
const int fd = kte::syscall::Mkstemp(name.data());
if (fd < 0) {
create_errno = errno;
err = "Failed to create " + tmpl + ": " + std::strerror(create_errno);
return false;
}
tmpl.assign(name.data());
const std::string &path = tmpl;
bool ok = write_all_fd(fd, data, err);
if (ok && kte::syscall::Fsync(fd) != 0) {
err = std::string("fsync failed: ") + std::strerror(errno);
ok = false;
}
(void) kte::syscall::Close(fd);
if (!ok)
(void) ::unlink(path.c_str());
return ok;
}
// Whether a failure to create a file beside the target means only that the
// directory will not take a new name (not writable, name too long), so the
// target itself may still be rewritten in place. Anything else (ENOSPC,
// EDQUOT, EIO, ...) would most likely hit the in-place write too, after it
// had already truncated the file.
static bool
may_write_in_place_after(int create_errno)
{
return create_errno == EACCES || create_errno == EPERM || create_errno == ENAMETOOLONG;
}
static bool
atomic_write_file(const std::string &path_in, const ByteChunks &data, std::string &err)
{
// Write through a symlink to its target: renaming over the link itself
// would replace the link with a regular file.
std::string path = path_in;
try {
if (std::filesystem::is_symlink(path_in))
path = std::filesystem::weakly_canonical(path_in).string();
} catch (...) {
// Fall back to the given path.
}
struct stat dst_st{};
const bool dst_exists = ::stat(path.c_str(), &dst_st) == 0;
// Never replace a directory, device node, FIFO or socket with a file.
if (dst_exists && !S_ISREG(dst_st.st_mode)) {
err = "Refusing to overwrite a non-regular file: " + path;
return false;
}
// A file with other hard links must be rewritten in place, or those
// names would keep the old content.
if (dst_exists && S_ISREG(dst_st.st_mode) && dst_st.st_nlink > 1) {
// Truncating in place risks the only copy if the write then fails
// (ENOSPC, EIO, crash), so first write and sync a copy beside it. If
// the directory will not take a new file (not writable, name too
// long), write in place anyway rather than refuse to save; any
// other failure (out of space, I/O error) refuses the save.
std::string copy = path + ".kte-save.XXXXXX";
std::string copy_err;
int copy_errno = 0;
const bool have_copy = write_new_file(copy, data, copy_err, copy_errno);
if (!have_copy && !may_write_in_place_after(copy_errno)) {
err = "Save failed, file left unchanged: " + copy_err;
return false;
}
if (!write_in_place(path, data, err)) {
if (have_copy)
err += " (the new content is in " + copy + ")";
return false;
}
if (have_copy)
(void) ::unlink(copy.c_str());
best_effort_fsync_dir(path);
return true;
}
// Create a temp file in the same directory so rename() is atomic. // Create a temp file in the same directory so rename() is atomic.
std::filesystem::path p(path); std::filesystem::path p(path);
std::filesystem::path dir = p.parent_path(); std::filesystem::path dir = p.parent_path();
@@ -146,27 +280,54 @@ atomic_write_file(const std::string &path, const char *data, std::size_t len, st
// mkstemp requires a mutable buffer. // mkstemp requires a mutable buffer.
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end()); std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
buf.push_back('\0'); buf.push_back('\0');
int fd = ::mkstemp(buf.data());
// Retry on transient errors for temp file creation
int fd = -1;
auto mkstemp_fn = [&]() -> bool {
// Reset buffer for each retry attempt
buf.assign(tmpl_s.begin(), tmpl_s.end());
buf.push_back('\0');
fd = kte::syscall::Mkstemp(buf.data());
return fd >= 0;
};
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) {
const int saved_errno = errno;
// No temp file in the directory because the directory is not
// writable (the file is): an existing regular file can still be
// written in place, as editors traditionally do. Not on ENOSPC and
// the like: the in-place write would truncate the file and then fail.
if (fd < 0 && dst_exists && S_ISREG(dst_st.st_mode) && may_write_in_place_after(saved_errno)) {
err.clear();
return write_in_place(path, data, err);
}
if (fd < 0) { if (fd < 0) {
err = std::string("Failed to create temp file for save: ") + std::strerror(errno); err = std::string("Failed to create temp file for save: ") + std::strerror(saved_errno) + err;
}
return false; return false;
} }
std::string tmp_path(buf.data()); std::string tmp_path(buf.data());
// If the destination exists, carry over its permissions. bool ok = write_all_fd(fd, data, err);
struct stat dst_st{}; // Ownership and mode go on after the data: without privilege, both
if (::stat(path.c_str(), &dst_st) == 0) { // chown and write clear setuid/setgid, so the mode is applied last.
(void) ::fchmod(fd, dst_st.st_mode); if (ok && dst_exists) {
if (::fchown(fd, dst_st.st_uid, dst_st.st_gid) != 0) {
// Expected without privilege when the owner differs; keep ours.
} }
(void) kte::syscall::Fchmod(fd, dst_st.st_mode & 07777);
bool ok = write_all_fd(fd, data, len, err); } else if (ok) {
if (ok) { // mkstemp creates 0600; a new file gets the usual 0666 & ~umask.
if (::fsync(fd) != 0) { (void) kte::syscall::Fchmod(fd, 0666 & ~g_process_umask);
}
// Never retry fsync: after a writeback error Linux may mark the pages
// clean, so a second fsync can succeed although the data was lost, and
// the rename below would then replace a good file with a bad one.
if (ok && kte::syscall::Fsync(fd) != 0) {
err = std::string("fsync failed: ") + std::strerror(errno); err = std::string("fsync failed: ") + std::strerror(errno);
ok = false; ok = false;
} }
} (void) kte::syscall::Close(fd);
(void) ::close(fd);
if (ok) { if (ok) {
if (::rename(tmp_path.c_str(), path.c_str()) != 0) { if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
@@ -196,46 +357,31 @@ Buffer::Buffer(const Buffer &other)
{ {
curx_ = other.curx_; curx_ = other.curx_;
cury_ = other.cury_; cury_ = other.cury_;
rx_ = other.rx_;
nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_; rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_; coloffs_ = other.coloffs_;
rows_ = other.rows_;
content_ = other.content_; content_ = other.content_;
rows_cache_dirty_ = other.rows_cache_dirty_;
filename_ = other.filename_; filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
is_virtual_ = other.is_virtual_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_; read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_; mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_; mark_cury_ = other.mark_cury_;
// Copy syntax/highlighting flags // Copy edit mode + syntax/highlighting flags
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
syntax_user_override_ = other.syntax_user_override_;
filetype_ = other.filetype_; filetype_ = other.filetype_;
// Fresh undo system for the copy // Fresh undo system for the copy
undo_tree_ = std::make_unique<UndoTree>(); undo_tree_ = std::make_unique<UndoTree>();
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_); undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
// Recreate a highlighter engine for this copy based on filetype/syntax state // Recreate a highlighter engine for this copy based on filetype/syntax state
if (syntax_enabled_) { if (syntax_enabled_)
// Allocate engine and install an appropriate highlighter InstallFiletypeHighlighter();
highlighter_ = std::make_unique<kte::HighlighterEngine>();
if (!filetype_.empty()) {
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
if (hl) {
highlighter_->SetHighlighter(std::move(hl));
} else {
// Unsupported filetype -> NullHighlighter keeps syntax pipeline active
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
}
} else {
// No filetype -> keep syntax enabled but use NullHighlighter
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
}
// Fresh engine has empty caches; nothing to invalidate
}
} }
@@ -246,22 +392,23 @@ Buffer::operator=(const Buffer &other)
return *this; return *this;
curx_ = other.curx_; curx_ = other.curx_;
cury_ = other.cury_; cury_ = other.cury_;
rx_ = other.rx_;
nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_; rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_; coloffs_ = other.coloffs_;
rows_ = other.rows_;
content_ = other.content_; content_ = other.content_;
rows_cache_dirty_ = other.rows_cache_dirty_;
filename_ = other.filename_; filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
is_virtual_ = other.is_virtual_;
id_ = NextBufferId(); // a copy is a different document
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_; read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_; mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_; mark_cury_ = other.mark_cury_;
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
syntax_user_override_ = other.syntax_user_override_;
filetype_ = other.filetype_; filetype_ = other.filetype_;
// Recreate undo system for this instance // Recreate undo system for this instance
undo_tree_ = std::make_unique<UndoTree>(); undo_tree_ = std::make_unique<UndoTree>();
@@ -269,19 +416,8 @@ Buffer::operator=(const Buffer &other)
// Recreate highlighter engine consistent with syntax settings // Recreate highlighter engine consistent with syntax settings
highlighter_.reset(); highlighter_.reset();
if (syntax_enabled_) { if (syntax_enabled_)
highlighter_ = std::make_unique<kte::HighlighterEngine>(); InstallFiletypeHighlighter();
if (!filetype_.empty()) {
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
if (hl) {
highlighter_->SetHighlighter(std::move(hl));
} else {
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
}
} else {
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
}
}
return *this; return *this;
} }
@@ -290,28 +426,38 @@ Buffer::operator=(const Buffer &other)
Buffer::Buffer(Buffer &&other) noexcept Buffer::Buffer(Buffer &&other) noexcept
: curx_(other.curx_), : curx_(other.curx_),
cury_(other.cury_), cury_(other.cury_),
rx_(other.rx_),
nrows_(other.nrows_),
rowoffs_(other.rowoffs_), rowoffs_(other.rowoffs_),
coloffs_(other.coloffs_), coloffs_(other.coloffs_),
rows_(std::move(other.rows_)),
filename_(std::move(other.filename_)), filename_(std::move(other.filename_)),
is_file_backed_(other.is_file_backed_), is_file_backed_(other.is_file_backed_),
is_virtual_(other.is_virtual_),
id_(other.id_),
dirty_(other.dirty_), dirty_(other.dirty_),
read_only_(other.read_only_), read_only_(other.read_only_),
mark_set_(other.mark_set_), mark_set_(other.mark_set_),
mark_curx_(other.mark_curx_), mark_curx_(other.mark_curx_),
mark_cury_(other.mark_cury_), mark_cury_(other.mark_cury_),
visual_line_active_(other.visual_line_active_),
visual_line_anchor_y_(other.visual_line_anchor_y_),
visual_line_active_y_(other.visual_line_active_y_),
undo_tree_(std::move(other.undo_tree_)), undo_tree_(std::move(other.undo_tree_)),
undo_sys_(std::move(other.undo_sys_)) undo_sys_(std::move(other.undo_sys_))
{ {
// Move syntax/highlighting state // Move edit mode + syntax/highlighting state
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
syntax_user_override_ = other.syntax_user_override_;
filetype_ = std::move(other.filetype_); filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_); highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_); content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_; on_disk_identity_ = other.on_disk_identity_;
// Non-owning: the recorder object itself is owned by SwapManager and outlives
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
// so the journal's Buffer* key follows this object to its new address.
swap_rec_ = other.swap_rec_;
other.swap_rec_ = nullptr;
// Update UndoSystem's buffer reference to point to this object // Update UndoSystem's buffer reference to point to this object
if (undo_sys_) { if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this); undo_sys_->UpdateBufferReference(*this);
@@ -328,28 +474,38 @@ Buffer::operator=(Buffer &&other) noexcept
curx_ = other.curx_; curx_ = other.curx_;
cury_ = other.cury_; cury_ = other.cury_;
rx_ = other.rx_;
nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_; rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_; coloffs_ = other.coloffs_;
rows_ = std::move(other.rows_);
filename_ = std::move(other.filename_); filename_ = std::move(other.filename_);
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
is_virtual_ = other.is_virtual_;
id_ = other.id_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_; read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_; mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_; mark_cury_ = other.mark_cury_;
visual_line_active_ = other.visual_line_active_;
visual_line_anchor_y_ = other.visual_line_anchor_y_;
visual_line_active_y_ = other.visual_line_active_y_;
undo_tree_ = std::move(other.undo_tree_); undo_tree_ = std::move(other.undo_tree_);
undo_sys_ = std::move(other.undo_sys_); undo_sys_ = std::move(other.undo_sys_);
// Move syntax/highlighting state // Move edit mode + syntax/highlighting state
edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
syntax_user_override_ = other.syntax_user_override_;
filetype_ = std::move(other.filetype_); filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_); highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_); content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_; on_disk_identity_ = other.on_disk_identity_;
// Non-owning: the recorder object itself is owned by SwapManager and outlives
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
// so the journal's Buffer* key follows this object to its new address.
swap_rec_ = other.swap_rec_;
other.swap_rec_ = nullptr;
// Update UndoSystem's buffer reference to point to this object // Update UndoSystem's buffer reference to point to this object
if (undo_sys_) { if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this); undo_sys_->UpdateBufferReference(*this);
@@ -388,48 +544,95 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
const std::string norm = normalize_path(path); const std::string norm = normalize_path(path);
// If the file doesn't exist, initialize an empty, non-file-backed buffer // If the file doesn't exist, initialize an empty, non-file-backed buffer
// with the provided filename. Do not touch the filesystem until Save/SaveAs. // with the provided filename. Do not touch the filesystem until Save/SaveAs.
if (!std::filesystem::exists(norm)) { std::error_code exists_ec;
rows_.clear(); const bool exists = std::filesystem::exists(norm, exists_ec);
nrows_ = 0; if (exists_ec) {
// e.g. name too long, or a parent directory we may not search
err = "Cannot open " + norm + ": " + exists_ec.message();
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
if (!exists) {
filename_ = norm; filename_ = norm;
is_file_backed_ = false; is_file_backed_ = false;
is_virtual_ = false;
dirty_ = false; dirty_ = false;
// Reset cursor/viewport state // Reset cursor/viewport state
curx_ = cury_ = rx_ = 0; curx_ = cury_ = 0;
rowoffs_ = coloffs_ = 0; rowoffs_ = coloffs_ = 0;
mark_set_ = false; mark_set_ = false;
mark_curx_ = mark_cury_ = 0; mark_curx_ = mark_cury_ = 0;
// Empty PieceTable // Empty PieceTable
content_.Clear(); content_.Clear();
rows_cache_dirty_ = true; // History recorded against the previous content no longer applies.
if (undo_sys_)
undo_sys_->clear();
MarkContentChanged();
return true; return true;
} }
std::ifstream in(norm, std::ios::in | std::ios::binary); // Open non-blocking so a named pipe cannot hang the editor, then accept
if (!in) { // regular files only: a directory reads as garbage (or throws), a FIFO
err = "Failed to open file: " + norm; // blocks, and saving over a device node would replace it with a file.
int oflags = O_RDONLY | O_NONBLOCK;
#ifdef O_CLOEXEC
oflags |= O_CLOEXEC;
#endif
const int fd = kte::syscall::Open(norm.c_str(), oflags);
if (fd < 0) {
err = "Failed to open file: " + norm + ": " + std::strerror(errno);
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
struct stat st{};
if (kte::syscall::Fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
err = S_ISDIR(st.st_mode) ? ("Is a directory: " + norm) : ("Not a regular file: " + norm);
(void) kte::syscall::Close(fd);
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false; return false;
} }
// Read entire file into PieceTable as-is // Read to EOF in chunks (the file may change size while we read).
std::string data; std::string data;
in.seekg(0, std::ios::end); try {
auto sz = in.tellg(); data.reserve(static_cast<std::size_t>(st.st_size));
if (sz > 0) { char chunk[1 << 16];
data.resize(static_cast<std::size_t>(sz)); for (;;) {
in.seekg(0, std::ios::beg); const ssize_t n = ::read(fd, chunk, sizeof(chunk));
in.read(data.data(), static_cast<std::streamsize>(data.size())); if (n > 0) {
data.append(chunk, static_cast<std::size_t>(n));
} else if (n == 0) {
break;
} else if (errno != EINTR) {
err = "Failed to read file: " + norm + ": " + std::strerror(errno);
(void) kte::syscall::Close(fd);
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
}
} catch (const std::bad_alloc &) {
(void) kte::syscall::Close(fd);
err = "File too large to load: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
(void) kte::syscall::Close(fd);
// The bytes become the piece table's original storage, uncopied. If that
// runs out of memory the buffer keeps its old text (clearing first left
// an empty buffer that a later save wrote over the file).
try {
content_.AdoptOriginal(std::move(data));
} catch (const std::bad_alloc &) {
err = "File too large to load: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
} }
content_.Clear();
if (!data.empty())
content_.Append(data.data(), data.size());
rows_cache_dirty_ = true;
nrows_ = 0; // not used under PieceTable
filename_ = norm; filename_ = norm;
is_file_backed_ = true; is_file_backed_ = true;
is_virtual_ = false;
dirty_ = false; dirty_ = false;
RefreshOnDiskIdentity(); RefreshOnDiskIdentity();
@@ -440,9 +643,10 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_); undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
// Clear any existing history for a fresh load // Clear any existing history for a fresh load
undo_sys_->clear(); undo_sys_->clear();
MarkContentChanged();
// Reset cursor/viewport state // Reset cursor/viewport state
curx_ = cury_ = rx_ = 0; curx_ = cury_ = 0;
rowoffs_ = coloffs_ = 0; rowoffs_ = coloffs_ = 0;
mark_set_ = false; mark_set_ = false;
mark_curx_ = mark_cury_ = 0; mark_curx_ = mark_cury_ = 0;
@@ -458,14 +662,14 @@ Buffer::Save(std::string &err) const
err = "Buffer is not file-backed; use SaveAs()"; err = "Buffer is not file-backed; use SaveAs()";
return false; return false;
} }
const std::size_t sz = content_.Size(); ByteChunks chunks;
const char *data = sz ? content_.Data() : nullptr; content_.ForEachChunk([&](const char *d, std::size_t n) {
if (sz && !data) { chunks.emplace_back(d, n);
err = "Internal error: buffer materialization failed"; });
if (!atomic_write_file(filename_, chunks, err)) {
kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
return false; return false;
} }
if (!atomic_write_file(filename_, data ? data : "", sz, err))
return false;
// Update observed on-disk identity after a successful save. // Update observed on-disk identity after a successful save.
const_cast<Buffer *>(this)->RefreshOnDiskIdentity(); const_cast<Buffer *>(this)->RefreshOnDiskIdentity();
// Note: const method cannot change dirty_. Intentionally const to allow UI code // Note: const method cannot change dirty_. Intentionally const to allow UI code
@@ -496,17 +700,18 @@ Buffer::SaveAs(const std::string &path, std::string &err)
out_path = path; out_path = path;
} }
const std::size_t sz = content_.Size(); ByteChunks chunks;
const char *data = sz ? content_.Data() : nullptr; content_.ForEachChunk([&](const char *d, std::size_t n) {
if (sz && !data) { chunks.emplace_back(d, n);
err = "Internal error: buffer materialization failed"; });
if (!atomic_write_file(out_path, chunks, err)) {
kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
return false; return false;
} }
if (!atomic_write_file(out_path, data ? data : "", sz, err))
return false;
filename_ = out_path; filename_ = out_path;
is_file_backed_ = true; is_file_backed_ = true;
is_virtual_ = false;
dirty_ = false; dirty_ = false;
RefreshOnDiskIdentity(); RefreshOnDiskIdentity();
return true; return true;
@@ -538,43 +743,54 @@ Buffer::insert_text(int row, int col, std::string_view text)
static_cast<std::size_t>(col)); static_cast<std::size_t>(col));
if (!text.empty()) { if (!text.empty()) {
content_.Insert(off, text.data(), text.size()); content_.Insert(off, text.data(), text.size());
rows_cache_dirty_ = true; edited_at_(row);
if (swap_rec_) if (swap_rec_)
swap_rec_->OnInsert(row, col, text); swap_rec_->OnInsert(row, col, text);
} }
} }
// ===== Adapter helpers for PieceTable-backed Buffer ===== void
std::string_view Buffer::ApplyDetectedFiletype()
Buffer::GetLineView(std::size_t row) const
{ {
// Get byte range for the logical line and return a view into materialized data EnsureHighlighter();
auto range = content_.GetLineRange(row); // [start,end) in bytes const std::string first = Nrows() > 0 ? GetLineString(0) : std::string();
const char *base = content_.Data(); // materializes if needed const std::string ft = kte::HighlighterRegistry::DetectForPath(filename_, first);
if (!base) SetFiletype(ft);
return std::string_view(); SetSyntaxEnabled(true);
const std::size_t start = range.first; InstallFiletypeHighlighter();
const std::size_t len = (range.second > range.first) ? (range.second - range.first) : 0;
return std::string_view(base + start, len);
} }
void void
Buffer::ensure_rows_cache() const Buffer::InstallFiletypeHighlighter()
{ {
std::lock_guard<std::mutex> lock(buffer_mutex_); EnsureHighlighter();
if (!rows_cache_dirty_) auto hl = filetype_.empty() ? nullptr : kte::HighlighterRegistry::CreateFor(filetype_);
return; if (!hl)
rows_.clear(); hl = std::make_unique<kte::NullHighlighter>();
const std::size_t lc = content_.LineCount(); highlighter_->SetHighlighter(std::move(hl));
rows_.reserve(lc); }
for (std::size_t i = 0; i < lc; ++i) {
rows_.emplace_back(content_.GetLine(i));
} // ===== Adapter helpers for PieceTable-backed Buffer =====
// Keep nrows_ in sync for any legacy code that still reads it std::string
const_cast<Buffer *>(this)->nrows_ = rows_.size(); Buffer::Bytes() const
rows_cache_dirty_ = false; {
std::string out;
out.reserve(content_.Size());
content_.ForEachChunk([&](const char *d, std::size_t n) {
out.append(d, n);
});
return out;
}
std::string_view
Buffer::GetLineView(std::size_t row) const
{
const auto [start, end] = content_.GetLineRange(row); // [start,end) in bytes
return end > start ? content_.View(start, end - start) : std::string_view();
} }
@@ -589,13 +805,17 @@ Buffer::content_LineCount_() const
std::string std::string
Buffer::BytesForTests() const Buffer::BytesForTests() const
{ {
const std::size_t sz = content_.Size(); return Bytes();
if (sz == 0) }
return std::string();
const char *data = content_.Data();
if (!data) std::vector<std::string>
return std::string(); Buffer::LinesForTests() const
return std::string(data, data + sz); {
std::vector<std::string> out;
for (std::size_t i = 0; i < Nrows(); ++i)
out.push_back(GetLineString(i));
return out;
} }
#endif #endif
@@ -610,51 +830,18 @@ Buffer::delete_text(int row, int col, std::size_t len)
if (col < 0) if (col < 0)
col = 0; col = 0;
// `len` counts each newline as one character, so the deletion is simply
// the next `len` bytes from the (clamped) start, capped at end of buffer.
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row), const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
static_cast<std::size_t>(col)); static_cast<std::size_t>(col));
std::size_t r = static_cast<std::size_t>(row);
std::size_t c = static_cast<std::size_t>(col);
std::size_t remaining = len;
const std::size_t lc = content_.LineCount();
while (remaining > 0 && r < lc) {
const std::string line = content_.GetLine(r); // logical line (without trailing '\n')
const std::size_t L = line.size();
if (c < L) {
const std::size_t take = std::min(remaining, L - c);
c += take;
remaining -= take;
}
if (remaining == 0)
break;
// Consume newline between lines as one char, if there is a next line
if (r + 1 < lc) {
remaining -= 1; // the newline
r += 1;
c = 0;
} else {
// At last line and still remaining: delete to EOF
const std::size_t total = content_.Size(); const std::size_t total = content_.Size();
const std::size_t actual = (total > start) ? (total - start) : 0; const std::size_t actual = (total > start) ? std::min(len, total - start) : 0;
if (actual == 0) if (actual == 0)
return; return;
content_.Delete(start, actual); content_.Delete(start, actual);
rows_cache_dirty_ = true; edited_at_(row);
if (swap_rec_) if (swap_rec_)
swap_rec_->OnDelete(row, col, actual); swap_rec_->OnDelete(row, col, actual);
return;
}
}
// Compute end offset at (r,c)
std::size_t end = content_.LineColToByteOffset(r, c);
if (end > start) {
const std::size_t actual = end - start;
content_.Delete(start, actual);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, actual);
}
} }
@@ -670,7 +857,7 @@ Buffer::split_line(int row, const int col)
static_cast<std::size_t>(c)); static_cast<std::size_t>(c));
const char nl = '\n'; const char nl = '\n';
content_.Insert(off, &nl, 1); content_.Insert(off, &nl, 1);
rows_cache_dirty_ = true; edited_at_(row);
if (swap_rec_) if (swap_rec_)
swap_rec_->OnInsert(row, c, std::string_view("\n", 1)); swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
} }
@@ -689,7 +876,7 @@ Buffer::join_lines(int row)
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max()); std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position. // end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
content_.Delete(end_of_line, 1); content_.Delete(end_of_line, 1);
rows_cache_dirty_ = true; edited_at_(row);
if (swap_rec_) if (swap_rec_)
swap_rec_->OnDelete(row, col, 1); swap_rec_->OnDelete(row, col, 1);
} }
@@ -705,12 +892,16 @@ Buffer::insert_row(int row, const std::string_view text)
content_.Insert(off, text.data(), text.size()); content_.Insert(off, text.data(), text.size());
const char nl = '\n'; const char nl = '\n';
content_.Insert(off + text.size(), &nl, 1); content_.Insert(off + text.size(), &nl, 1);
rows_cache_dirty_ = true; edited_at_(row);
if (swap_rec_) { if (swap_rec_) {
// Avoid allocation: emit the row text insertion (if any) and the newline insertion. // One record: the first of two could trigger a journal checkpoint,
if (!text.empty()) // whose snapshot already holds the newline the second then added
swap_rec_->OnInsert(row, 0, text); // again on replay.
swap_rec_->OnInsert(row, static_cast<int>(text.size()), std::string_view("\n", 1)); std::string line;
line.reserve(text.size() + 1);
line.append(text);
line.push_back('\n');
swap_rec_->OnInsert(row, 0, line);
} }
} }
@@ -732,7 +923,7 @@ Buffer::delete_row(int row)
if (actual == 0) if (actual == 0)
return; return;
content_.Delete(start, actual); content_.Delete(start, actual);
rows_cache_dirty_ = true; edited_at_(row);
if (swap_rec_) if (swap_rec_)
swap_rec_->OnDelete(row, 0, actual); swap_rec_->OnDelete(row, 0, actual);
} }
@@ -744,7 +935,50 @@ Buffer::replace_all_bytes(const std::string_view bytes)
content_.Clear(); content_.Clear();
if (!bytes.empty()) if (!bytes.empty())
content_.Append(bytes.data(), bytes.size()); content_.Append(bytes.data(), bytes.size());
rows_cache_dirty_ = true; if (undo_sys_)
undo_sys_->clear();
MarkContentChanged();
}
void
Buffer::insert_spans(int row, int col, const std::vector<TextSpan> &spans)
{
if (row < 0)
row = 0;
if (col < 0)
col = 0;
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
static_cast<std::size_t>(col));
content_.InsertSpans(off, spans);
edited_at_(row);
if (swap_rec_) {
// The journal needs the bytes as one record (see insert_row).
std::string text;
content_.VisitSpans(spans, [&](const char *d, std::size_t n) {
text.append(d, n);
});
if (!text.empty())
swap_rec_->OnInsert(row, col, text);
}
}
std::vector<TextSpan>
Buffer::SpansAt(int row, int col, std::size_t len) const
{
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(std::max(row, 0)),
static_cast<std::size_t>(std::max(col, 0)));
return content_.SpansInRange(off, len);
}
std::vector<TextSpan>
Buffer::TakeDeletedSpans(int row, int col, std::size_t len)
{
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(std::max(row, 0)),
static_cast<std::size_t>(std::max(col, 0)));
return content_.TakeDeletedSpans(off, len);
} }
+241 -195
View File
@@ -1,11 +1,41 @@
/* /*
* Buffer.h - editor buffer representing an open document * Buffer.h - editor buffer representing an open document
*
* Buffer is the central document model in kte. Each Buffer represents one open file
* or scratch document and manages:
*
* - Content storage: Uses PieceTable for efficient text operations
* - Cursor state: Current position (curx_, cury_)
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
* - File backing: Optional association with a file on disk
* - Undo/Redo: Integrated UndoSystem for operation history
* - Syntax highlighting: Optional HighlighterEngine for language-aware coloring
* - Swap/crash recovery: Integration with SwapRecorder for journaling
* - Dirty tracking: Modification state for save prompts
*
* Key concepts:
*
* 1. Cursor coordinates:
* - (curx_, cury_): Logical character position in the document
*
* 2. File backing:
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
* - File identity tracking detects external modifications
*
* 3. Content access:
* - Nrows(), GetLineString(): per-line access
* - GetLineView(): line access via string_view (zero-copy within a piece)
* - Direct PieceTable access for new editing operations
*/ */
#pragma once #pragma once
#include <atomic>
#include <algorithm>
#include <cstddef> #include <cstddef>
#include <filesystem>
#include <memory> #include <memory>
#include <string> #include <string>
#include <unordered_set>
#include <vector> #include <vector>
#include <string_view> #include <string_view>
@@ -14,7 +44,26 @@
#include <cstdint> #include <cstdint>
#include "syntax/HighlighterEngine.h" #include "syntax/HighlighterEngine.h"
#include "Highlight.h" #include "Highlight.h"
#include <mutex>
// Edit mode determines which font class is used for a buffer.
enum class EditMode { Code, Writing };
// Detect edit mode from a filename's extension.
inline EditMode
DetectEditMode(const std::string &filename)
{
std::string ext = std::filesystem::path(filename).extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
static const std::unordered_set<std::string> writing_exts = {
".txt", ".md", ".markdown", ".rst", ".org",
".tex", ".adoc", ".asciidoc",
};
if (writing_exts.count(ext))
return EditMode::Writing;
return EditMode::Code;
}
// Forward declaration for swap journal integration // Forward declaration for swap journal integration
namespace kte { namespace kte {
@@ -63,12 +112,6 @@ public:
} }
[[nodiscard]] std::size_t Rx() const
{
return rx_;
}
[[nodiscard]] std::size_t Nrows() const [[nodiscard]] std::size_t Nrows() const
{ {
return content_LineCount_(); return content_LineCount_();
@@ -87,169 +130,7 @@ public:
} }
// Line wrapper used by legacy command paths. // A line's text without its trailing newline.
// Keep this lightweight: store materialized bytes only for that line.
class Line {
public:
Line() = default;
explicit Line(const char *s)
{
assign_from(s ? std::string(s) : std::string());
}
explicit Line(const std::string &s)
{
assign_from(s);
}
Line(const Line &other) = default;
Line &operator=(const Line &other) = default;
Line(Line &&other) noexcept = default;
Line &operator=(Line &&other) noexcept = default;
// capacity helpers
void Clear()
{
s_.clear();
}
// size/access
[[nodiscard]] std::size_t size() const
{
return s_.size();
}
[[nodiscard]] bool empty() const
{
return s_.empty();
}
// read-only raw view
[[nodiscard]] const char *Data() const
{
return s_.data();
}
[[nodiscard]] std::size_t Size() const
{
return s_.size();
}
// element access (read-only)
[[nodiscard]] char operator[](std::size_t i) const
{
return (i < s_.size()) ? s_[i] : '\0';
}
// conversions
explicit operator std::string() const
{
return s_;
}
// string-like API used by command/renderer layers (implemented via materialization for now)
[[nodiscard]] std::string substr(std::size_t pos) const
{
return pos < s_.size() ? s_.substr(pos) : std::string();
}
[[nodiscard]] std::string substr(std::size_t pos, std::size_t len) const
{
return pos < s_.size() ? s_.substr(pos, len) : std::string();
}
// minimal find() to support search within a line
[[nodiscard]] std::size_t find(const std::string &needle, const std::size_t pos = 0) const
{
return s_.find(needle, pos);
}
void erase(std::size_t pos)
{
if (pos < s_.size())
s_.erase(pos);
}
void erase(std::size_t pos, std::size_t len)
{
if (pos < s_.size())
s_.erase(pos, len);
}
void insert(std::size_t pos, const std::string &seg)
{
if (pos > s_.size())
pos = s_.size();
s_.insert(pos, seg);
}
Line &operator+=(const Line &other)
{
s_ += other.s_;
return *this;
}
Line &operator+=(const std::string &s)
{
s_ += s;
return *this;
}
Line &operator=(const std::string &s)
{
assign_from(s);
return *this;
}
private:
void assign_from(const std::string &s)
{
s_ = s;
}
std::string s_;
};
[[nodiscard]] const std::vector<Line> &Rows() const
{
ensure_rows_cache();
return rows_;
}
[[nodiscard]] std::vector<Line> &Rows()
{
ensure_rows_cache();
return rows_;
}
// Lightweight, lazy per-line accessors that avoid materializing all rows.
// Prefer these over Rows() in hot paths to reduce memory overhead on large files.
[[nodiscard]] std::string GetLineString(std::size_t row) const [[nodiscard]] std::string GetLineString(std::size_t row) const
{ {
return content_.GetLine(row); return content_.GetLine(row);
@@ -262,10 +143,25 @@ public:
} }
// Zero-copy view of a line. Points into the materialized backing store; becomes // View of a line's raw bytes, including its trailing '\n' if any. No copy
// invalid after subsequent edits. Use immediately. // when the line lies within one piece (an unedited stretch); a line that
// straddles an edit materializes the whole buffer, so per-row loops over
// an edited buffer should prefer GetLineString(). Invalid after the next
// edit; views of different lines need not be contiguous.
[[nodiscard]] std::string_view GetLineView(std::size_t row) const; [[nodiscard]] std::string_view GetLineView(std::size_t row) const;
// The whole text as one contiguous view (materializes an edited buffer).
// Invalid after the next edit.
[[nodiscard]] std::string_view ContentView() const
{
return content_.ContentView();
}
// A copy of the whole text, assembled from the pieces (no materialized
// copy is kept).
[[nodiscard]] std::string Bytes() const;
[[nodiscard]] const std::string &Filename() const [[nodiscard]] const std::string &Filename() const
{ {
@@ -279,6 +175,7 @@ public:
{ {
filename_ = name; filename_ = name;
is_file_backed_ = false; is_file_backed_ = false;
is_virtual_ = true;
} }
@@ -288,6 +185,23 @@ public:
} }
// A virtual buffer (e.g. "+HELP+") has a display name that is not a path:
// it is never journaled, and saving it asks for a file name.
[[nodiscard]] bool IsVirtual() const
{
return is_virtual_;
}
// Identity of this document, stable for its lifetime, including when the
// Buffer object is moved (e.g. by the editor's buffer vector). Copies get
// a new id. Use it, not the object's address, to key per-buffer caches.
[[nodiscard]] std::uint64_t Id() const
{
return id_;
}
[[nodiscard]] bool Dirty() const [[nodiscard]] bool Dirty() const
{ {
return dirty_; return dirty_;
@@ -320,12 +234,6 @@ public:
} }
void SetRenderX(const std::size_t rx)
{
rx_ = rx;
}
void SetOffsets(const std::size_t row, const std::size_t col) void SetOffsets(const std::size_t row, const std::size_t col)
{ {
rowoffs_ = row; rowoffs_ = row;
@@ -335,13 +243,17 @@ public:
void SetDirty(bool d) void SetDirty(bool d)
{ {
// The version tracks content, not the dirty flag: every text mutation
// bumps it (see edited_at_), including undo/redo back to the saved state.
dirty_ = d; dirty_ = d;
if (d) {
++version_;
if (highlighter_) {
highlighter_->InvalidateFrom(0);
}
} }
// Whole-buffer content change (load, reload, replace): bump the version
// and drop all cached highlighting.
void MarkContentChanged()
{
edited_at_(0);
} }
@@ -414,15 +326,22 @@ public:
} }
// The selected rows, clamped to the buffer: they are stored as row numbers
// and go stale when lines are removed, and commands must never act on rows
// past the end (edits there were misplaced and could not be undone).
[[nodiscard]] std::size_t VisualLineStartY() const [[nodiscard]] std::size_t VisualLineStartY() const
{ {
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_; const std::size_t y = visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_
: visual_line_active_y_;
return clamp_row_(y);
} }
[[nodiscard]] std::size_t VisualLineEndY() const [[nodiscard]] std::size_t VisualLineEndY() const
{ {
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_y_; const std::size_t y = visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_
: visual_line_anchor_y_;
return clamp_row_(y);
} }
@@ -445,6 +364,15 @@ public:
[[nodiscard]] std::string AsString() const; [[nodiscard]] std::string AsString() const;
private:
[[nodiscard]] std::size_t clamp_row_(std::size_t y) const
{
const std::size_t n = Nrows();
return (n == 0) ? 0 : (y < n ? y : n - 1);
}
public:
// Syntax highlighting integration (per-buffer) // Syntax highlighting integration (per-buffer)
[[nodiscard]] std::uint64_t Version() const [[nodiscard]] std::uint64_t Version() const
{ {
@@ -452,6 +380,35 @@ public:
} }
// Edit mode (code vs writing)
[[nodiscard]] EditMode GetEditMode() const
{
return edit_mode_;
}
void SetEditMode(EditMode m)
{
edit_mode_ = m;
edit_mode_detected_ = true;
}
void ToggleEditMode()
{
edit_mode_ = (edit_mode_ == EditMode::Code)
? EditMode::Writing
: EditMode::Code;
edit_mode_detected_ = true;
}
[[nodiscard]] bool EditModeDetected() const
{
return edit_mode_detected_;
}
void SetSyntaxEnabled(bool on) void SetSyntaxEnabled(bool on)
{ {
syntax_enabled_ = on; syntax_enabled_ = on;
@@ -464,6 +421,25 @@ public:
} }
// Marks that the user explicitly set syntax state via a command (:syntax
// on/off, :set filetype=...), as opposed to it being auto-applied from
// GUIConfig. Frontends that re-apply the config-driven default every
// frame (e.g. ImGuiFrontend::apply_syntax_to_buffer) must check this
// first, mirroring EditModeDetected()'s "don't stomp a manual toggle"
// pattern - otherwise a manual :syntax off is silently undone on the very
// next frame.
void SetSyntaxUserOverride(bool on)
{
syntax_user_override_ = on;
}
[[nodiscard]] bool SyntaxUserOverride() const
{
return syntax_user_override_;
}
void SetFiletype(const std::string &ft) void SetFiletype(const std::string &ft)
{ {
filetype_ = ft; filetype_ = ft;
@@ -495,6 +471,15 @@ public:
} }
// Enable syntax with the highlighter the filename (or a shebang on the
// first line) calls for, or none for an unknown type.
void ApplyDetectedFiletype();
// Install the highlighter for Filetype() (the null highlighter for an
// empty or unsupported one), creating the engine if needed.
void InstallFiletypeHighlighter();
// Swap journal integration (set by Editor) // Swap journal integration (set by Editor)
void SetSwapRecorder(kte::SwapRecorder *rec) void SetSwapRecorder(kte::SwapRecorder *rec)
{ {
@@ -522,11 +507,55 @@ public:
void delete_row(int row); void delete_row(int row);
// Insert the bytes of storage spans (see TextSpan.h) at (row, col)
// without copying them into storage again. Used by undo for large texts.
void insert_spans(int row, int col, const std::vector<TextSpan> &spans);
// Replace the entire buffer content with raw bytes. // Replace the entire buffer content with raw bytes.
// Intended for crash recovery (swap replay) and test harnesses. // Intended for crash recovery (swap replay) and test harnesses.
// This does not trigger swap or undo recording. // This does not trigger swap or undo recording, and clears undo history
// (recorded against the previous content, which it also references).
void replace_all_bytes(std::string_view bytes); void replace_all_bytes(std::string_view bytes);
// ===== Span access for undo (see TextSpan.h) =====
[[nodiscard]] std::vector<TextSpan> SpansAt(int row, int col, std::size_t len) const;
// Spans removed by the most recent deletion, if it was exactly `len`
// bytes at (row, col) (see PieceTable::TakeDeletedSpans).
[[nodiscard]] std::vector<TextSpan> TakeDeletedSpans(int row, int col, std::size_t len);
[[nodiscard]] bool SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const
{
return content_.SpansEqual(spans, text);
}
template<typename Fn>
void VisitSpans(const std::vector<TextSpan> &spans, Fn &&fn) const
{
content_.VisitSpans(spans, std::forward<Fn>(fn));
}
// Total size of the content in bytes.
[[nodiscard]] std::size_t ContentBytes() const
{
return content_.Size();
}
// Byte offset of (row, col) in the content (clamped), and back.
[[nodiscard]] std::size_t RowColToOffset(std::size_t row, std::size_t col) const
{
return content_.LineColToByteOffset(row, col);
}
[[nodiscard]] std::pair<std::size_t, std::size_t> OffsetToRowCol(std::size_t off) const
{
return content_.ByteOffsetToLineCol(off);
}
// Undo system accessors (created per-buffer) // Undo system accessors (created per-buffer)
[[nodiscard]] UndoSystem *Undo(); [[nodiscard]] UndoSystem *Undo();
@@ -534,6 +563,10 @@ public:
#if defined(KTE_TESTS) #if defined(KTE_TESTS)
// Test-only: return the raw buffer bytes (including newlines) as a string. // Test-only: return the raw buffer bytes (including newlines) as a string.
// Every line without its newline (tests only).
[[nodiscard]] std::vector<std::string> LinesForTests() const;
[[nodiscard]] std::string BytesForTests() const; [[nodiscard]] std::string BytesForTests() const;
#endif #endif
@@ -554,22 +587,23 @@ private:
// State mirroring original C struct (without undo_tree) // State mirroring original C struct (without undo_tree)
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
std::size_t rx_ = 0; // render x (tabs expanded)
std::size_t nrows_ = 0; // number of rows
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
mutable std::vector<Line> rows_; // materialized cache of rows (without trailing newlines)
// PieceTable is the source of truth. // PieceTable is the source of truth.
PieceTable content_{}; PieceTable content_{};
mutable bool rows_cache_dirty_ = true; // invalidate on edits / I/O
// Helper to rebuild rows_ from content_
void ensure_rows_cache() const;
// Helper to query content_.LineCount() while keeping header minimal // Helper to query content_.LineCount() while keeping header minimal
std::size_t content_LineCount_() const; std::size_t content_LineCount_() const;
std::string filename_; std::string filename_;
bool is_file_backed_ = false; bool is_file_backed_ = false;
bool is_virtual_ = false;
std::uint64_t id_ = NextBufferId();
static std::uint64_t NextBufferId()
{
static std::atomic<std::uint64_t> next{1};
return next.fetch_add(1);
}
bool dirty_ = false; bool dirty_ = false;
bool read_only_ = false; bool read_only_ = false;
bool mark_set_ = false; bool mark_set_ = false;
@@ -582,13 +616,25 @@ private:
std::unique_ptr<struct UndoTree> undo_tree_; std::unique_ptr<struct UndoTree> undo_tree_;
std::unique_ptr<UndoSystem> undo_sys_; std::unique_ptr<UndoSystem> undo_sys_;
// Edit mode (code vs writing)
EditMode edit_mode_ = EditMode::Code;
bool edit_mode_detected_ = false; // true after initial auto-detection
// Syntax/highlighting state // Syntax/highlighting state
std::uint64_t version_ = 0; // increment on edits std::uint64_t version_ = 0; // increment on edits
// Record a content change starting at `row`: bump the version and let the
// highlighter drop cached rows from `row` down (rows above are unaffected).
void edited_at_(int row)
{
++version_;
if (highlighter_)
highlighter_->OnEdit(row < 0 ? 0 : row, version_);
}
bool syntax_enabled_ = true; bool syntax_enabled_ = true;
bool syntax_user_override_ = false; // true once user explicitly set syntax state via a command
std::string filetype_; std::string filetype_;
std::unique_ptr<kte::HighlighterEngine> highlighter_; std::unique_ptr<kte::HighlighterEngine> highlighter_;
// Non-owning pointer to swap recorder managed by Editor/SwapManager // Non-owning pointer to swap recorder managed by Editor/SwapManager
kte::SwapRecorder *swap_rec_ = nullptr; kte::SwapRecorder *swap_rec_ = nullptr;
mutable std::mutex buffer_mutex_;
}; };
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+130 -37
View File
@@ -4,16 +4,17 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.6.4") set(KTE_VERSION "1.13.0")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.") set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.") set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI") set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF) option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF) option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
option(KTE_STATIC_LINK "Enable static linking on Linux" OFF)
option(KTE_USE_PCRE2 "Use PCRE2 (libpcre2-8) for regex search when found; std::regex otherwise" ON)
# Optionally enable AddressSanitizer (ASan) # Optionally enable AddressSanitizer (ASan)
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF) option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
@@ -21,6 +22,8 @@ option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
if (ENABLE_ASAN) if (ENABLE_ASAN)
message(STATUS "ASan enabled") message(STATUS "ASan enabled")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=address -fno-omit-frame-pointer") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
# The project is C++; without this only C sources were instrumented.
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
# Ensure the sanitizer is linked too (especially important on some platforms) # Ensure the sanitizer is linked too (especially important on some platforms)
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address") set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
endif () endif ()
@@ -39,7 +42,6 @@ if (MSVC)
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>") add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
else () else ()
add_compile_options( add_compile_options(
"-static"
"-Wall" "-Wall"
"-Wextra" "-Wextra"
"-Werror" "-Werror"
@@ -51,6 +53,7 @@ else ()
) )
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
add_compile_options("-stdlib=libc++") add_compile_options("-stdlib=libc++")
add_link_options("-stdlib=libc++")
else () else ()
# nothing special for gcc at the moment # nothing special for gcc at the moment
endif () endif ()
@@ -63,15 +66,69 @@ endif ()
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}") message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
# Regex engine (see RegexEngine.h). Linked by name (-lpcre2-8) so that a
# static build picks up the static library.
if (KTE_USE_PCRE2)
find_package(PkgConfig QUIET)
if (PKG_CONFIG_FOUND)
pkg_check_modules(PCRE2 QUIET libpcre2-8)
endif ()
if (PCRE2_FOUND)
message(STATUS "Regex engine: PCRE2 ${PCRE2_VERSION}")
add_compile_definitions(KTE_USE_PCRE2)
include_directories(${PCRE2_INCLUDE_DIRS})
link_directories(${PCRE2_LIBRARY_DIRS})
link_libraries(${PCRE2_LIBRARIES})
else ()
message(STATUS "Regex engine: std::regex (libpcre2-8 not found)")
endif ()
else ()
message(STATUS "Regex engine: std::regex")
endif ()
if (BUILD_GUI) if (BUILD_GUI)
include(cmake/imgui.cmake) include(cmake/imgui.cmake)
endif () endif ()
# NCurses for terminal mode # NCurses for terminal mode
set(CURSES_NEED_NCURSES) set(CURSES_NEED_NCURSES TRUE)
set(CURSES_NEED_WIDE) set(CURSES_NEED_WIDE TRUE)
# macOS ships only a narrow-char ncurses in the SDK, and Homebrew's wide-char
# build is keg-only, so FindCurses can't see it without a hint.
if (APPLE)
find_program(BREW_EXECUTABLE brew)
if (BREW_EXECUTABLE)
execute_process(COMMAND "${BREW_EXECUTABLE}" --prefix ncurses
OUTPUT_VARIABLE HOMEBREW_NCURSES_PREFIX
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET)
if (HOMEBREW_NCURSES_PREFIX AND IS_DIRECTORY "${HOMEBREW_NCURSES_PREFIX}")
message(STATUS "Using Homebrew ncursesw: ${HOMEBREW_NCURSES_PREFIX}")
list(PREPEND CMAKE_PREFIX_PATH "${HOMEBREW_NCURSES_PREFIX}")
endif ()
endif ()
endif ()
find_package(Curses REQUIRED) find_package(Curses REQUIRED)
include_directories(${CURSES_INCLUDE_DIR})
# FindCurses exports CURSES_INCLUDE_DIRS; the singular CURSES_INCLUDE_DIR was
# never set, so this silently expanded to nothing and the compiler fell back
# to whatever curses header happened to be on the default include path.
include_directories(${CURSES_INCLUDE_DIRS})
# The wide-char entry points we rely on (get_wch, etc.) are declared only when
# NCURSES_WIDECHAR is set; the header defaults it to 0 unless a feature-test
# macro says otherwise. We always link the wide library, so ask for them.
add_compile_definitions(NCURSES_WIDECHAR=1)
# On Alpine Linux, CMake's FindCurses looks in wrong paths
# Manually find the correct ncurses library
if (EXISTS "/etc/alpine-release")
find_library(NCURSESW_LIB NAMES ncursesw PATHS /usr/lib /lib REQUIRED)
set(CURSES_LIBRARIES ${NCURSESW_LIB})
message(STATUS "Alpine Linux detected, using ncurses at: ${NCURSESW_LIB}")
endif ()
set(SYNTAX_SOURCES set(SYNTAX_SOURCES
syntax/GoHighlighter.cc syntax/GoHighlighter.cc
@@ -105,17 +162,6 @@ if (BUILD_GUI)
set(GUI_SOURCES set(GUI_SOURCES
GUIConfig.cc GUIConfig.cc
) )
if (KTE_USE_QT)
find_package(Qt6 COMPONENTS Widgets REQUIRED)
set(GUI_SOURCES
${GUI_SOURCES}
QtFrontend.cc
QtInputHandler.cc
QtRenderer.cc
)
# Expose preprocessor switch so sources can exclude ImGui-specific code
add_compile_definitions(KTE_USE_QT)
else ()
set(GUI_SOURCES set(GUI_SOURCES
${GUI_SOURCES} ${GUI_SOURCES}
${FONT_SOURCES} ${FONT_SOURCES}
@@ -123,7 +169,6 @@ if (BUILD_GUI)
ImGuiInputHandler.cc ImGuiInputHandler.cc
ImGuiRenderer.cc ImGuiRenderer.cc
) )
endif ()
endif () endif ()
set(COMMON_SOURCES set(COMMON_SOURCES
@@ -134,6 +179,12 @@ set(COMMON_SOURCES
HelpText.cc HelpText.cc
KKeymap.cc KKeymap.cc
Swap.cc Swap.cc
ErrorHandler.cc
SyscallWrappers.cc
ErrorRecovery.cc
RegexGuard.cc
RegexEngine.cc
SearchHighlight.cc
TerminalInputHandler.cc TerminalInputHandler.cc
TerminalRenderer.cc TerminalRenderer.cc
TerminalFrontend.cc TerminalFrontend.cc
@@ -194,6 +245,8 @@ set(FONT_HEADERS
fonts/FontList.h fonts/FontList.h
fonts/B612Mono.h fonts/B612Mono.h
fonts/BrassMono.h fonts/BrassMono.h
fonts/CrimsonPro.h
fonts/ETBook.h
fonts/BrassMonoCode.h fonts/BrassMonoCode.h
fonts/FiraCode.h fonts/FiraCode.h
fonts/Go.h fonts/Go.h
@@ -205,6 +258,7 @@ set(FONT_HEADERS
fonts/IosevkaExtended.h fonts/IosevkaExtended.h
fonts/ShareTech.h fonts/ShareTech.h
fonts/SpaceMono.h fonts/SpaceMono.h
fonts/Spectral.h
fonts/Syne.h fonts/Syne.h
fonts/Triplicate.h fonts/Triplicate.h
fonts/Unispace.h fonts/Unispace.h
@@ -228,6 +282,9 @@ set(COMMON_HEADERS
TestInputHandler.h TestInputHandler.h
TestRenderer.h TestRenderer.h
TestFrontend.h TestFrontend.h
TextSpan.h
RegexEngine.h
SearchHighlight.h
UndoNode.h UndoNode.h
UndoTree.h UndoTree.h
UndoSystem.h UndoSystem.h
@@ -241,14 +298,6 @@ if (BUILD_GUI)
GUIConfig.h GUIConfig.h
) )
if (KTE_USE_QT)
set(GUI_HEADERS
${GUI_HEADERS}
QtFrontend.h
QtInputHandler.h
QtRenderer.h
)
else ()
set(GUI_HEADERS set(GUI_HEADERS
${GUI_HEADERS} ${GUI_HEADERS}
${THEME_HEADERS} ${THEME_HEADERS}
@@ -258,7 +307,6 @@ if (BUILD_GUI)
ImGuiRenderer.h ImGuiRenderer.h
fonts/BerkeleyMono.h fonts/BerkeleyMono.h
) )
endif ()
endif () endif ()
# kte (terminal-first) executable # kte (terminal-first) executable
@@ -274,6 +322,11 @@ endif ()
target_link_libraries(kte ${CURSES_LIBRARIES}) target_link_libraries(kte ${CURSES_LIBRARIES})
# Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kte PRIVATE -static)
endif ()
if (KTE_ENABLE_TREESITTER) if (KTE_ENABLE_TREESITTER)
# Users can provide their own tree-sitter include/lib via cache variables # Users can provide their own tree-sitter include/lib via cache variables
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory") set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
@@ -308,15 +361,24 @@ if (BUILD_TESTS)
tests/test_swap_recorder.cc tests/test_swap_recorder.cc
tests/test_swap_writer.cc tests/test_swap_writer.cc
tests/test_swap_replay.cc tests/test_swap_replay.cc
tests/test_swap_edge_cases.cc
tests/test_swap_recovery_prompt.cc tests/test_swap_recovery_prompt.cc
tests/test_swap_cleanup.cc tests/test_swap_cleanup.cc
tests/test_swap_cleanup2.cc
tests/test_swap_git_editor.cc
tests/test_piece_table.cc tests/test_piece_table.cc
tests/test_search.cc
tests/test_search_replace_flow.cc tests/test_search_replace_flow.cc
tests/test_reflow_paragraph.cc tests/test_reflow_paragraph.cc
tests/test_reflow_indented_bullets.cc tests/test_reflow_indented_bullets.cc
tests/test_undo.cc tests/test_undo.cc
tests/test_visual_line_mode.cc tests/test_visual_line_mode.cc
tests/test_benchmarks.cc
tests/test_migration_coverage.cc
tests/test_smart_newline.cc
tests/test_reflow_undo.cc
tests/test_syntax_highlighting.cc
tests/test_paste_split.cc
tests/test_audit_regressions.cc
# minimal engine sources required by Buffer # minimal engine sources required by Buffer
PieceTable.cc PieceTable.cc
@@ -325,9 +387,14 @@ if (BUILD_TESTS)
Command.cc Command.cc
HelpText.cc HelpText.cc
Swap.cc Swap.cc
ErrorHandler.cc
SyscallWrappers.cc
ErrorRecovery.cc
RegexGuard.cc
RegexEngine.cc
SearchHighlight.cc
KKeymap.cc KKeymap.cc
SwapRecorder.h SwapRecorder.h
OptimizedSearch.cc
UndoNode.cc UndoNode.cc
UndoTree.cc UndoTree.cc
UndoSystem.cc UndoSystem.cc
@@ -349,6 +416,14 @@ if (BUILD_TESTS)
target_link_libraries(kte_tests ${TREESITTER_LIBRARY}) target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
endif () endif ()
endif () endif ()
# Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kte_tests PRIVATE -static)
endif ()
enable_testing()
add_test(NAME kte_tests COMMAND kte_tests)
endif () endif ()
if (BUILD_GUI) if (BUILD_GUI)
@@ -376,16 +451,14 @@ if (BUILD_GUI)
) )
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE}) target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
if (KTE_USE_QT)
target_compile_definitions(kge PRIVATE KTE_USE_QT=1)
endif ()
if (KTE_UNDO_DEBUG) if (KTE_UNDO_DEBUG)
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1) target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
endif () endif ()
if (KTE_USE_QT)
target_link_libraries(kge ${CURSES_LIBRARIES} Qt6::Widgets)
else ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui) target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
# Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kge PRIVATE -static)
endif () endif ()
# On macOS, build kge as a proper .app bundle # On macOS, build kge as a proper .app bundle
@@ -441,10 +514,30 @@ if (BUILD_GUI)
# Install kge man page only when GUI is built # Install kge man page only when GUI is built
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons) install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
if (UNIX AND NOT APPLE)
install(FILES kge.desktop DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/applications)
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/pixmaps)
install(FILES kge.iconset/icon_16x16.png
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/16x16/apps
RENAME kge.png)
install(FILES kge.iconset/icon_32x32.png
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/32x32/apps
RENAME kge.png)
install(FILES kge.iconset/icon_128x128.png
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/128x128/apps
RENAME kge.png)
install(FILES kge.iconset/icon_256x256.png
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/256x256/apps
RENAME kge.png)
install(FILES kge.iconset/icon_512x512.png
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/512x512/apps
RENAME kge.png)
endif ()
# Optional post-build bundle fixup (can also be run from scripts). # Optional post-build bundle fixup (can also be run from scripts).
# This provides a CMake target to run BundleUtilities' fixup_bundle on the # This provides a CMake target to run BundleUtilities' fixup_bundle on the
# built app, useful after macdeployqt to ensure non-Qt dylibs are internalized. # built app, copying non-system dylibs (e.g. Homebrew SDL2/SDL3, freetype,
# pcre2) into the bundle so it runs on machines without them installed.
if (APPLE AND TARGET kge) if (APPLE AND TARGET kge)
get_target_property(IS_BUNDLE kge MACOSX_BUNDLE) get_target_property(IS_BUNDLE kge MACOSX_BUNDLE)
if (IS_BUNDLE) if (IS_BUNDLE)
@@ -452,7 +545,7 @@ if (BUILD_GUI)
COMMAND ${CMAKE_COMMAND} COMMAND ${CMAKE_COMMAND}
-DAPP_BUNDLE=${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_PROPERTY:kge,MACOSX_BUNDLE_BUNDLE_NAME>.app -DAPP_BUNDLE=${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_PROPERTY:kge,MACOSX_BUNDLE_BUNDLE_NAME>.app
-P ${CMAKE_CURRENT_LIST_DIR}/cmake/fix_bundle.cmake -P ${CMAKE_CURRENT_LIST_DIR}/cmake/fix_bundle.cmake
COMMENT "Running fixup_bundle on kge.app to internalize non-Qt dylibs" COMMENT "Running fixup_bundle on kge.app to internalize non-system dylibs"
VERBATIM) VERBATIM)
add_dependencies(kge_fixup_bundle kge) add_dependencies(kge_fixup_bundle kge)
endif () endif ()
+116
View File
@@ -0,0 +1,116 @@
# kge Configuration
kge loads configuration from `~/.config/kte/kge.toml`. If no TOML file is
found, it falls back to the legacy `kge.ini` format.
## TOML Format
```toml
[window]
fullscreen = false
columns = 80
rows = 42
[font]
# Default font and size
name = "default"
size = 18.0
# Font used in code mode (monospace)
code = "default"
# Font used in writing mode (proportional)
writing = "crimsonpro"
[appearance]
theme = "nord"
# "dark" or "light" for themes with variants
background = "dark"
[editor]
syntax = true
```
## Sections
### `[window]`
| Key | Type | Default | Description |
|--------------|------|---------|---------------------------------|
| `fullscreen` | bool | false | Start in fullscreen mode |
| `columns` | int | 80 | Initial window width in columns |
| `rows` | int | 42 | Initial window height in rows |
### `[font]`
| Key | Type | Default | Description |
|-----------|--------|--------------|------------------------------------------|
| `name` | string | "default" | Default font loaded at startup |
| `size` | float | 18.0 | Font size in pixels |
| `code` | string | "default" | Font for code mode (monospace) |
| `writing` | string | "crimsonpro" | Font for writing mode (proportional) |
### `[appearance]`
| Key | Type | Default | Description |
|--------------|--------|---------|-----------------------------------------|
| `theme` | string | "nord" | Color theme |
| `background` | string | "dark" | Background mode: "dark" or "light" |
### `[editor]`
| Key | Type | Default | Description |
|----------|------|---------|------------------------------|
| `syntax` | bool | true | Enable syntax highlighting |
## Edit Modes
kge has two edit modes that control which font is used:
- **code** — Uses the monospace font (`font.code`). Default for source files.
- **writing** — Uses the proportional font (`font.writing`). Auto-detected
for `.txt`, `.md`, `.markdown`, `.rst`, `.org`, `.tex`, `.adoc`, and
`.asciidoc` files.
Toggle with `C-k m` or `: mode [code|writing]`.
## Available Fonts
### Monospace
b612, berkeley, berkeley-bold, brassmono, brassmono-bold, brassmonocode,
brassmonocode-bold, fira, go, ibm, idealist, inconsolata, inconsolataex,
iosevka, iosevkaex, sharetech, space, syne, triplicate, unispace
### Proportional (Serif)
crimsonpro, etbook, spectral
## Available Themes
amber, eink, everforest, gruvbox, kanagawa-paper, lcars, leuchtturm, nord,
old-book, orbital, plan9, solarized, tufte, weyland-yutani, zenburn
Themes with light/dark variants: eink, gruvbox, leuchtturm, old-book,
solarized. Set `background = "light"` or use `: background light`.
## Migrating from kge.ini
If you have an existing `kge.ini`, kge will still read it but prints a
notice to stderr suggesting migration. To migrate, create `kge.toml` in the
same directory (`~/.config/kte/`) using the format above. The TOML file
takes priority when both exist.
The INI keys map to TOML as follows:
| INI key | TOML equivalent |
|---------------|--------------------------|
| `fullscreen` | `window.fullscreen` |
| `columns` | `window.columns` |
| `rows` | `window.rows` |
| `font` | `font.name` |
| `font_size` | `font.size` |
| `theme` | `appearance.theme` |
| `background` | `appearance.background` |
| `syntax` | `editor.syntax` |
New keys `font.code` and `font.writing` have no INI equivalent (the INI
parser accepts `code_font` and `writing_font` if needed).
+1727 -1024
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -28,7 +28,6 @@ enum class CommandId {
OpenFileStart, // begin open-file prompt OpenFileStart, // begin open-file prompt
VisualFilePickerToggle, VisualFilePickerToggle,
// GUI-only: toggle/show a visual font selector dialog // GUI-only: toggle/show a visual font selector dialog
VisualFontPickerToggle,
// Buffers // Buffers
BufferSwitchStart, // begin buffer switch prompt BufferSwitchStart, // begin buffer switch prompt
BufferNew, // create a new empty, unnamed buffer (C-k i) BufferNew, // create a new empty, unnamed buffer (C-k i)
@@ -38,6 +37,7 @@ enum class CommandId {
// Editing // Editing
InsertText, // arg: text to insert at cursor (UTF-8, no newlines) InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
Newline, // insert a newline at cursor Newline, // insert a newline at cursor
SmartNewline, // insert a newline with auto-indent (Shift-Enter)
Backspace, // delete char before cursor (may join lines) Backspace, // delete char before cursor (may join lines)
DeleteChar, // delete char at cursor (may join lines) DeleteChar, // delete char at cursor (may join lines)
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
@@ -110,6 +110,14 @@ enum class CommandId {
SetOption, // generic ":set key=value" (v1: filetype=<lang>) SetOption, // generic ":set key=value" (v1: filetype=<lang>)
// Viewport control // Viewport control
CenterOnCursor, // center the viewport on the current cursor line (C-k k) CenterOnCursor, // center the viewport on the current cursor line (C-k k)
// GUI: open a new editor window sharing the same buffer list
NewWindow,
// GUI: font size controls
FontZoomIn,
FontZoomOut,
FontZoomReset,
// Edit mode (code/writing)
ToggleEditMode,
}; };
+25
View File
@@ -0,0 +1,25 @@
# Minimal Dockerfile for building and testing kte on Linux
# This container provides a build environment with all dependencies.
# Mount the source tree at /kte when running the container.
FROM alpine:3.19
# Install build dependencies
RUN apk add --no-cache \
g++ \
cmake \
make \
ncurses-dev \
pcre2-dev \
pkgconf \
sdl2-dev \
mesa-dev \
freetype-dev \
libx11-dev \
libxext-dev
# Set working directory where source will be mounted
WORKDIR /kte
# Default command: build and run tests
# Add DirectFB include path for SDL2 compatibility on Alpine
CMD ["sh", "-c", "cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON -DCMAKE_CXX_FLAGS='-I/usr/include/directfb' && cmake --build build --target kte && cmake --build build --target kge && cmake --build build --target kte_tests && ./build/kte_tests"]
+290 -136
View File
@@ -1,28 +1,21 @@
#include <algorithm> #include <algorithm>
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <filesystem> #include <filesystem>
#include <utility> #include <utility>
#include "Editor.h" #include "Editor.h"
#include "syntax/HighlighterRegistry.h" #include "ErrorHandler.h"
#include "syntax/CppHighlighter.h" #include "syntax/CppHighlighter.h"
#include "syntax/NullHighlighter.h"
namespace { namespace {
static std::string // Universal-argument repeat counts are capped here (well below INT_MAX).
buffer_bytes_via_views(const Buffer &b) constexpr int kMaxUArgCount = 1000000;
{ } // namespace
const auto &rows = b.Rows();
std::string out;
for (std::size_t i = 0; i < rows.size(); i++) {
auto v = b.GetLineView(i);
out.append(v.data(), v.size());
}
return out;
}
namespace {
static void static void
apply_pending_line(Editor &ed, const std::size_t line1) apply_pending_line(Editor &ed, const std::size_t line1)
{ {
@@ -44,6 +37,46 @@ apply_pending_line(Editor &ed, const std::size_t line1)
} // namespace } // namespace
namespace {
// Same normalization as Buffer::OpenFromFile (expand "~", canonical path if
// the file exists, else absolute), so a path can be compared with the
// Filename() of open buffers.
std::string
normalize_open_path(const std::string &in)
{
std::string expanded = in;
if (!expanded.empty() && expanded[0] == '~') {
const char *home = std::getenv("HOME");
if (home && expanded.size() >= 2 && (expanded[1] == '/' || expanded[1] == '\\'))
expanded = std::string(home) + expanded.substr(1);
else if (home && expanded.size() == 1)
expanded = std::string(home);
}
try {
std::filesystem::path p(expanded);
if (std::filesystem::exists(p))
return std::filesystem::canonical(p).string();
return std::filesystem::absolute(p).string();
} catch (...) {
return expanded;
}
}
} // namespace
std::size_t
Editor::FindOpenBuffer(const std::string &path) const
{
const std::string norm = normalize_open_path(path);
const auto &bufs = Buffers();
for (std::size_t i = 0; i < bufs.size(); ++i) {
if (!bufs[i].Filename().empty() && bufs[i].Filename() == norm)
return i;
}
return static_cast<std::size_t>(-1);
}
Editor::Editor() Editor::Editor()
{ {
swap_ = std::make_unique<kte::SwapManager>(); swap_ = std::make_unique<kte::SwapManager>();
@@ -69,20 +102,22 @@ Editor::SetStatus(const std::string &message)
Buffer * Buffer *
Editor::CurrentBuffer() Editor::CurrentBuffer()
{ {
if (buffers_.empty() || curbuf_ >= buffers_.size()) { auto &bufs = Buffers();
if (bufs.empty() || curbuf_ >= bufs.size()) {
return nullptr; return nullptr;
} }
return &buffers_[curbuf_]; return &bufs[curbuf_];
} }
const Buffer * const Buffer *
Editor::CurrentBuffer() const Editor::CurrentBuffer() const
{ {
if (buffers_.empty() || curbuf_ >= buffers_.size()) { const auto &bufs = Buffers();
if (bufs.empty() || curbuf_ >= bufs.size()) {
return nullptr; return nullptr;
} }
return &buffers_[curbuf_]; return &bufs[curbuf_];
} }
@@ -117,8 +152,9 @@ Editor::DisplayNameFor(const Buffer &buf) const
// Prepare list of other buffer paths // Prepare list of other buffer paths
std::vector<std::vector<std::filesystem::path> > others; std::vector<std::vector<std::filesystem::path> > others;
others.reserve(buffers_.size()); const auto &bufs = Buffers();
for (const auto &b: buffers_) { others.reserve(bufs.size());
for (const auto &b: bufs) {
if (&b == &buf) if (&b == &buf)
continue; continue;
if (b.Filename().empty()) if (b.Filename().empty())
@@ -161,78 +197,94 @@ Editor::DisplayNameFor(const Buffer &buf) const
std::size_t std::size_t
Editor::AddBuffer(const Buffer &buf) Editor::AddBuffer(const Buffer &buf)
{ {
buffers_.push_back(buf); auto &bufs = Buffers();
// Attach swap recorder // push_back may reallocate the vector's storage, moving every existing
if (swap_) { // Buffer to a new address. Drain any in-flight swap records first so the
swap_->Attach(&buffers_.back()); // writer thread never dereferences an address that's about to move, then
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back())); // rehome each attached buffer that actually moved.
if (Swap() && !bufs.empty())
Swap()->Flush();
std::vector<Buffer *> old_addrs;
old_addrs.reserve(bufs.size());
for (auto &b: bufs)
old_addrs.push_back(&b);
bufs.push_back(buf);
if (Swap()) {
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
Buffer *new_addr = &bufs[i];
if (new_addr != old_addrs[i])
bufs[i].SetSwapRecorder(Swap()->Rehome(old_addrs[i], new_addr));
} }
if (buffers_.size() == 1) { Swap()->Attach(&bufs.back());
bufs.back().SetSwapRecorder(Swap()->RecorderFor(&bufs.back()));
}
if (bufs.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
} }
return buffers_.size() - 1; return bufs.size() - 1;
} }
std::size_t std::size_t
Editor::AddBuffer(Buffer &&buf) Editor::AddBuffer(Buffer &&buf)
{ {
buffers_.push_back(std::move(buf)); auto &bufs = Buffers();
if (swap_) { if (Swap() && !bufs.empty())
swap_->Attach(&buffers_.back()); Swap()->Flush();
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back())); std::vector<Buffer *> old_addrs;
old_addrs.reserve(bufs.size());
for (auto &b: bufs)
old_addrs.push_back(&b);
bufs.push_back(std::move(buf));
if (Swap()) {
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
Buffer *new_addr = &bufs[i];
if (new_addr != old_addrs[i])
bufs[i].SetSwapRecorder(Swap()->Rehome(old_addrs[i], new_addr));
} }
if (buffers_.size() == 1) { Swap()->Attach(&bufs.back());
bufs.back().SetSwapRecorder(Swap()->RecorderFor(&bufs.back()));
}
if (bufs.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
} }
return buffers_.size() - 1; return bufs.size() - 1;
} }
bool bool
Editor::OpenFile(const std::string &path, std::string &err) Editor::OpenFile(const std::string &path, std::string &err)
{ {
// If there is exactly one unnamed, empty, clean buffer, reuse it instead // A file that is already open is switched to, not opened again: two
// of creating a new one. // buffers for one file would share (and corrupt) one swap journal, and
if (buffers_.size() == 1) { // closing either would delete the other's journal.
Buffer &cur = buffers_[curbuf_]; if (const std::size_t open_idx = FindOpenBuffer(path); open_idx != static_cast<std::size_t>(-1)) {
SwitchTo(open_idx);
return true;
}
// If the current buffer is an unnamed, empty, clean scratch buffer, reuse
// it instead of creating a new one.
auto &bufs_ref = Buffers();
if (!bufs_ref.empty() && curbuf_ < bufs_ref.size()) {
Buffer &cur = bufs_ref[curbuf_];
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked(); const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
const bool clean = !cur.Dirty(); const bool clean = !cur.Dirty();
const auto &rows = cur.Rows(); const std::size_t nrows = cur.Nrows();
const bool rows_empty = rows.empty(); const bool rows_empty = (nrows == 0);
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0); const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
if (unnamed && clean && (rows_empty || single_empty_line)) { if (unnamed && clean && (rows_empty || single_empty_line)) {
bool ok = cur.OpenFromFile(path, err); bool ok = cur.OpenFromFile(path, err);
if (!ok) if (!ok)
return false; return false;
// Ensure swap recorder is attached for this buffer // Ensure swap recorder is attached for this buffer
if (swap_) { if (Swap()) {
swap_->Attach(&cur); Swap()->Attach(&cur);
cur.SetSwapRecorder(swap_->RecorderFor(&cur)); cur.SetSwapRecorder(Swap()->RecorderFor(&cur));
swap_->NotifyFilenameChanged(cur); Swap()->NotifyFilenameChanged(cur);
}
// Setup highlighting using registry (extension + shebang)
cur.EnsureHighlighter();
std::string first = "";
const auto &cur_rows = cur.Rows();
if (!cur_rows.empty())
first = static_cast<std::string>(cur_rows[0]);
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
if (!ft.empty()) {
cur.SetFiletype(ft);
cur.SetSyntaxEnabled(true);
if (auto *eng = cur.Highlighter()) {
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
eng->InvalidateFrom(0);
}
} else {
cur.SetFiletype("");
cur.SetSyntaxEnabled(true);
if (auto *eng = cur.Highlighter()) {
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
eng->InvalidateFrom(0);
}
} }
// Highlighting by extension or shebang
cur.ApplyDetectedFiletype();
// Defensive: ensure any active prompt is closed after a successful open // Defensive: ensure any active prompt is closed after a successful open
CancelPrompt(); CancelPrompt();
return true; return true;
@@ -245,34 +297,12 @@ Editor::OpenFile(const std::string &path, std::string &err)
} }
// NOTE: swap recorder/attach must happen after the buffer is stored in its // NOTE: swap recorder/attach must happen after the buffer is stored in its
// final location (vector) because swap manager keys off Buffer*. // final location (vector) because swap manager keys off Buffer*.
// Initialize syntax highlighting by extension + shebang via registry (v2) // Highlighting by extension or shebang
b.EnsureHighlighter(); b.ApplyDetectedFiletype();
std::string first = "";
{
const auto &rows = b.Rows();
if (!rows.empty())
first = static_cast<std::string>(rows[0]);
}
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
if (!ft.empty()) {
b.SetFiletype(ft);
b.SetSyntaxEnabled(true);
if (auto *eng = b.Highlighter()) {
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
eng->InvalidateFrom(0);
}
} else {
b.SetFiletype("");
b.SetSyntaxEnabled(true);
if (auto *eng = b.Highlighter()) {
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
eng->InvalidateFrom(0);
}
}
// Add as a new buffer and switch to it // Add as a new buffer and switch to it
std::size_t idx = AddBuffer(std::move(b)); std::size_t idx = AddBuffer(std::move(b));
if (swap_) { if (Swap()) {
swap_->NotifyFilenameChanged(buffers_[idx]); Swap()->NotifyFilenameChanged(Buffers()[idx]);
} }
SwitchTo(idx); SwitchTo(idx);
// Defensive: ensure any active prompt is closed after a successful open // Defensive: ensure any active prompt is closed after a successful open
@@ -312,6 +342,10 @@ Editor::CancelRecoveryPrompt()
pending_recovery_open_ = PendingOpen{}; pending_recovery_open_ = PendingOpen{};
pending_recovery_swap_path_.clear(); pending_recovery_swap_path_.clear();
pending_recovery_replay_err_.clear(); pending_recovery_replay_err_.clear();
std::string().swap(pending_recovery_content_);
pending_recovery_valid_bytes_ = 0;
pending_recovery_swap_size_ = 0;
pending_recovery_have_content_ = false;
} }
@@ -324,6 +358,10 @@ Editor::ResolveRecoveryPrompt(const bool yes)
const PendingOpen req = pending_recovery_open_; const PendingOpen req = pending_recovery_open_;
const std::string swp = pending_recovery_swap_path_; const std::string swp = pending_recovery_swap_path_;
const std::string rerr_s = pending_recovery_replay_err_; const std::string rerr_s = pending_recovery_replay_err_;
std::string content = std::move(pending_recovery_content_);
const bool have_content = pending_recovery_have_content_;
std::uint64_t valid_bytes = pending_recovery_valid_bytes_;
const std::uint64_t swap_size_then = pending_recovery_swap_size_;
CancelRecoveryPrompt(); CancelRecoveryPrompt();
std::string err; std::string err;
@@ -338,11 +376,35 @@ Editor::ResolveRecoveryPrompt(const bool yes)
SetStatus("Recovery failed: no buffer"); SetStatus("Recovery failed: no buffer");
return false; return false;
} }
std::uint64_t swap_size_now = 0;
try {
swap_size_now = std::filesystem::file_size(swp);
} catch (...) {
}
if (have_content && swap_size_now == swap_size_then) {
// The prompt already replayed the journal (unchanged since); install that.
b->replace_all_bytes(content);
std::string().swap(content);
} else {
std::string rerr; std::string rerr;
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) { if (!kte::SwapManager::ReplayFile(*b, swp, rerr, &valid_bytes)) {
SetStatus("Swap recovery failed: " + rerr); SetStatus("Swap recovery failed: " + rerr);
return false; return false;
} }
}
// This session keeps appending to the same journal. Drop a torn
// final record first, or new records would land behind it and the
// journal could not be replayed after another crash.
try {
if (std::filesystem::file_size(swp) > valid_bytes)
std::filesystem::resize_file(swp, valid_bytes);
} catch (...) {
// Best effort; the replay itself succeeded.
}
// Nothing in the undo history matches the file on disk now: keep
// the buffer dirty even after undoing back to the loaded state.
if (auto *u = b->Undo())
u->mark_base_unsaved();
b->SetDirty(true); b->SetDirty(true);
apply_pending_line(*this, req.line1); apply_pending_line(*this, req.line1);
SetStatus("Recovered " + req.path); SetStatus("Recovered " + req.path);
@@ -359,8 +421,16 @@ Editor::ResolveRecoveryPrompt(const bool yes)
return true; return true;
} }
if (kind == RecoveryPromptKind::DeleteCorruptSwap) { if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
std::string kept_as;
if (yes) { if (yes) {
(void) std::remove(swp.c_str()); (void) std::remove(swp.c_str());
} else {
// Keep the unreadable journal for inspection, but out of the way:
// left in place, the new session's records would be appended
// behind the corrupt data and could never be replayed.
kept_as = swp + ".corrupt";
if (std::rename(swp.c_str(), kept_as.c_str()) != 0)
kept_as.clear();
} }
if (!OpenFile(req.path, err)) { if (!OpenFile(req.path, err)) {
SetStatus(err); SetStatus(err);
@@ -368,7 +438,9 @@ Editor::ResolveRecoveryPrompt(const bool yes)
} }
apply_pending_line(*this, req.line1); apply_pending_line(*this, req.line1);
// Include a short hint that the swap was corrupt. // Include a short hint that the swap was corrupt.
if (!rerr_s.empty()) { if (!kept_as.empty()) {
SetStatus("Opened " + req.path + " (unreadable swap kept as " + kept_as + ")");
} else if (!rerr_s.empty()) {
SetStatus("Opened " + req.path + " (swap unreadable)"); SetStatus("Opened " + req.path + " (swap unreadable)");
} else { } else {
SetStatus("Opened " + req.path); SetStatus("Opened " + req.path);
@@ -381,6 +453,39 @@ Editor::ResolveRecoveryPrompt(const bool yes)
bool bool
Editor::ProcessPendingOpens() Editor::ProcessPendingOpens()
{
bool opened = false;
// Opening can throw (bad_alloc on a huge file, filesystem errors), and so
// can the checkpoint snapshot below. This runs from every frontend's
// loop, outside command dispatch: report the failure instead of letting
// it end the editor.
try {
// Also the once-per-frame hook for the journal: retry the
// checkpoint of a journal that lost a record.
if (kte::SwapManager *sm = Swap())
sm->RetryGapCheckpoints();
opened = process_pending_opens_();
} catch (const std::exception &e) {
kte::ErrorHandler::Instance().Error("Editor", std::string("open failed: ") + e.what(), "");
SetStatus(std::string("Open failed: ") + e.what());
} catch (...) {
SetStatus("Open failed");
}
// Journal problems for the user, after the open's own status so it does
// not hide them; all pending ones at once (one per frame flashed by).
if (kte::SwapManager *sm = Swap()) {
std::string notices;
for (std::string n = sm->TakeUserNotice(); !n.empty(); n = sm->TakeUserNotice())
notices += (notices.empty() ? "" : "; ") + n;
if (!notices.empty())
SetStatus(notices);
}
return opened;
}
bool
Editor::process_pending_opens_()
{ {
if (PromptActive()) if (PromptActive())
return false; return false;
@@ -393,6 +498,15 @@ Editor::ProcessPendingOpens()
pending_open_.pop_front(); pending_open_.pop_front();
if (req.path.empty()) if (req.path.empty())
continue; continue;
// Already open: switch to it. Its swap file is this session's live
// journal, not something to recover.
if (const std::size_t open_idx = FindOpenBuffer(req.path); open_idx != static_cast<std::size_t>(-1)) {
SwitchTo(open_idx);
apply_pending_line(*this, req.line1);
SetStatus("Switched to " + Buffers()[open_idx].Filename());
opened_any = true;
return opened_any;
}
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path); std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
bool swp_exists = false; bool swp_exists = false;
@@ -401,20 +515,59 @@ Editor::ProcessPendingOpens()
} catch (...) { } catch (...) {
swp_exists = false; swp_exists = false;
} }
if (swp_exists && kte::SwapManager::JournalInUse(swp)) {
// Another running kte is editing this file: its journal is live.
// Open without recovery and leave the journal alone; this
// session will not journal the file.
std::string err;
if (!OpenFile(req.path, err)) {
SetStatus(err);
continue;
}
apply_pending_line(*this, req.line1);
SetStatus(req.path + " is open in another kte; this copy has no crash recovery");
return true;
}
if (swp_exists && !kte::SwapManager::JournalMatchesFile(swp, req.path)) {
// The file changed (or was removed) after the journal started, so
// its position-based edits no longer apply.
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
pending_recovery_open_ = req;
pending_recovery_swap_path_ = swp;
pending_recovery_replay_err_ = "stale";
StartPrompt(PromptKind::Confirm, "Swap", "");
SetStatus("Swap file for " + req.path +
" predates changes to the file on disk and cannot be applied. Delete it? (y/N, C-g cancel)");
return opened_any;
}
if (swp_exists) { if (swp_exists) {
Buffer tmp; Buffer tmp;
std::string oerr; std::string oerr;
if (tmp.OpenFromFile(req.path, oerr)) { if (tmp.OpenFromFile(req.path, oerr)) {
const std::string orig = buffer_bytes_via_views(tmp); const std::string orig = tmp.Bytes();
std::string rerr; std::string rerr;
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) { std::uint64_t valid = 0;
const std::string rec = buffer_bytes_via_views(tmp); if (kte::SwapManager::ReplayFile(tmp, swp, rerr, &valid)) {
if (rec != orig) { std::string rec = tmp.Bytes();
if (rec == orig) {
// Nothing to recover. Remove the journal rather than
// appending this session's records to it (it may end in a
// torn record, which would make them unreplayable).
(void) std::remove(swp.c_str());
} else {
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard; pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
pending_recovery_open_ = req; pending_recovery_open_ = req;
pending_recovery_swap_path_ = swp; pending_recovery_swap_path_ = swp;
pending_recovery_content_ = std::move(rec);
pending_recovery_valid_bytes_ = valid;
pending_recovery_have_content_ = true;
try {
pending_recovery_swap_size_ = std::filesystem::file_size(swp);
} catch (...) {
pending_recovery_have_content_ = false; // replay on "y"
}
StartPrompt(PromptKind::Confirm, "Recover", ""); StartPrompt(PromptKind::Confirm, "Recover", "");
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)"); SetStatus("Recover swap edits for " + req.path + "? (y/n, C-g cancel)");
return opened_any; return opened_any;
} }
} else { } else {
@@ -450,31 +603,15 @@ Editor::ProcessPendingOpens()
bool bool
Editor::SwitchTo(std::size_t index) Editor::SwitchTo(std::size_t index)
{ {
if (index >= buffers_.size()) { auto &bufs = Buffers();
if (index >= bufs.size()) {
return false; return false;
} }
curbuf_ = index; curbuf_ = index;
// Robustness: ensure a valid highlighter is installed when switching buffers // Robustness: ensure a valid highlighter is installed when switching buffers
Buffer &b = buffers_[curbuf_]; Buffer &b = bufs[curbuf_];
if (b.SyntaxEnabled()) { if (b.SyntaxEnabled() && !(b.Highlighter() && b.Highlighter()->HasHighlighter()))
b.EnsureHighlighter(); b.InstallFiletypeHighlighter();
if (auto *eng = b.Highlighter()) {
if (!eng->HasHighlighter()) {
// Try to set based on existing filetype; fall back to NullHighlighter
if (!b.Filetype().empty()) {
auto hl = kte::HighlighterRegistry::CreateFor(b.Filetype());
if (hl) {
eng->SetHighlighter(std::move(hl));
} else {
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
}
} else {
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
}
eng->InvalidateFrom(0);
}
}
}
return true; return true;
} }
@@ -482,20 +619,36 @@ Editor::SwitchTo(std::size_t index)
bool bool
Editor::CloseBuffer(std::size_t index) Editor::CloseBuffer(std::size_t index)
{ {
if (index >= buffers_.size()) { auto &bufs = Buffers();
if (index >= bufs.size()) {
return false; return false;
} }
if (swap_) { if (Swap()) {
// If the buffer is clean, remove its swap file when closing. // Always remove swap file when closing a buffer on normal exit.
// (Crash recovery is unaffected: on crash, close paths are not executed.) // Swap files are for crash recovery; on clean close, we don't need them.
swap_->Detach(&buffers_[index], !buffers_[index].Dirty()); // This prevents stale swap files from accumulating (e.g., when used as git editor).
buffers_[index].SetSwapRecorder(nullptr); Swap()->Detach(&bufs[index], true);
bufs[index].SetSwapRecorder(nullptr);
// Drain in-flight records before the erase-shift below moves other
// buffers to new addresses (vector::erase never reallocates, but it does
// move-assign each trailing buffer into the previous slot).
Swap()->Flush();
} }
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index)); bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
if (buffers_.empty()) { if (Swap()) {
// erase() shifts every buffer after `index` down by one slot in-place
// (no reallocation), so the buffer now at slot i used to live at slot
// i+1 (same underlying storage, since data() doesn't move on erase).
for (std::size_t i = index; i < bufs.size(); ++i) {
Buffer *new_addr = &bufs[i];
Buffer *old_addr = new_addr + 1;
bufs[i].SetSwapRecorder(Swap()->Rehome(old_addr, new_addr));
}
}
if (bufs.empty()) {
curbuf_ = 0; curbuf_ = 0;
} else if (curbuf_ >= buffers_.size()) { } else if (curbuf_ >= bufs.size()) {
curbuf_ = buffers_.size() - 1; curbuf_ = bufs.size() - 1;
} }
return true; return true;
} }
@@ -505,21 +658,22 @@ void
Editor::Reset() Editor::Reset()
{ {
rows_ = cols_ = 0; rows_ = cols_ = 0;
mode_ = 0;
kill_ = 0; kill_ = 0;
no_kill_ = 0;
dirtyex_ = 0;
msg_.clear(); msg_.clear();
msgtm_ = 0; msgtm_ = 0;
uarg_ = 0; uarg_ = 0;
ucount_ = 0; ucount_ = 0;
repeatable_ = false;
quit_requested_ = false; quit_requested_ = false;
quit_confirm_pending_ = false; quit_confirm_pending_ = false;
// Reset close-confirm/save state // Reset close-confirm/save state
close_confirm_pending_ = false; close_confirm_pending_ = false;
close_after_save_ = false; close_after_save_ = false;
buffers_.clear(); auto &bufs = Buffers();
if (Swap()) {
for (auto &buf : bufs)
Swap()->Detach(&buf, true);
}
bufs.clear();
curbuf_ = 0; curbuf_ = 0;
} }
@@ -535,7 +689,7 @@ Editor::UArgStart()
if (ucount_ == 0) { if (ucount_ == 0) {
ucount_ = 1; ucount_ = 1;
} }
ucount_ *= 4; ucount_ = std::min(ucount_ * 4, kMaxUArgCount); // no int overflow
} }
uarg_ = 1; uarg_ = 1;
char buf[64]; char buf[64];
@@ -555,7 +709,7 @@ Editor::UArgDigit(int d)
uarg_ = 1; uarg_ = 1;
ucount_ = 0; ucount_ = 0;
} }
ucount_ = ucount_ * 10 + d; ucount_ = (ucount_ > (kMaxUArgCount - d) / 10) ? kMaxUArgCount : ucount_ * 10 + d;
char buf[64]; char buf[64];
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_); std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
SetStatus(buf); SetStatus(buf);
+98 -64
View File
@@ -1,5 +1,42 @@
/* /*
* Editor.h - top-level editor state and buffer management * Editor.h - top-level editor state and buffer management
*
* Editor is the top-level coordinator in kte. It manages:
*
* - Buffer collection: Multiple open documents (buffers_), current buffer selection
* - UI state: Dimensions, status messages, prompts, search state
* - Kill ring: Shared clipboard for cut/copy/paste operations across buffers
* - Universal argument: Repeat count mechanism (C-u)
* - Mode flags: Editor modes (normal, k-command, search, prompt, etc.)
* - Swap/crash recovery: SwapManager integration for journaling
* - File operations: Opening files, managing pending opens, recovery prompts
*
* Key responsibilities:
*
* 1. Buffer lifecycle:
* - AddBuffer(): Add new buffers to the collection
* - OpenFile(): Load files into buffers
* - SwitchTo(): Change active buffer
* - CloseBuffer(): Remove buffers with dirty checks
*
* 2. UI coordination:
* - SetDimensions(): Terminal/window size for viewport calculations
* - SetStatus(): Status line messages with timestamps
* - Prompt system: Multi-step prompts for file open, buffer switch, etc.
* - Search state: Active search, query, match position, origin tracking
*
* 3. Shared editor state:
* - Kill ring: Circular buffer of killed text (max 60 entries)
* - Universal argument: C-u digit collection for command repetition
* - Mode tracking: Current input mode (normal, k-command, ESC, prompt)
*
* 4. Integration points:
* - Commands operate on Editor and current Buffer
* - Frontend (Terminal/GUI) queries Editor for rendering
* - SwapManager journals all buffer modifications
*
* Design note: Editor owns the buffer collection but doesn't directly edit content.
* Commands modify buffers through Buffer's API, and Editor coordinates the UI state.
*/ */
#pragma once #pragma once
#include <cstddef> #include <cstddef>
@@ -42,19 +79,6 @@ public:
} }
// Mode and flags (mirroring legacy fields)
void SetMode(int m)
{
mode_ = m;
}
[[nodiscard]] int Mode() const
{
return mode_;
}
void SetKillChain(bool on) void SetKillChain(bool on)
{ {
kill_ = on ? 1 : 0; kill_ = on ? 1 : 0;
@@ -67,18 +91,6 @@ public:
} }
void SetNoKill(bool on)
{
no_kill_ = on ? 1 : 0;
}
[[nodiscard]] bool NoKill() const
{
return no_kill_ != 0;
}
// Kill ring API // Kill ring API
void KillRingClear() void KillRingClear()
{ {
@@ -127,18 +139,6 @@ public:
} }
void SetDirtyEx(int d)
{
dirtyex_ = d;
}
[[nodiscard]] int DirtyEx() const
{
return dirtyex_;
}
void SetUniversalArg(int uarg, int ucount) void SetUniversalArg(int uarg, int ucount)
{ {
uarg_ = uarg; uarg_ = uarg;
@@ -152,12 +152,6 @@ public:
} }
[[nodiscard]] int UCount() const
{
return ucount_;
}
// --- Universal argument control (C-u) --- // --- Universal argument control (C-u) ---
// Begin or extend a universal argument (like ke's uarg_start) // Begin or extend a universal argument (like ke's uarg_start)
void UArgStart(); void UArgStart();
@@ -172,19 +166,6 @@ public:
// If no universal argument active, returns 1. // If no universal argument active, returns 1.
int UArgGet(); 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 // Status message storage. Rendering is renderer-dependent; the editor
// merely stores the current message and its timestamp. // merely stores the current message and its timestamp.
void SetStatus(const std::string &message); void SetStatus(const std::string &message);
@@ -209,6 +190,18 @@ public:
} }
void SetNewWindowRequested(bool on)
{
new_window_requested_ = on;
}
[[nodiscard]] bool NewWindowRequested() const
{
return new_window_requested_;
}
void SetQuitConfirmPending(bool on) void SetQuitConfirmPending(bool on)
{ {
quit_confirm_pending_ = on; quit_confirm_pending_ = on;
@@ -472,7 +465,7 @@ public:
// Buffers // Buffers
[[nodiscard]] std::size_t BufferCount() const [[nodiscard]] std::size_t BufferCount() const
{ {
return buffers_.size(); return Buffers().size();
} }
@@ -482,6 +475,19 @@ public:
} }
// Clamp curbuf_ to valid range. Call when the shared buffer list may
// have been modified by another editor (e.g., buffer closed in another window).
void ValidateBufferIndex()
{
const auto &bufs = Buffers();
if (bufs.empty()) {
curbuf_ = 0;
} else if (curbuf_ >= bufs.size()) {
curbuf_ = bufs.size() - 1;
}
}
Buffer *CurrentBuffer(); Buffer *CurrentBuffer();
const Buffer *CurrentBuffer() const; const Buffer *CurrentBuffer() const;
@@ -506,6 +512,11 @@ public:
// Returns true if a file was opened during this call. // Returns true if a file was opened during this call.
bool ProcessPendingOpens(); bool ProcessPendingOpens();
private:
bool process_pending_opens_();
public:
[[nodiscard]] bool HasPendingOpens() const; [[nodiscard]] bool HasPendingOpens() const;
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm` // Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
@@ -533,20 +544,38 @@ public:
// Direct access when needed (try to prefer methods above) // Direct access when needed (try to prefer methods above)
[[nodiscard]] const std::vector<Buffer> &Buffers() const [[nodiscard]] const std::vector<Buffer> &Buffers() const
{ {
return buffers_; return shared_buffers_ ? *shared_buffers_ : buffers_;
} }
std::vector<Buffer> &Buffers() std::vector<Buffer> &Buffers()
{ {
return buffers_; return shared_buffers_ ? *shared_buffers_ : buffers_;
}
// Share another editor's buffer list. When set, this editor operates on
// the provided vector instead of its own. Pass nullptr to detach.
// Buffers hold recorders owned by the SwapManager that attached them, so
// editors sharing buffers must also share that manager (shared_swap);
// otherwise closing one window destroys recorders the other still uses.
// Index of the open buffer whose file is `path` (after the same
// normalization OpenFromFile applies), or size_t(-1) if none.
[[nodiscard]] std::size_t FindOpenBuffer(const std::string &path) const;
void SetSharedBuffers(std::vector<Buffer> *shared, kte::SwapManager *shared_swap = nullptr)
{
shared_buffers_ = shared;
shared_swap_ = shared ? shared_swap : nullptr;
curbuf_ = 0;
} }
// Swap manager access (for advanced integrations/tests) // Swap manager access (for advanced integrations/tests)
[[nodiscard]] kte::SwapManager *Swap() [[nodiscard]] kte::SwapManager *Swap()
{ {
return swap_.get(); return shared_swap_ ? shared_swap_ : swap_.get();
} }
@@ -581,20 +610,18 @@ private:
}; };
std::size_t rows_ = 0, cols_ = 0; std::size_t rows_ = 0, cols_ = 0;
int mode_ = 0;
int kill_ = 0; // KILL CHAIN int kill_ = 0; // KILL CHAIN
int no_kill_ = 0; // don't kill in delete_row
int dirtyex_ = 0;
std::string msg_; std::string msg_;
std::time_t msgtm_ = 0; std::time_t msgtm_ = 0;
int uarg_ = 0, ucount_ = 0; // C-u support int uarg_ = 0, ucount_ = 0; // C-u support
bool repeatable_ = false; // whether the next command is repeatable
std::vector<Buffer> buffers_; std::vector<Buffer> buffers_;
std::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of buffers_
std::size_t curbuf_ = 0; // index into buffers_ std::size_t curbuf_ = 0; // index into buffers_
// Swap journaling manager (lifetime = editor) // Swap journaling manager (lifetime = editor)
std::unique_ptr<kte::SwapManager> swap_; std::unique_ptr<kte::SwapManager> swap_;
kte::SwapManager *shared_swap_ = nullptr; // set with shared_buffers_; not owned
// Kill ring (Emacs-like) // Kill ring (Emacs-like)
std::vector<std::string> kill_ring_; std::vector<std::string> kill_ring_;
@@ -602,6 +629,7 @@ private:
// Quit state // Quit state
bool quit_requested_ = false; bool quit_requested_ = false;
bool new_window_requested_ = false;
bool quit_confirm_pending_ = false; bool quit_confirm_pending_ = false;
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close 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 bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
@@ -629,6 +657,12 @@ private:
PendingOpen pending_recovery_open_{}; PendingOpen pending_recovery_open_{};
std::string pending_recovery_swap_path_; std::string pending_recovery_swap_path_;
std::string pending_recovery_replay_err_; std::string pending_recovery_replay_err_;
// The recovered content the prompt offers, and how much of the journal
// produced it: "y" installs it instead of replaying the journal again.
std::string pending_recovery_content_;
std::uint64_t pending_recovery_valid_bytes_ = 0;
std::uint64_t pending_recovery_swap_size_ = 0; // the journal's size then
bool pending_recovery_have_content_ = false;
// GUI-only state (safe no-op in terminal builds) // GUI-only state (safe no-op in terminal builds)
bool file_picker_visible_ = false; bool file_picker_visible_ = false;
+313
View File
@@ -0,0 +1,313 @@
#include "ErrorHandler.h"
#include <chrono>
#include <ctime>
#include <iomanip>
#include <sstream>
#include <filesystem>
#include <cstdlib>
namespace fs = std::filesystem;
namespace kte {
ErrorHandler::ErrorHandler()
{
// Determine log file path: ~/.local/state/kte/error.log
const char *home = std::getenv("HOME");
if (home) {
fs::path log_dir = fs::path(home) / ".local" / "state" / "kte";
try {
if (!fs::exists(log_dir)) {
fs::create_directories(log_dir);
}
log_file_path_ = (log_dir / "error.log").string();
// Create the log file immediately so it exists in the state directory
ensure_log_file();
} catch (...) {
// If we can't create the directory, disable file logging
file_logging_enabled_ = false;
}
} else {
// No HOME, disable file logging
file_logging_enabled_ = false;
}
}
ErrorHandler::~ErrorHandler()
{
std::lock_guard<std::mutex> lg(mtx_);
if (log_file_ && log_file_->is_open()) {
log_file_->flush();
log_file_->close();
}
}
ErrorHandler &
ErrorHandler::Instance()
{
static ErrorHandler instance;
return instance;
}
void
ErrorHandler::Report(ErrorSeverity severity, const std::string &component,
const std::string &message, const std::string &context)
{
ErrorRecord record;
record.timestamp_ns = now_ns();
record.severity = severity;
record.component = component;
record.message = message;
record.context = context;
{
std::lock_guard<std::mutex> lg(mtx_);
// Add to in-memory queue
errors_.push_back(record);
while (errors_.size() > 100) {
errors_.pop_front();
}
++total_error_count_;
if (severity == ErrorSeverity::Critical) {
++critical_error_count_;
}
// Write to log file if enabled
if (file_logging_enabled_) {
write_to_log(record);
}
}
}
void
ErrorHandler::Info(const std::string &component, const std::string &message,
const std::string &context)
{
Report(ErrorSeverity::Info, component, message, context);
}
void
ErrorHandler::Warning(const std::string &component, const std::string &message,
const std::string &context)
{
Report(ErrorSeverity::Warning, component, message, context);
}
void
ErrorHandler::Error(const std::string &component, const std::string &message,
const std::string &context)
{
Report(ErrorSeverity::Error, component, message, context);
}
void
ErrorHandler::Critical(const std::string &component, const std::string &message,
const std::string &context)
{
Report(ErrorSeverity::Critical, component, message, context);
}
bool
ErrorHandler::HasErrors() const
{
std::lock_guard<std::mutex> lg(mtx_);
return !errors_.empty();
}
bool
ErrorHandler::HasCriticalErrors() const
{
std::lock_guard<std::mutex> lg(mtx_);
return critical_error_count_ > 0;
}
std::string
ErrorHandler::GetLastError() const
{
std::lock_guard<std::mutex> lg(mtx_);
if (errors_.empty())
return "";
const ErrorRecord &e = errors_.back();
std::string result = "[" + severity_to_string(e.severity) + "] ";
result += e.component;
if (!e.context.empty()) {
result += " (" + e.context + ")";
}
result += ": " + e.message;
return result;
}
std::size_t
ErrorHandler::GetErrorCount() const
{
std::lock_guard<std::mutex> lg(mtx_);
return total_error_count_;
}
std::size_t
ErrorHandler::GetErrorCount(ErrorSeverity severity) const
{
std::lock_guard<std::mutex> lg(mtx_);
std::size_t count = 0;
for (const auto &e: errors_) {
if (e.severity == severity) {
++count;
}
}
return count;
}
std::vector<ErrorHandler::ErrorRecord>
ErrorHandler::GetRecentErrors(std::size_t max_count) const
{
std::lock_guard<std::mutex> lg(mtx_);
std::vector<ErrorRecord> result;
result.reserve(std::min(max_count, errors_.size()));
// Return most recent first
auto it = errors_.rbegin();
for (std::size_t i = 0; i < max_count && it != errors_.rend(); ++i, ++it) {
result.push_back(*it);
}
return result;
}
void
ErrorHandler::ClearErrors()
{
std::lock_guard<std::mutex> lg(mtx_);
errors_.clear();
total_error_count_ = 0;
critical_error_count_ = 0;
}
void
ErrorHandler::SetFileLoggingEnabled(bool enabled)
{
std::lock_guard<std::mutex> lg(mtx_);
file_logging_enabled_ = enabled;
if (!enabled && log_file_ && log_file_->is_open()) {
log_file_->flush();
log_file_->close();
log_file_.reset();
}
}
std::string
ErrorHandler::GetLogFilePath() const
{
std::lock_guard<std::mutex> lg(mtx_);
return log_file_path_;
}
void
ErrorHandler::write_to_log(const ErrorRecord &record)
{
// Must be called with mtx_ held
if (log_file_path_.empty())
return;
ensure_log_file();
if (!log_file_ || !log_file_->is_open())
return;
// Format: [timestamp] [SEVERITY] component (context): message
std::string timestamp = format_timestamp(record.timestamp_ns);
std::string severity = severity_to_string(record.severity);
*log_file_ << "[" << timestamp << "] [" << severity << "] " << record.component;
if (!record.context.empty()) {
*log_file_ << " (" << record.context << ")";
}
*log_file_ << ": " << record.message << "\n";
log_file_->flush();
}
void
ErrorHandler::ensure_log_file()
{
// Must be called with mtx_ held
if (log_file_ && log_file_->is_open())
return;
if (log_file_path_.empty())
return;
try {
log_file_ = std::make_unique<std::ofstream>(log_file_path_,
std::ios::app | std::ios::out);
if (!log_file_->is_open()) {
log_file_.reset();
}
} catch (...) {
log_file_.reset();
}
}
std::string
ErrorHandler::format_timestamp(std::uint64_t timestamp_ns) const
{
// Convert nanoseconds to time_t (seconds)
std::time_t seconds = static_cast<std::time_t>(timestamp_ns / 1000000000ULL);
std::uint64_t nanos = timestamp_ns % 1000000000ULL;
std::tm tm_buf{};
#if defined(_WIN32)
localtime_s(&tm_buf, &seconds);
#else
localtime_r(&seconds, &tm_buf);
#endif
std::ostringstream oss;
oss << std::put_time(&tm_buf, "%Y-%m-%d %H:%M:%S");
oss << "." << std::setfill('0') << std::setw(3) << (nanos / 1000000ULL);
return oss.str();
}
std::string
ErrorHandler::severity_to_string(ErrorSeverity severity) const
{
switch (severity) {
case ErrorSeverity::Info:
return "INFO";
case ErrorSeverity::Warning:
return "WARNING";
case ErrorSeverity::Error:
return "ERROR";
case ErrorSeverity::Critical:
return "CRITICAL";
default:
return "UNKNOWN";
}
}
std::uint64_t
ErrorHandler::now_ns()
{
using namespace std::chrono;
return duration_cast<nanoseconds>(system_clock::now().time_since_epoch()).count();
}
} // namespace kte
+106
View File
@@ -0,0 +1,106 @@
// ErrorHandler.h - Centralized error handling and logging for kte
#pragma once
#include <string>
#include <vector>
#include <deque>
#include <mutex>
#include <cstdint>
#include <memory>
#include <fstream>
namespace kte {
enum class ErrorSeverity {
Info, // Informational messages
Warning, // Non-critical issues
Error, // Errors that affect functionality but allow continuation
Critical // Critical errors that may cause data loss or crashes
};
// Centralized error handler with logging and in-memory error tracking
class ErrorHandler {
public:
struct ErrorRecord {
std::uint64_t timestamp_ns{0};
ErrorSeverity severity{ErrorSeverity::Error};
std::string component; // e.g., "SwapManager", "Buffer", "main"
std::string message;
std::string context; // e.g., filename, buffer name, operation
};
// Get the global ErrorHandler instance
static ErrorHandler &Instance();
// Report an error with severity, component, message, and optional context
void Report(ErrorSeverity severity, const std::string &component,
const std::string &message, const std::string &context = "");
// Convenience methods for common severity levels
void Info(const std::string &component, const std::string &message,
const std::string &context = "");
void Warning(const std::string &component, const std::string &message,
const std::string &context = "");
void Error(const std::string &component, const std::string &message,
const std::string &context = "");
void Critical(const std::string &component, const std::string &message,
const std::string &context = "");
// Query error state (thread-safe)
bool HasErrors() const;
bool HasCriticalErrors() const;
std::string GetLastError() const;
std::size_t GetErrorCount() const;
std::size_t GetErrorCount(ErrorSeverity severity) const;
// Get recent errors (up to max_count, most recent first)
std::vector<ErrorRecord> GetRecentErrors(std::size_t max_count = 10) const;
// Clear in-memory error history (does not affect log file)
void ClearErrors();
// Enable/disable file logging (enabled by default)
void SetFileLoggingEnabled(bool enabled);
// Get the path to the error log file
std::string GetLogFilePath() const;
private:
ErrorHandler();
~ErrorHandler();
// Non-copyable, non-movable
ErrorHandler(const ErrorHandler &) = delete;
ErrorHandler &operator=(const ErrorHandler &) = delete;
ErrorHandler(ErrorHandler &&) = delete;
ErrorHandler &operator=(ErrorHandler &&) = delete;
void write_to_log(const ErrorRecord &record);
void ensure_log_file();
std::string format_timestamp(std::uint64_t timestamp_ns) const;
std::string severity_to_string(ErrorSeverity severity) const;
static std::uint64_t now_ns();
mutable std::mutex mtx_;
std::deque<ErrorRecord> errors_; // bounded to max 100 entries
std::size_t total_error_count_{0};
std::size_t critical_error_count_{0};
bool file_logging_enabled_{true};
std::string log_file_path_;
std::unique_ptr<std::ofstream> log_file_;
};
} // namespace kte
+159
View File
@@ -0,0 +1,159 @@
// ErrorRecovery.cc - Error recovery mechanisms implementation
#include "ErrorRecovery.h"
#include <mutex>
namespace kte {
CircuitBreaker::CircuitBreaker(const Config &cfg)
: config_(cfg), state_(State::Closed), failure_count_(0), success_count_(0),
last_failure_time_(std::chrono::steady_clock::time_point::min()),
state_change_time_(std::chrono::steady_clock::now()) {}
bool
CircuitBreaker::AllowRequest()
{
std::lock_guard<std::mutex> lg(mtx_);
const auto now = std::chrono::steady_clock::now();
switch (state_) {
case State::Closed:
// Normal operation, allow all requests
return true;
case State::Open: {
// Check if timeout has elapsed to transition to HalfOpen
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - state_change_time_
);
if (elapsed >= config_.open_timeout) {
TransitionTo(State::HalfOpen);
return true; // Allow one request to test recovery
}
return false; // Circuit is open, reject request
}
case State::HalfOpen:
// Allow limited requests to test recovery
return true;
}
return false;
}
void
CircuitBreaker::RecordSuccess()
{
std::lock_guard<std::mutex> lg(mtx_);
switch (state_) {
case State::Closed:
// Reset failure count on success in normal operation
failure_count_ = 0;
break;
case State::HalfOpen:
++success_count_;
if (success_count_ >= config_.success_threshold) {
// Enough successes, close the circuit
TransitionTo(State::Closed);
}
break;
case State::Open:
// Shouldn't happen (requests rejected), but handle gracefully
break;
}
}
void
CircuitBreaker::RecordFailure()
{
std::lock_guard<std::mutex> lg(mtx_);
// The window is measured from the previous failure, so check it before
// recording this one.
const bool window_expired = IsWindowExpired();
last_failure_time_ = std::chrono::steady_clock::now();
switch (state_) {
case State::Closed:
// Check if we need to reset the failure count (window expired)
if (window_expired) {
failure_count_ = 0;
}
++failure_count_;
if (failure_count_ >= config_.failure_threshold) {
// Too many failures, open the circuit
TransitionTo(State::Open);
}
break;
case State::HalfOpen:
// Failure during recovery test, reopen the circuit
TransitionTo(State::Open);
break;
case State::Open:
// Already open, just track the failure
++failure_count_;
break;
}
}
void
CircuitBreaker::Reset()
{
std::lock_guard<std::mutex> lg(mtx_);
TransitionTo(State::Closed);
}
void
CircuitBreaker::TransitionTo(State new_state)
{
if (state_ == new_state) {
return;
}
state_ = new_state;
state_change_time_ = std::chrono::steady_clock::now();
switch (new_state) {
case State::Closed:
failure_count_ = 0;
success_count_ = 0;
break;
case State::Open:
success_count_ = 0;
// Keep failure_count_ for diagnostics
break;
case State::HalfOpen:
success_count_ = 0;
// Keep failure_count_ for diagnostics
break;
}
}
bool
CircuitBreaker::IsWindowExpired() const
{
if (failure_count_ == 0) {
return false;
}
const auto now = std::chrono::steady_clock::now();
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - last_failure_time_
);
return elapsed >= config_.window;
}
} // namespace kte
+165
View File
@@ -0,0 +1,165 @@
// ErrorRecovery.h - Error recovery mechanisms for kte
#pragma once
#include <chrono>
#include <cstddef>
#include <functional>
#include <string>
#include <thread>
#include <mutex>
#include <cerrno>
namespace kte {
// Classify errno values as transient (retryable) or permanent
inline bool
IsTransientError(int err)
{
switch (err) {
case EAGAIN:
#if EAGAIN != EWOULDBLOCK
case EWOULDBLOCK:
#endif
case EBUSY:
case EIO: // I/O error (may be transient on network filesystems)
case ETIMEDOUT:
case ENOSPC: // Disk full (may become available)
case EDQUOT: // Quota exceeded (may become available)
return true;
default:
return false;
}
}
// RetryPolicy defines retry behavior for transient failures
struct RetryPolicy {
std::size_t max_attempts{3}; // Maximum retry attempts
std::chrono::milliseconds initial_delay{100}; // Initial delay before first retry
double backoff_multiplier{2.0}; // Exponential backoff multiplier
std::chrono::milliseconds max_delay{5000}; // Maximum delay between retries
// Default policy: 3 attempts, 100ms initial, 2x backoff, 5s max
static RetryPolicy Default()
{
return RetryPolicy{};
}
// Aggressive policy for critical operations: more attempts, faster retries
static RetryPolicy Aggressive()
{
return RetryPolicy{5, std::chrono::milliseconds(50), 1.5, std::chrono::milliseconds(2000)};
}
};
// Retry a function with exponential backoff for transient errors
// Returns true on success, false on permanent failure or exhausted retries
// The function `fn` should return true on success, false on failure, and set errno on failure
template<typename Func>
bool
RetryOnTransientError(Func fn, const RetryPolicy &policy, std::string &err)
{
std::size_t attempt = 0;
std::chrono::milliseconds delay = policy.initial_delay;
while (attempt < policy.max_attempts) {
++attempt;
errno = 0;
if (fn()) {
return true; // Success
}
int saved_errno = errno;
if (!IsTransientError(saved_errno)) {
// Permanent error, don't retry
return false;
}
if (attempt >= policy.max_attempts) {
// Exhausted retries
err += " (exhausted " + std::to_string(policy.max_attempts) + " retry attempts)";
return false;
}
// Sleep before retry
std::this_thread::sleep_for(delay);
// Exponential backoff
delay = std::chrono::milliseconds(
static_cast<long long>(delay.count() * policy.backoff_multiplier)
);
if (delay > policy.max_delay) {
delay = policy.max_delay;
}
}
return false;
}
// CircuitBreaker prevents repeated attempts to failing operations
// States: Closed (normal), Open (failing, reject immediately), HalfOpen (testing recovery)
class CircuitBreaker {
public:
enum class State {
Closed, // Normal operation, allow all requests
Open, // Failing, reject requests immediately
HalfOpen // Testing recovery, allow limited requests
};
struct Config {
std::size_t failure_threshold; // Failures before opening circuit
std::chrono::seconds open_timeout; // Time before attempting recovery (Open → HalfOpen)
std::size_t success_threshold; // Successes in HalfOpen before closing
std::chrono::seconds window; // Time window for counting failures
Config()
: failure_threshold(5), open_timeout(30), success_threshold(2), window(60) {}
};
explicit CircuitBreaker(const Config &cfg = Config());
// Check if operation is allowed (returns false if circuit is Open)
bool AllowRequest();
// Record successful operation
void RecordSuccess();
// Record failed operation
void RecordFailure();
// Get current state
State GetState() const
{
return state_;
}
// Get failure count in current window
std::size_t GetFailureCount() const
{
return failure_count_;
}
// Reset circuit to Closed state (for testing or manual intervention)
void Reset();
private:
void TransitionTo(State new_state);
bool IsWindowExpired() const;
Config config_;
State state_;
std::size_t failure_count_;
std::size_t success_count_;
std::chrono::steady_clock::time_point last_failure_time_;
std::chrono::steady_clock::time_point state_change_time_;
mutable std::mutex mtx_;
};
} // namespace kte
+151 -12
View File
@@ -1,11 +1,49 @@
#include <cmath>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
#include <algorithm> #include <algorithm>
#include <filesystem>
#include <iostream>
#include "GUIConfig.h" #include "GUIConfig.h"
// toml++ for TOML config parsing
#if defined(__clang__)
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Weverything"
#elif defined(__GNUC__)
# pragma GCC diagnostic push
# pragma GCC diagnostic ignored "-Wall"
# pragma GCC diagnostic ignored "-Wextra"
#endif
#include "ext/tomlplusplus/toml.hpp"
#if defined(__clang__)
# pragma clang diagnostic pop
#elif defined(__GNUC__)
# pragma GCC diagnostic pop
#endif
// Reject values that would overflow later arithmetic (columns * font size)
// or that are not usable: "size = inf" is valid TOML.
static bool
valid_dimension(long long v)
{
return v > 0 && v <= 1000;
}
static bool
valid_font_size(double v)
{
return std::isfinite(v) && v >= 4.0 && v <= 200.0;
}
static void static void
trim(std::string &s) trim(std::string &s)
@@ -19,37 +57,124 @@ trim(std::string &s)
static std::string static std::string
default_config_path() config_dir()
{ {
const char *home = std::getenv("HOME"); const char *home = std::getenv("HOME");
if (!home || !*home) if (!home || !*home)
return {}; return {};
std::string path(home); return std::string(home) + "/.config/kte";
path += "/.config/kte/kge.ini";
return path;
} }
GUIConfig GUIConfig
GUIConfig::Load() GUIConfig::Load()
{ {
GUIConfig cfg; // defaults already set GUIConfig cfg;
const std::string path = default_config_path(); std::string dir = config_dir();
if (dir.empty())
return cfg;
if (!path.empty()) { // Try TOML first
cfg.LoadFromFile(path); std::string toml_path = dir + "/kge.toml";
if (cfg.LoadFromTOML(toml_path))
return cfg;
// Fall back to legacy INI
std::string ini_path = dir + "/kge.ini";
if (cfg.LoadFromINI(ini_path)) {
std::cerr << "kge: loaded legacy kge.ini; consider migrating to kge.toml\n";
return cfg;
} }
return cfg; return cfg;
} }
bool bool
GUIConfig::LoadFromFile(const std::string &path) GUIConfig::LoadFromTOML(const std::string &path)
{
if (!std::filesystem::exists(path))
return false;
toml::table tbl;
try {
tbl = toml::parse_file(path);
} catch (const toml::parse_error &err) {
std::cerr << "kge: TOML parse error in " << path << ": " << err.what() << "\n";
return false;
}
// [window]
if (auto win = tbl["window"].as_table()) {
if (auto v = (*win)["fullscreen"].value<bool>())
fullscreen = *v;
if (auto v = (*win)["columns"].value<int64_t>()) {
if (valid_dimension(*v)) columns = static_cast<int>(*v);
}
if (auto v = (*win)["rows"].value<int64_t>()) {
if (valid_dimension(*v)) rows = static_cast<int>(*v);
}
}
// [font]
bool explicit_code_font = false;
bool explicit_writing_font = false;
if (auto sec = tbl["font"].as_table()) {
if (auto v = (*sec)["name"].value<std::string>())
font = *v;
if (auto v = (*sec)["size"].value<double>()) {
if (valid_font_size(*v)) font_size = static_cast<float>(*v);
}
if (auto v = (*sec)["code"].value<std::string>()) {
code_font = *v;
explicit_code_font = true;
}
if (auto v = (*sec)["writing"].value<std::string>()) {
writing_font = *v;
explicit_writing_font = true;
}
}
// [appearance]
if (auto sec = tbl["appearance"].as_table()) {
if (auto v = (*sec)["theme"].value<std::string>())
theme = *v;
if (auto v = (*sec)["background"].value<std::string>()) {
std::string bg = *v;
std::transform(bg.begin(), bg.end(), bg.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
if (bg == "light" || bg == "dark")
background = bg;
}
}
// [editor]
if (auto sec = tbl["editor"].as_table()) {
if (auto v = (*sec)["syntax"].value<bool>())
syntax = *v;
}
// Default code_font to the main font if not explicitly set
if (!explicit_code_font)
code_font = font;
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
writing_font = font;
return true;
}
bool
GUIConfig::LoadFromINI(const std::string &path)
{ {
std::ifstream in(path); std::ifstream in(path);
if (!in.good()) if (!in.good())
return false; return false;
bool explicit_code_font = false;
bool explicit_writing_font = false;
std::string line; std::string line;
while (std::getline(in, line)) { while (std::getline(in, line)) {
// Remove comments starting with '#' or ';' // Remove comments starting with '#' or ';'
@@ -85,25 +210,31 @@ GUIConfig::LoadFromFile(const std::string &path)
try { try {
v = std::stoi(val); v = std::stoi(val);
} catch (...) {} } catch (...) {}
if (v > 0) if (valid_dimension(v))
columns = v; columns = v;
} else if (key == "rows") { } else if (key == "rows") {
int v = rows; int v = rows;
try { try {
v = std::stoi(val); v = std::stoi(val);
} catch (...) {} } catch (...) {}
if (v > 0) if (valid_dimension(v))
rows = v; rows = v;
} else if (key == "font_size" || key == "fontsize") { } else if (key == "font_size" || key == "fontsize") {
float v = font_size; float v = font_size;
try { try {
v = std::stof(val); v = std::stof(val);
} catch (...) {} } catch (...) {}
if (v > 0.0f) { if (valid_font_size(v)) {
font_size = v; font_size = v;
} }
} else if (key == "font") { } else if (key == "font") {
font = val; font = val;
} else if (key == "code_font") {
code_font = val;
explicit_code_font = true;
} else if (key == "writing_font") {
writing_font = val;
explicit_writing_font = true;
} else if (key == "theme") { } else if (key == "theme") {
theme = val; theme = val;
} else if (key == "background" || key == "bg") { } else if (key == "background" || key == "bg") {
@@ -126,5 +257,13 @@ GUIConfig::LoadFromFile(const std::string &path)
} }
} }
// If code_font was not explicitly set, default it to the main font
// so that the edit-mode font switcher doesn't immediately switch away
// from the font loaded during Init.
if (!explicit_code_font)
code_font = font;
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
writing_font = font;
return true; return true;
} }
+14 -6
View File
@@ -1,5 +1,7 @@
/* /*
* GUIConfig - loads simple GUI configuration from $HOME/.config/kte/kge.ini * GUIConfig - loads GUI configuration from $HOME/.config/kte/kge.toml
*
* Falls back to legacy kge.ini if no TOML config is found.
*/ */
#pragma once #pragma once
@@ -22,12 +24,18 @@ public:
std::string background = "dark"; std::string background = "dark";
// Default syntax highlighting state for GUI (kge): on/off // Default syntax highlighting state for GUI (kge): on/off
// Accepts: on/off/true/false/yes/no/1/0 in the ini file. bool syntax = true;
bool syntax = true; // default: enabled
// Load from default path: $HOME/.config/kte/kge.ini // Per-mode font defaults
std::string code_font = "default";
std::string writing_font = "crimsonpro";
// Load from default paths: try kge.toml first, fall back to kge.ini
static GUIConfig Load(); static GUIConfig Load();
// Load from explicit path. Returns true if file existed and was parsed. // Load from explicit TOML path. Returns true if file existed and was parsed.
bool LoadFromFile(const std::string &path); bool LoadFromTOML(const std::string &path);
// Load from explicit INI path (legacy). Returns true if file existed and was parsed.
bool LoadFromINI(const std::string &path);
}; };
+187 -311
View File
@@ -8,296 +8,6 @@
#include "Highlight.h" #include "Highlight.h"
// Cross-frontend theme change request hook: declared here, defined in Command.cc
namespace kte {
extern bool gThemeChangePending;
extern std::string gThemeChangeRequest; // raw user-provided name
// Qt GUI: cross-frontend font change hooks and current font state
extern bool gFontChangePending;
extern std::string gFontFamilyRequest; // requested family (case-insensitive)
extern float gFontSizeRequest; // <= 0 means keep size
extern std::string gCurrentFontFamily; // last applied family (Qt)
extern float gCurrentFontSize; // last applied size (Qt)
// Qt GUI: request to show a visual font dialog (set by command handler)
extern bool gFontDialogRequested;
}
#if defined(KTE_USE_QT)
// Qt build: avoid hard dependency on ImGui headers/types.
// Provide a lightweight color vector matching ImVec4 fields used by renderers.
struct KteColor {
float x{0}, y{0}, z{0}, w{1};
};
static inline KteColor
RGBA(unsigned int rgb, float a = 1.0f)
{
const float r = static_cast<float>((rgb >> 16) & 0xFF) / 255.0f;
const float g = static_cast<float>((rgb >> 8) & 0xFF) / 255.0f;
const float b = static_cast<float>(rgb & 0xFF) / 255.0f;
return {r, g, b, a};
}
namespace kte {
// Background mode selection for light/dark palettes
enum class BackgroundMode { Light, Dark };
// Global background mode; default to Dark to match prior defaults
static inline auto gBackgroundMode = BackgroundMode::Dark;
static inline void
SetBackgroundMode(const BackgroundMode m)
{
gBackgroundMode = m;
}
static inline BackgroundMode
GetBackgroundMode()
{
return gBackgroundMode;
}
// Minimal GUI palette for Qt builds. This mirrors the defaults used in the ImGui
// frontend (Nord-ish) and switches for light/dark background mode.
struct Palette {
KteColor bg; // editor background
KteColor fg; // default foreground text
KteColor sel_bg; // selection background
KteColor cur_bg; // cursor cell background
KteColor status_bg; // status bar background
KteColor status_fg; // status bar foreground
};
// Optional theme override (Qt): when set, GetPalette() will return this instead
// of the generic light/dark defaults. This allows honoring theme names in kge.ini.
static inline bool gPaletteOverride = false;
static inline Palette gOverridePalette{};
static inline std::string gOverrideThemeName = ""; // lowercased name
static inline Palette
GetPalette()
{
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
if (gPaletteOverride) {
return gOverridePalette;
}
if (dark) {
return Palette{
/*bg*/ RGBA(0x1C1C1E),
/*fg*/ RGBA(0xDCDCDC),
/*sel_bg*/ RGBA(0xC8C800, 0.35f),
/*cur_bg*/ RGBA(0xC8C8FF, 0.50f),
/*status_bg*/ RGBA(0x28282C),
/*status_fg*/ RGBA(0xB4B48C)
};
} else {
// Light palette tuned for readability
return Palette{
/*bg*/ RGBA(0xFBFBFC),
/*fg*/ RGBA(0x30343A),
/*sel_bg*/ RGBA(0x268BD2, 0.22f),
/*cur_bg*/ RGBA(0x000000, 0.15f),
/*status_bg*/ RGBA(0xE6E8EA),
/*status_fg*/ RGBA(0x50555A)
};
}
}
// A few named palettes to provide visible differences between themes in Qt.
// These are approximate and palette-based (no widget style changes like ImGuiStyle).
static inline Palette
NordDark()
{
return {
/*bg*/RGBA(0x2E3440), /*fg*/RGBA(0xD8DEE9), /*sel_bg*/RGBA(0x88C0D0, 0.25f),
/*cur_bg*/RGBA(0x81A1C1, 0.35f), /*status_bg*/RGBA(0x3B4252), /*status_fg*/RGBA(0xE5E9F0)
};
}
static inline Palette
NordLight()
{
return {
/*bg*/RGBA(0xECEFF4), /*fg*/RGBA(0x2E3440), /*sel_bg*/RGBA(0x5E81AC, 0.22f),
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0xE5E9F0), /*status_fg*/RGBA(0x4C566A)
};
}
static inline Palette
SolarizedDark()
{
return {
/*bg*/RGBA(0x002b36), /*fg*/RGBA(0x93a1a1), /*sel_bg*/RGBA(0x586e75, 0.40f),
/*cur_bg*/RGBA(0x657b83, 0.35f), /*status_bg*/RGBA(0x073642), /*status_fg*/RGBA(0xeee8d5)
};
}
static inline Palette
SolarizedLight()
{
return {
/*bg*/RGBA(0xfdf6e3), /*fg*/RGBA(0x586e75), /*sel_bg*/RGBA(0x268bd2, 0.25f),
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xeee8d5), /*status_fg*/RGBA(0x657b83)
};
}
static inline Palette
GruvboxDark()
{
return {
/*bg*/RGBA(0x282828), /*fg*/RGBA(0xebdbb2), /*sel_bg*/RGBA(0xd79921, 0.35f),
/*cur_bg*/RGBA(0x458588, 0.40f), /*status_bg*/RGBA(0x3c3836), /*status_fg*/RGBA(0xd5c4a1)
};
}
static inline Palette
GruvboxLight()
{
return {
/*bg*/RGBA(0xfbf1c7), /*fg*/RGBA(0x3c3836), /*sel_bg*/RGBA(0x076678, 0.22f),
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xebdbb2), /*status_fg*/RGBA(0x504945)
};
}
static inline Palette
EInk()
{
return {
/*bg*/RGBA(0xffffff), /*fg*/RGBA(0x000000), /*sel_bg*/RGBA(0x000000, 0.10f),
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0x000000), /*status_fg*/RGBA(0xffffff)
};
}
// Apply a Qt theme by name. Returns true on success. Name matching is case-insensitive and
// supports common aliases (e.g., "solarized-light" or "solarized light"). If the name conveys
// a background (light/dark), BackgroundMode is updated to keep SyntaxInk consistent.
static inline bool
ApplyQtThemeByName(std::string name)
{
// normalize
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
auto has = [&](const std::string &s) {
return name.find(s) != std::string::npos;
};
if (name.empty() || name == "default" || name == "nord") {
// Choose variant by current background mode
if (GetBackgroundMode() == BackgroundMode::Dark) {
gOverridePalette = NordDark();
} else {
gOverridePalette = NordLight();
}
gPaletteOverride = true;
gOverrideThemeName = "nord";
return true;
}
if (has("solarized")) {
if (has("light")) {
SetBackgroundMode(BackgroundMode::Light);
gOverridePalette = SolarizedLight();
} else if (has("dark")) {
SetBackgroundMode(BackgroundMode::Dark);
gOverridePalette = SolarizedDark();
} else {
// pick from current background
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
? SolarizedDark()
: SolarizedLight();
}
gPaletteOverride = true;
gOverrideThemeName = "solarized";
return true;
}
if (has("gruvbox")) {
if (has("light")) {
SetBackgroundMode(BackgroundMode::Light);
gOverridePalette = GruvboxLight();
} else if (has("dark")) {
SetBackgroundMode(BackgroundMode::Dark);
gOverridePalette = GruvboxDark();
} else {
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
? GruvboxDark()
: GruvboxLight();
}
gPaletteOverride = true;
gOverrideThemeName = "gruvbox";
return true;
}
if (has("eink") || has("e-ink") || has("paper")) {
SetBackgroundMode(BackgroundMode::Light);
gOverridePalette = EInk();
gPaletteOverride = true;
gOverrideThemeName = "eink";
return true;
}
// Unknown -> clear override so default light/dark applies; return false.
gPaletteOverride = false;
gOverrideThemeName.clear();
return false;
}
// Minimal SyntaxInk mapping for Qt builds, returning KteColor
[[maybe_unused]] static KteColor
SyntaxInk(const TokenKind k)
{
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
const KteColor def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
switch (k) {
case TokenKind::Keyword:
return dark ? RGBA(0x81A1C1) : RGBA(0x5E81AC);
case TokenKind::Type:
return dark ? RGBA(0x8FBCBB) : RGBA(0x4C566A);
case TokenKind::String:
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
case TokenKind::Char:
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
case TokenKind::Comment:
return dark ? RGBA(0x616E88) : RGBA(0x7A869A);
case TokenKind::Number:
return dark ? RGBA(0xEBCB8B) : RGBA(0xB58900);
case TokenKind::Preproc:
return dark ? RGBA(0xD08770) : RGBA(0xAF3A03);
case TokenKind::Constant:
return dark ? RGBA(0xB48EAD) : RGBA(0x7B4B7F);
case TokenKind::Function:
return dark ? RGBA(0x88C0D0) : RGBA(0x3465A4);
case TokenKind::Operator:
return dark ? RGBA(0x2E3440) : RGBA(0x2E3440);
case TokenKind::Punctuation:
return dark ? RGBA(0xECEFF4) : RGBA(0x2E3440);
case TokenKind::Identifier:
return def;
case TokenKind::Whitespace:
return def;
case TokenKind::Error:
return dark ? RGBA(0xBF616A) : RGBA(0xCC0000);
case TokenKind::Default: default:
return def;
}
}
} // namespace kte
#else
#include <imgui.h> #include <imgui.h>
#include <vector> #include <vector>
#include <memory> #include <memory>
@@ -312,7 +22,7 @@ namespace kte {
enum class BackgroundMode { Light, Dark }; enum class BackgroundMode { Light, Dark };
// Global background mode; default to Dark to match prior defaults // Global background mode; default to Dark to match prior defaults
static inline auto gBackgroundMode = BackgroundMode::Dark; inline auto gBackgroundMode = BackgroundMode::Dark;
// Basic theme identifier (kept minimal; some ids are aliases) // Basic theme identifier (kept minimal; some ids are aliases)
enum class ThemeId { enum class ThemeId {
@@ -330,11 +40,13 @@ enum class ThemeId {
Amber = 10, Amber = 10,
WeylandYutani = 11, WeylandYutani = 11,
Orbital = 12, Orbital = 12,
Tufte = 13,
Leuchtturm = 14,
}; };
// Current theme tracking // Current theme tracking
static inline auto gCurrentTheme = ThemeId::Nord; inline auto gCurrentTheme = ThemeId::Nord;
static inline std::size_t gCurrentThemeIndex = 6; // Nord index inline std::size_t gCurrentThemeIndex = 7; // Nord index
// Forward declarations for helpers used below // Forward declarations for helpers used below
static size_t ThemeIndexFromId(ThemeId id); static size_t ThemeIndexFromId(ThemeId id);
@@ -372,11 +84,13 @@ BackgroundModeName()
#include "themes/Everforest.h" #include "themes/Everforest.h"
#include "themes/KanagawaPaper.h" #include "themes/KanagawaPaper.h"
#include "themes/LCARS.h" #include "themes/LCARS.h"
#include "themes/Leuchtturm.h"
#include "themes/OldBook.h" #include "themes/OldBook.h"
#include "themes/Amber.h" #include "themes/Amber.h"
#include "themes/WeylandYutani.h" #include "themes/WeylandYutani.h"
#include "themes/Zenburn.h" #include "themes/Zenburn.h"
#include "themes/Orbital.h" #include "themes/Orbital.h"
#include "themes/Tufte.h"
// Theme abstraction and registry (generalized theme system) // Theme abstraction and registry (generalized theme system)
@@ -409,6 +123,28 @@ struct LCARSTheme final : Theme {
} }
}; };
struct LeuchtturmTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
return "leuchtturm";
}
void Apply() const override
{
if (gBackgroundMode == BackgroundMode::Dark)
ApplyLeuchtturmDarkTheme();
else
ApplyLeuchtturmLightTheme();
}
ThemeId Id() override
{
return ThemeId::Leuchtturm;
}
};
struct EverforestTheme final : Theme { struct EverforestTheme final : Theme {
[[nodiscard]] const char *Name() const override [[nodiscard]] const char *Name() const override
{ {
@@ -488,6 +224,28 @@ struct OrbitalTheme final : Theme {
} }
}; };
struct TufteTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
return "tufte";
}
void Apply() const override
{
if (gBackgroundMode == BackgroundMode::Dark)
ApplyTufteDarkTheme();
else
ApplyTufteLightTheme();
}
ThemeId Id() override
{
return ThemeId::Tufte;
}
};
struct ZenburnTheme final : Theme { struct ZenburnTheme final : Theme {
[[nodiscard]] const char *Name() const override [[nodiscard]] const char *Name() const override
{ {
@@ -657,18 +415,20 @@ ThemeRegistry()
static std::vector<std::unique_ptr<Theme> > reg; static std::vector<std::unique_ptr<Theme> > reg;
if (reg.empty()) { if (reg.empty()) {
// Alphabetical by canonical name: // Alphabetical by canonical name:
// amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, orbital, plan9, solarized, weyland-yutani, zenburn // amber, eink, everforest, gruvbox, kanagawa-paper, lcars, leuchtturm, nord, old-book, orbital, plan9, solarized, tufte, weyland-yutani, zenburn
reg.emplace_back(std::make_unique<detail::AmberTheme>()); reg.emplace_back(std::make_unique<detail::AmberTheme>());
reg.emplace_back(std::make_unique<detail::EInkTheme>()); reg.emplace_back(std::make_unique<detail::EInkTheme>());
reg.emplace_back(std::make_unique<detail::EverforestTheme>()); reg.emplace_back(std::make_unique<detail::EverforestTheme>());
reg.emplace_back(std::make_unique<detail::GruvboxTheme>()); reg.emplace_back(std::make_unique<detail::GruvboxTheme>());
reg.emplace_back(std::make_unique<detail::KanagawaPaperTheme>()); reg.emplace_back(std::make_unique<detail::KanagawaPaperTheme>());
reg.emplace_back(std::make_unique<detail::LCARSTheme>()); reg.emplace_back(std::make_unique<detail::LCARSTheme>());
reg.emplace_back(std::make_unique<detail::LeuchtturmTheme>());
reg.emplace_back(std::make_unique<detail::NordTheme>()); reg.emplace_back(std::make_unique<detail::NordTheme>());
reg.emplace_back(std::make_unique<detail::OldBookTheme>()); reg.emplace_back(std::make_unique<detail::OldBookTheme>());
reg.emplace_back(std::make_unique<detail::OrbitalTheme>()); reg.emplace_back(std::make_unique<detail::OrbitalTheme>());
reg.emplace_back(std::make_unique<detail::Plan9Theme>()); reg.emplace_back(std::make_unique<detail::Plan9Theme>());
reg.emplace_back(std::make_unique<detail::SolarizedTheme>()); reg.emplace_back(std::make_unique<detail::SolarizedTheme>());
reg.emplace_back(std::make_unique<detail::TufteTheme>());
reg.emplace_back(std::make_unique<detail::WeylandYutaniTheme>()); reg.emplace_back(std::make_unique<detail::WeylandYutaniTheme>());
reg.emplace_back(std::make_unique<detail::ZenburnTheme>()); reg.emplace_back(std::make_unique<detail::ZenburnTheme>());
} }
@@ -845,20 +605,24 @@ ThemeIndexFromId(const ThemeId id)
return 4; return 4;
case ThemeId::LCARS: case ThemeId::LCARS:
return 5; return 5;
case ThemeId::Nord: case ThemeId::Leuchtturm:
return 6; return 6;
case ThemeId::OldBook: case ThemeId::Nord:
return 7; return 7;
case ThemeId::Orbital: case ThemeId::OldBook:
return 8; return 8;
case ThemeId::Plan9: case ThemeId::Orbital:
return 9; return 9;
case ThemeId::Solarized: case ThemeId::Plan9:
return 10; return 10;
case ThemeId::WeylandYutani: case ThemeId::Solarized:
return 11; return 11;
case ThemeId::Zenburn: case ThemeId::Tufte:
return 12; return 12;
case ThemeId::WeylandYutani:
return 13;
case ThemeId::Zenburn:
return 14;
} }
return 0; return 0;
} }
@@ -882,30 +646,144 @@ ThemeIdFromIndex(const size_t idx)
case 5: case 5:
return ThemeId::LCARS; return ThemeId::LCARS;
case 6: case 6:
return ThemeId::Nord; return ThemeId::Leuchtturm;
case 7: case 7:
return ThemeId::OldBook; return ThemeId::Nord;
case 8: case 8:
return ThemeId::Orbital; return ThemeId::OldBook;
case 9: case 9:
return ThemeId::Plan9; return ThemeId::Orbital;
case 10: case 10:
return ThemeId::Solarized; return ThemeId::Plan9;
case 11: case 11:
return ThemeId::WeylandYutani; return ThemeId::Solarized;
case 12: case 12:
return ThemeId::Tufte;
case 13:
return ThemeId::WeylandYutani;
case 14:
return ThemeId::Zenburn; return ThemeId::Zenburn;
} }
} }
// --- Syntax palette (v1): map TokenKind to ink color per current theme/background --- // --- Syntax palette (v1): map TokenKind to ink color per current theme/background ---
// Tufte palette: high-contrast, restrained color. Body text is true black on
// cream; only keywords and links get subtle color to avoid a "christmas tree."
static ImVec4
SyntaxInkTufte(const TokenKind k, const bool dark)
{
const ImVec4 ink = dark ? RGBA(0xEAE6DE) : RGBA(0x111111); // body text
const ImVec4 dim = dark ? RGBA(0x8A8680) : RGBA(0x555555); // comments
const ImVec4 red = dark ? RGBA(0xD06060) : RGBA(0x8B0000); // keywords/preproc
const ImVec4 navy = dark ? RGBA(0x7098C0) : RGBA(0x1A3A5C); // functions/links
const ImVec4 grn = dark ? RGBA(0x8AAA6E) : RGBA(0x2E5E2E); // strings
switch (k) {
case TokenKind::Keyword:
case TokenKind::Preproc:
return red;
case TokenKind::String:
case TokenKind::Char:
return grn;
case TokenKind::Comment:
return dim;
case TokenKind::Function:
return navy;
case TokenKind::Number:
case TokenKind::Constant:
return dark ? RGBA(0xC8A85A) : RGBA(0x6B4C00);
case TokenKind::Type:
return dark ? RGBA(0xBBAA90) : RGBA(0x333333);
case TokenKind::Error:
return dark ? RGBA(0xD06060) : RGBA(0xCC0000);
default:
return ink;
}
}
// Leuchtturm palette: blue-black fountain pen ink with brass and bronze accents.
// Body text is ink-colored; accents drawn from the pen metals.
static ImVec4
SyntaxInkLeuchtturm(const TokenKind k, const bool dark)
{
const ImVec4 ink = dark ? RGBA(0xE5DDD0) : RGBA(0x040720); // fountain pen ink
const ImVec4 dim = dark ? RGBA(0x7A7060) : RGBA(0x6A6558); // comments
const ImVec4 brass = dark ? RGBA(0xB8A060) : RGBA(0x504518); // patinated brass
const ImVec4 bronze= dark ? RGBA(0xC08050) : RGBA(0x5C3010); // dark bronze
const ImVec4 navy = dark ? RGBA(0x8898B0) : RGBA(0x1C2E4A); // deep navy
switch (k) {
case TokenKind::Keyword:
case TokenKind::Preproc:
return brass;
case TokenKind::String:
case TokenKind::Char:
return bronze;
case TokenKind::Comment:
return dim;
case TokenKind::Function:
return navy;
case TokenKind::Number:
case TokenKind::Constant:
return dark ? RGBA(0xA89060) : RGBA(0x483C10);
case TokenKind::Type:
return dark ? RGBA(0xC0B898) : RGBA(0x222238);
case TokenKind::Error:
return dark ? RGBA(0xD06060) : RGBA(0xA02020);
default:
return ink;
}
}
// Everforest: warm forest palette on dark green-gray (bg 0x2B3339).
// Default comment color (0x616E88) is too dim; boost it and tune others.
static ImVec4
SyntaxInkEverforest(const TokenKind k)
{
switch (k) {
case TokenKind::Keyword:
return RGBA(0xE67E80); // everforest red
case TokenKind::Type:
return RGBA(0xD699B6); // everforest purple
case TokenKind::String:
case TokenKind::Char:
return RGBA(0xA7C080); // everforest green
case TokenKind::Comment:
return RGBA(0x859289); // boosted from 0x616E88 for contrast
case TokenKind::Number:
case TokenKind::Constant:
return RGBA(0xD8A657); // everforest yellow/orange
case TokenKind::Preproc:
return RGBA(0xE69875); // everforest orange
case TokenKind::Function:
return RGBA(0x83C092); // everforest aqua
case TokenKind::Operator:
case TokenKind::Punctuation:
return RGBA(0xD3C6AA); // everforest fg
case TokenKind::Error:
return RGBA(0xE67E80);
default:
return RGBA(0xD3C6AA); // everforest fg
}
}
[[maybe_unused]] static ImVec4 [[maybe_unused]] static ImVec4
SyntaxInk(const TokenKind k) SyntaxInk(const TokenKind k)
{ {
// Basic palettes for dark/light backgrounds; tuned for Nord-ish defaults
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark); const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
// Base text
// Per-theme syntax palettes
if (gCurrentTheme == ThemeId::Tufte)
return SyntaxInkTufte(k, dark);
if (gCurrentTheme == ThemeId::Leuchtturm)
return SyntaxInkLeuchtturm(k, dark);
if (gCurrentTheme == ThemeId::Everforest)
return SyntaxInkEverforest(k);
// Default palettes tuned for Nord-ish themes
const ImVec4 def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440); const ImVec4 def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
switch (k) { switch (k) {
case TokenKind::Keyword: case TokenKind::Keyword:
@@ -941,5 +819,3 @@ SyntaxInk(const TokenKind k)
} }
} }
} // namespace kte } // namespace kte
#endif // KTE_USE_QT
+34 -5
View File
@@ -22,21 +22,26 @@ HelpText::Text()
" C-k ' Toggle read-only\n" " C-k ' Toggle read-only\n"
" C-k - Unindent region (mark required)\n" " C-k - Unindent region (mark required)\n"
" C-k = Indent region (mark required)\n" " C-k = Indent region (mark required)\n"
" C-k / Toggle visual line mode\n"
" C-k ; Command prompt (:\\ )\n" " C-k ; Command prompt (:\\ )\n"
" C-k SPACE Toggle mark\n"
" C-k C-d Kill entire line\n" " C-k C-d Kill entire line\n"
" C-k C-q Quit now (no confirm)\n" " C-k C-q Quit now (no confirm)\n"
" C-k C-s Save\n"
" C-k C-x Save and quit\n" " C-k C-x Save and quit\n"
" C-k a Mark start of file, jump to end\n" " C-k a Mark start of file, jump to end\n"
" C-k b Switch buffer\n" " C-k b Switch buffer\n"
" C-k c Close current buffer\n" " C-k c Close current buffer\n"
" C-k d Kill to end of line\n" " C-k d Kill to end of line\n"
" C-k e Open file (prompt)\n" " C-k e Open file (prompt)\n"
" C-k i New empty buffer\n"
" C-k f Flush kill ring\n" " C-k f Flush kill ring\n"
" C-k g Jump to line\n" " C-k g Jump to line\n"
" C-k h Show this help\n" " C-k h Show this help\n"
" C-k i New empty buffer\n"
" C-k j Jump to mark\n" " C-k j Jump to mark\n"
" C-k k Center viewport on cursor\n"
" C-k l Reload buffer from disk\n" " C-k l Reload buffer from disk\n"
" C-k m Toggle edit mode (code/writing)\n"
" C-k n Previous buffer\n" " C-k n Previous buffer\n"
" C-k o Change working directory (prompt)\n" " C-k o Change working directory (prompt)\n"
" C-k p Next buffer\n" " C-k p Next buffer\n"
@@ -60,6 +65,10 @@ HelpText::Text()
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n" " ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
" ESC q Reflow paragraph\n" " ESC q Reflow paragraph\n"
"\n" "\n"
"Universal argument:\n"
" C-u Begin repeat count (then type digits); C-u alone multiplies by 4\n"
" C-u N <cmd> Repeat <cmd> N times (e.g., C-u 8 C-f moves right 8 chars)\n"
"\n"
"Control keys:\n" "Control keys:\n"
" C-a C-e Line start / end\n" " C-a C-e Line start / end\n"
" C-b C-f Move left / right\n" " C-b C-f Move left / right\n"
@@ -71,12 +80,32 @@ HelpText::Text()
" C-t Regex search & replace\n" " C-t Regex search & replace\n"
" C-h Search & replace\n" " C-h Search & replace\n"
" C-l / C-g Refresh / Cancel\n" " C-l / C-g Refresh / Cancel\n"
" C-u [digits] Universal argument (repeat count)\n"
"\n" "\n"
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n" "Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
"\n" "\n"
"GUI appearance (command prompt):\n" "Edit modes:\n"
" : theme NAME Set GUI theme (amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, plan9, solarized, weyland-yutani, zenburn)\n" " code Monospace font (default for source files)\n"
" : background MODE Set background: light | dark (affects eink, gruvbox, old-book, solarized)\n" " writing Proportional font (auto for .txt, .md, .rst, .org, .tex)\n"
" C-k m or : mode [code|writing] to toggle\n"
"\n"
"GUI commands (command prompt):\n"
" : theme NAME Set theme (amber, eink, everforest, gruvbox,\n"
" kanagawa-paper, lcars, leuchtturm, nord, old-book,\n"
" orbital, plan9, solarized, tufte, weyland-yutani,\n"
" zenburn)\n"
" : background MODE Background: light | dark\n"
" : font NAME Set font (tab completes)\n"
" : font-size NUM Set font size in pixels\n"
" : mode [code|writing] Toggle or set edit mode\n"
"\n"
"Configuration:\n"
" Config file: ~/.config/kte/kge.toml (see CONFIG.md)\n"
" Legacy kge.ini is also supported.\n"
"\n"
"GUI window management:\n"
" Cmd+N (macOS) Open a new editor window sharing the same buffers\n"
" Ctrl+Shift+N (Linux) Open a new editor window sharing the same buffers\n"
" Close window Secondary windows close independently; closing the\n"
" primary window quits the editor\n"
); );
} }
+42
View File
@@ -1,7 +1,10 @@
// Highlight.h - core syntax highlighting types for kte // Highlight.h - core syntax highlighting types for kte
#pragma once #pragma once
#include <algorithm>
#include <cstddef>
#include <cstdint> #include <cstdint>
#include <string_view>
#include <vector> #include <vector>
namespace kte { namespace kte {
@@ -34,4 +37,43 @@ struct LineHighlight {
std::vector<HighlightSpan> spans; std::vector<HighlightSpan> spans;
std::uint64_t version{0}; // buffer version used for this line std::uint64_t version{0}; // buffer version used for this line
}; };
// A line's spans ready to draw: clamped to the line, ordered by start, not
// overlapping (the earlier span wins), and widened to whole UTF-8
// characters. Highlighters may emit one span per byte of a non-ASCII
// character; drawn separately, each byte showed as a replacement glyph.
inline void
SanitizeSpans(std::string_view line, const std::vector<HighlightSpan> &in, std::vector<HighlightSpan> &out)
{
out.clear();
const long len = static_cast<long>(line.size());
for (const HighlightSpan &sp: in) {
long s = std::clamp<long>(std::min(sp.col_start, sp.col_end), 0, len);
long e = std::clamp<long>(std::max(sp.col_start, sp.col_end), 0, len);
if (e > s)
out.push_back(HighlightSpan{static_cast<int>(s), static_cast<int>(e), sp.kind});
}
std::stable_sort(out.begin(), out.end(), [](const HighlightSpan &a, const HighlightSpan &b) {
return a.col_start < b.col_start;
});
auto continuation = [&](long i) {
return i < len && (static_cast<unsigned char>(line[static_cast<std::size_t>(i)]) & 0xC0) == 0x80;
};
std::size_t w = 0;
long prev_end = 0;
for (const HighlightSpan &sp: out) {
long s = sp.col_start, e = sp.col_end;
while (s > 0 && continuation(s))
--s;
while (e < len && continuation(e))
++e;
s = std::max(s, prev_end);
if (e <= s)
continue;
out[w++] = HighlightSpan{static_cast<int>(s), static_cast<int>(e), sp.kind};
prev_end = e;
}
out.resize(w);
}
} // namespace kte } // namespace kte
+445 -166
View File
@@ -19,8 +19,6 @@
#include "fonts/Font.h" // embedded default font (DefaultFont) #include "fonts/Font.h" // embedded default font (DefaultFont)
#include "fonts/FontRegistry.h" #include "fonts/FontRegistry.h"
#include "fonts/IosevkaExtended.h" #include "fonts/IosevkaExtended.h"
#include "syntax/HighlighterRegistry.h"
#include "syntax/NullHighlighter.h"
#ifndef KTE_FONT_SIZE #ifndef KTE_FONT_SIZE
@@ -29,21 +27,141 @@
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible) static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static void
apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
{
if (!b)
return;
// Auto-detect edit mode from file extension once per buffer so that
// manual toggles (C-k m / : mode) are not overridden every frame.
if (!b->EditModeDetected() && !b->Filename().empty())
b->SetEditMode(DetectEditMode(b->Filename()));
// If the user explicitly set syntax state via a command (:syntax on/off,
// :set filetype=...), leave it alone - otherwise this runs every frame
// and silently undoes a manual ":syntax off" on the very next frame.
if (b->SyntaxUserOverride())
return;
// Writing mode disables syntax; otherwise follow the global config.
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
b->SetSyntaxEnabled(true);
b->EnsureHighlighter();
if (!b->Highlighter()->HasHighlighter())
b->ApplyDetectedFiletype();
} else {
b->SetSyntaxEnabled(false);
}
}
// Update editor logical rows/cols from current ImGui metrics for a given display size.
static void
update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
{
float row_h = ImGui::GetTextLineHeightWithSpacing();
// Use average character width rather than "M" (the widest character)
// so that column count is reasonable for proportional fonts too.
float ch_w = ImGui::CalcTextSize("abcdefghijklmnopqrstuvwxyz").x / 26.0f;
if (row_h <= 0.0f)
row_h = 16.0f;
if (ch_w <= 0.0f)
ch_w = 8.0f;
const float pad_x = 6.0f;
const float pad_y = 6.0f;
float wanted_bar_h = ImGui::GetFrameHeight();
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
std::size_t rows = content_rows + 1;
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
if (rows != ed.Rows() || cols != ed.Cols()) {
ed.SetDimensions(rows, cols);
}
}
// ---------------------------------------------------------------------------
// SetupImGuiStyle_ — apply theme, fonts, and flags to the current ImGui context
// ---------------------------------------------------------------------------
void
GUIFrontend::SetupImGuiStyle_()
{
ImGuiIO &io = ImGui::GetIO();
// Disable imgui.ini for secondary windows (primary sets its own path in Init)
io.IniFilename = nullptr;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
ImGui::StyleColorsDark();
if (config_.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light);
else
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
kte::ApplyThemeByName(config_.theme);
// Load fonts into this context's font atlas.
// Font registry is global and already populated by Init; just load into this atlas.
if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
LoadGuiFont_(nullptr, (float) config_.font_size);
}
}
// ---------------------------------------------------------------------------
// Destroy a single window's ImGui context + SDL/GL resources
// ---------------------------------------------------------------------------
void
GUIFrontend::DestroyWindowResources_(WindowState &ws)
{
if (ws.imgui_ctx) {
// Must activate this window's GL context before shutting down the
// OpenGL3 backend, otherwise it deletes another context's resources.
if (ws.window && ws.gl_ctx)
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
ImGui::SetCurrentContext(ws.imgui_ctx);
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL2_Shutdown();
ImGui::DestroyContext(ws.imgui_ctx);
ws.imgui_ctx = nullptr;
}
if (ws.gl_ctx) {
SDL_GL_DeleteContext(ws.gl_ctx);
ws.gl_ctx = nullptr;
}
if (ws.window) {
SDL_DestroyWindow(ws.window);
ws.window = nullptr;
}
}
bool bool
GUIFrontend::Init(int &argc, char **argv, Editor &ed) GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc; (void) argc;
(void) argv; (void) argv;
// Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed); // Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
// editor dimensions will be initialized during the first Step() frame config_ = GUIConfig::Load();
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
return false; return false;
} }
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
GUIConfig cfg = GUIConfig::Load();
// GL attributes for core profile // GL attributes for core profile
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
@@ -56,159 +174,114 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
// Compute desired window size from config // Compute desired window size from config
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI; Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
if (cfg.fullscreen) { int init_w = 1280, init_h = 800;
// "Fullscreen": fill the usable bounds of the primary display. if (config_.fullscreen) {
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
SDL_Rect usable{}; SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) { if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
width_ = usable.w; init_w = usable.w;
height_ = usable.h; init_h = usable.h;
} }
#if !defined(__APPLE__) #if !defined(__APPLE__)
// Non-macOS: desktop fullscreen uses the current display resolution.
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP; win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
#endif #endif
} else { } else {
// Windowed: width = columns * font_size, height = (rows * 2) * font_size int w = config_.columns * static_cast<int>(config_.font_size);
int w = cfg.columns * static_cast<int>(cfg.font_size); int h = config_.rows * static_cast<int>(config_.font_size * 1.2);
int h = cfg.rows * static_cast<int>(cfg.font_size * 1.2);
// As a safety, clamp to display usable bounds if retrievable
SDL_Rect usable{}; SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) { if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
w = std::min(w, usable.w); w = std::min(w, usable.w);
h = std::min(h, usable.h); h = std::min(h, usable.h);
} }
width_ = std::max(320, w); init_w = std::max(320, w);
height_ = std::max(200, h); init_h = std::max(200, h);
} }
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1"); SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
window_ = SDL_CreateWindow( SDL_Window *win = SDL_CreateWindow(
"kge - kyle's graphical editor " KTE_VERSION_STR, "kge - kyle's graphical editor " KTE_VERSION_STR,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
width_, height_, init_w, init_h,
win_flags); win_flags);
if (!window_) { if (!win) {
return false; return false;
} }
SDL_EnableScreenSaver(); SDL_EnableScreenSaver();
#if defined(__APPLE__) #if defined(__APPLE__)
// macOS: when "fullscreen" is requested, position the window at the if (config_.fullscreen) {
// top-left of the usable display area to mimic fullscreen while keeping
// the system menu bar visible.
if (cfg.fullscreen) {
SDL_Rect usable{}; SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) { if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
SDL_SetWindowPosition(window_, usable.x, usable.y); SDL_SetWindowPosition(win, usable.x, usable.y);
} }
} }
#endif #endif
gl_ctx_ = SDL_GL_CreateContext(window_); SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
if (!gl_ctx_) if (!gl_ctx) {
SDL_DestroyWindow(win);
return false; return false;
SDL_GL_MakeCurrent(window_, gl_ctx_); }
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1); // vsync SDL_GL_SetSwapInterval(1); // vsync
// Create primary ImGui context
IMGUI_CHECKVERSION(); IMGUI_CHECKVERSION();
ImGui::CreateContext(); ImGuiContext *imgui_ctx = ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO(); ImGuiIO &io = ImGui::GetIO();
// Set custom ini filename path to ~/.config/kte/imgui.ini // Set custom ini filename path to ~/.config/kte/imgui.ini
if (const char *home = std::getenv("HOME")) { if (const char *home = std::getenv("HOME")) {
namespace fs = std::filesystem; namespace fs = std::filesystem;
fs::path config_dir = fs::path(home) / ".config" / "kte"; fs::path config_dir = fs::path(home) / ".config" / "kte";
std::error_code ec; std::error_code ec;
if (!fs::exists(config_dir)) { if (!fs::exists(config_dir)) {
fs::create_directories(config_dir, ec); fs::create_directories(config_dir, ec);
} }
if (fs::exists(config_dir)) { if (fs::exists(config_dir)) {
static std::string ini_path = (config_dir / "imgui.ini").string(); static std::string ini_path = (config_dir / "imgui.ini").string();
io.IniFilename = ini_path.c_str(); io.IniFilename = ini_path.c_str();
} }
} }
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
ImGui::StyleColorsDark(); ImGui::StyleColorsDark();
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands. if (config_.background == "light")
if (cfg.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light); kte::SetBackgroundMode(kte::BackgroundMode::Light);
else else
kte::SetBackgroundMode(kte::BackgroundMode::Dark); kte::SetBackgroundMode(kte::BackgroundMode::Dark);
kte::ApplyThemeByName(cfg.theme); kte::ApplyThemeByName(config_.theme);
// Apply default syntax highlighting preference from GUI config to the current buffer apply_syntax_to_buffer(ed.CurrentBuffer(), config_);
if (Buffer *b = ed.CurrentBuffer()) {
if (cfg.syntax) {
b->SetSyntaxEnabled(true);
// Ensure a highlighter is available if possible
b->EnsureHighlighter();
if (auto *eng = b->Highlighter()) {
if (!eng->HasHighlighter()) {
// Try detect from filename and first line; fall back to cpp or existing filetype
std::string first_line;
const auto &rows = b->Rows();
if (!rows.empty())
first_line = static_cast<std::string>(rows[0]);
std::string ft = kte::HighlighterRegistry::DetectForPath(
b->Filename(), first_line);
if (!ft.empty()) {
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
b->SetFiletype(ft);
eng->InvalidateFrom(0);
} else {
// Unknown/unsupported -> install a null highlighter to keep syntax enabled
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
b->SetFiletype("");
eng->InvalidateFrom(0);
}
}
}
} else {
b->SetSyntaxEnabled(false);
}
}
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_)) if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx))
return false; return false;
if (!ImGui_ImplOpenGL3_Init(kGlslVersion)) if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
return false; return false;
// Cache initial window size; logical rows/cols will be computed in Step() once a valid ImGui frame exists // Cache initial window size
int w, h; int w, h;
SDL_GetWindowSize(window_, &w, &h); SDL_GetWindowSize(win, &w, &h);
width_ = w; init_w = w;
height_ = h; init_h = h;
#if defined(__APPLE__) #if defined(__APPLE__)
// Workaround: On macOS Retina when starting maximized, we sometimes get a
// subtle input vs draw alignment mismatch until the first manual resize.
// Nudge the window size by 1px and back to trigger a proper internal
// recomputation, without visible impact.
if (w > 1 && h > 1) { if (w > 1 && h > 1) {
SDL_SetWindowSize(window_, w - 1, h - 1); SDL_SetWindowSize(win, w - 1, h - 1);
SDL_SetWindowSize(window_, w, h); SDL_SetWindowSize(win, w, h);
// Update cached size in case backend reports immediately SDL_GetWindowSize(win, &w, &h);
SDL_GetWindowSize(window_, &w, &h); init_w = w;
width_ = w; init_h = h;
height_ = h;
} }
#endif #endif
// Install embedded fonts into registry and load configured font // Install embedded fonts
kte::Fonts::InstallDefaultFonts(); kte::Fonts::InstallDefaultFonts();
// Initialize font atlas using configured font name and size; fallback to embedded default helper if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
if (!kte::Fonts::FontRegistry::Instance().LoadFont(cfg.font, (float) cfg.font_size)) { LoadGuiFont_(nullptr, (float) config_.font_size);
LoadGuiFont_(nullptr, (float) cfg.font_size); kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) config_.font_size);
// Record defaults in registry so subsequent size changes have a base
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) cfg.font_size);
std::string n; std::string n;
float s = 0.0f; float s = 0.0f;
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) { if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) {
@@ -216,6 +289,90 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
} }
} }
// Build primary WindowState
auto ws = std::make_unique<WindowState>();
ws->window = win;
ws->gl_ctx = gl_ctx;
ws->imgui_ctx = imgui_ctx;
ws->width = init_w;
ws->height = init_h;
// The primary window's editor IS the editor passed in from main; we don't
// use ws->editor for the primary — instead we keep a pointer to &ed.
// We store a sentinel: window index 0 uses the external editor reference.
// To keep things simple, attach input to the passed-in editor.
ws->input.Attach(&ed);
windows_.push_back(std::move(ws));
return true;
}
bool
GUIFrontend::OpenNewWindow_(Editor &primary)
{
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
int w = windows_[0]->width;
int h = windows_[0]->height;
SDL_Window *win = SDL_CreateWindow(
"kge - kyle's graphical editor " KTE_VERSION_STR,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
w, h,
win_flags);
if (!win)
return false;
SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
if (!gl_ctx) {
SDL_DestroyWindow(win);
return false;
}
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1);
// Each window gets its own ImGui context — ImGui requires exactly one
// NewFrame/Render cycle per context per frame.
ImGuiContext *imgui_ctx = ImGui::CreateContext();
ImGui::SetCurrentContext(imgui_ctx);
SetupImGuiStyle_();
if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx)) {
ImGui::DestroyContext(imgui_ctx);
SDL_GL_DeleteContext(gl_ctx);
SDL_DestroyWindow(win);
return false;
}
if (!ImGui_ImplOpenGL3_Init(kGlslVersion)) {
ImGui_ImplSDL2_Shutdown();
ImGui::DestroyContext(imgui_ctx);
SDL_GL_DeleteContext(gl_ctx);
SDL_DestroyWindow(win);
return false;
}
auto ws = std::make_unique<WindowState>();
ws->window = win;
ws->gl_ctx = gl_ctx;
ws->imgui_ctx = imgui_ctx;
ws->width = w;
ws->height = h;
// Secondary editor shares the primary's buffer list
ws->editor.SetSharedBuffers(&primary.Buffers(), primary.Swap());
ws->editor.SetDimensions(primary.Rows(), primary.Cols());
// Open a new untitled buffer and switch to it in the new window.
ws->editor.AddBuffer(Buffer());
ws->editor.SwitchTo(ws->editor.BufferCount() - 1);
ws->input.Attach(&ws->editor);
windows_.push_back(std::move(ws));
// Restore primary context
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
return true; return true;
} }
@@ -223,137 +380,261 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
void void
GUIFrontend::Step(Editor &ed, bool &running) GUIFrontend::Step(Editor &ed, bool &running)
{ {
// --- Event processing ---
// SDL events carry a window ID. Route each event to the correct window's
// ImGui context (for ImGui_ImplSDL2_ProcessEvent) and input handler.
SDL_Event e; SDL_Event e;
while (SDL_PollEvent(&e)) { while (SDL_PollEvent(&e)) {
ImGui_ImplSDL2_ProcessEvent(&e); // Determine which window this event belongs to
Uint32 event_win_id = 0;
switch (e.type) { switch (e.type) {
case SDL_QUIT:
running = false;
break;
case SDL_WINDOWEVENT: case SDL_WINDOWEVENT:
if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) { event_win_id = e.window.windowID;
width_ = e.window.data1; break;
height_ = e.window.data2; case SDL_KEYDOWN:
} case SDL_KEYUP:
event_win_id = e.key.windowID;
break;
case SDL_TEXTINPUT:
event_win_id = e.text.windowID;
break;
case SDL_MOUSEBUTTONDOWN:
case SDL_MOUSEBUTTONUP:
event_win_id = e.button.windowID;
break;
case SDL_MOUSEWHEEL:
event_win_id = e.wheel.windowID;
break;
case SDL_MOUSEMOTION:
event_win_id = e.motion.windowID;
break; break;
default: default:
break; break;
} }
// Map input to commands
input_.ProcessSDLEvent(e); if (e.type == SDL_QUIT) {
running = false;
break;
} }
// Apply pending font change before starting a new frame // Find the target window and route the event to its ImGui context
WindowState *target = nullptr;
std::size_t target_idx = 0;
if (event_win_id != 0) {
for (std::size_t i = 0; i < windows_.size(); ++i) {
if (SDL_GetWindowID(windows_[i]->window) == event_win_id) {
target = windows_[i].get();
target_idx = i;
break;
}
}
}
if (target && target->imgui_ctx) {
// Set this window's ImGui context so ImGui_ImplSDL2_ProcessEvent
// updates the correct IO state.
ImGui::SetCurrentContext(target->imgui_ctx);
ImGui_ImplSDL2_ProcessEvent(&e);
}
if (e.type == SDL_WINDOWEVENT) {
if (e.window.event == SDL_WINDOWEVENT_CLOSE) {
if (target) {
if (target_idx == 0) {
running = false;
} else {
target->alive = false;
}
}
} else if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
if (target) {
target->width = e.window.data1;
target->height = e.window.data2;
}
}
}
// Route input events to the correct window's input handler
if (target) {
Editor &tgt_ed = (target_idx == 0) ? ed : target->editor;
if (tgt_ed.FilePickerVisible()) {
// Modal: don't let keystrokes fall through as edit commands
// to the buffer underneath. Escape closes the picker;
// everything else (navigation, filtering, double-click) is
// handled by the picker's own ImGui widgets via
// ImGui_ImplSDL2_ProcessEvent above.
if (e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE) {
tgt_ed.SetFilePickerVisible(false);
}
} else {
target->input.ProcessSDLEvent(e);
}
}
}
if (!running)
return;
// --- Apply pending font change (to all contexts) ---
{ {
std::string fname; std::string fname;
float fsize = 0.0f; float fsize = 0.0f;
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) { if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
if (!fname.empty() && fsize > 0.0f) { if (!fname.empty() && fsize > 0.0f) {
for (auto &ws : windows_) {
if (!ws->alive || !ws->imgui_ctx)
continue;
ImGui::SetCurrentContext(ws->imgui_ctx);
SDL_GL_MakeCurrent(ws->window, ws->gl_ctx);
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize); kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
// Recreate backend font texture
ImGui_ImplOpenGL3_DestroyFontsTexture(); ImGui_ImplOpenGL3_DestroyFontsTexture();
ImGui_ImplOpenGL3_CreateFontsTexture(); ImGui_ImplOpenGL3_CreateFontsTexture();
} }
} }
} }
}
// Start a new ImGui frame BEFORE processing commands so dimensions are correct // --- Step each window ---
// We iterate by index because OpenNewWindow_ may append to windows_.
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
WindowState &ws = *windows_[wi];
if (!ws.alive)
continue;
Editor &wed = (wi == 0) ? ed : ws.editor;
// Shared buffer list may have been modified by another window.
wed.ValidateBufferIndex();
// Activate this window's GL and ImGui contexts
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
ImGui::SetCurrentContext(ws.imgui_ctx);
// Start a new ImGui frame
ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL2_NewFrame(window_); ImGui_ImplSDL2_NewFrame(ws.window);
ImGui::NewFrame(); ImGui::NewFrame();
// Update editor logical rows/cols using current ImGui metrics and display size // Update editor dimensions
{ {
ImGuiIO &io = ImGui::GetIO(); ImGuiIO &io = ImGui::GetIO();
float row_h = ImGui::GetTextLineHeightWithSpacing(); float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(ws.width);
float ch_w = ImGui::CalcTextSize("M").x; float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(ws.height);
if (row_h <= 0.0f) update_editor_dimensions(wed, disp_w, disp_h);
row_h = 16.0f;
if (ch_w <= 0.0f)
ch_w = 8.0f;
// Prefer ImGui IO display size; fall back to cached SDL window size
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(width_);
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_);
// Account for the GUI window padding and the status bar height used in ImGuiRenderer.
const float pad_x = 6.0f;
const float pad_y = 6.0f;
// Use the same logic as ImGuiRenderer for available height and status bar reservation.
float wanted_bar_h = ImGui::GetFrameHeight();
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
// Visible content rows inside the scroll child
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
// Editor::Rows includes the status line; add 1 back for it.
std::size_t rows = content_rows + 1;
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
// Only update if changed to avoid churn
if (rows != ed.Rows() || cols != ed.Cols()) {
ed.SetDimensions(rows, cols);
}
} }
// Allow deferred opens (including swap recovery prompts) to run. // Allow deferred opens
ed.ProcessPendingOpens(); wed.ProcessPendingOpens();
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated // Ensure newly opened buffers get syntax + edit mode detection
apply_syntax_to_buffer(wed.CurrentBuffer(), config_);
// Drain input queue
for (;;) { for (;;) {
MappedInput mi; MappedInput mi;
if (!input_.Poll(mi)) if (!ws.input.Poll(mi))
break; break;
if (mi.hasCommand) { if (mi.hasCommand) {
// Track kill ring before and after to sync GUI clipboard when it changes if (mi.id == CommandId::NewWindow) {
const std::string before = ed.KillRingHead(); // Open a new window; handled after this loop
Execute(ed, mi.id, mi.arg, mi.count); wed.SetNewWindowRequested(true);
const std::string after = ed.KillRingHead(); } else if (mi.id == CommandId::FontZoomIn ||
mi.id == CommandId::FontZoomOut ||
mi.id == CommandId::FontZoomReset) {
auto &fr = kte::Fonts::FontRegistry::Instance();
float cur = fr.CurrentFontSize();
if (cur <= 0.0f) cur = config_.font_size;
float next = cur;
if (mi.id == CommandId::FontZoomIn)
next = std::min(cur + 2.0f, 72.0f);
else if (mi.id == CommandId::FontZoomOut)
next = std::max(cur - 2.0f, 8.0f);
else
next = config_.font_size; // reset to config default
if (next != cur)
fr.RequestLoadFont(fr.CurrentFontName(), next);
} else {
const std::string before = wed.KillRingHead();
Execute(wed, mi.id, mi.arg, mi.count);
const std::string after = wed.KillRingHead();
if (after != before && !after.empty()) { if (after != before && !after.empty()) {
// Update the system clipboard to mirror the kill ring head in GUI
SDL_SetClipboardText(after.c_str()); SDL_SetClipboardText(after.c_str());
} }
} }
} }
}
if (ed.QuitRequested()) { if (wi == 0 && wed.QuitRequested()) {
running = false; running = false;
} }
// No runtime font UI; always use embedded font. // Switch font based on current buffer's edit mode (deferred to next frame)
{
Buffer *cur = wed.CurrentBuffer();
if (cur) {
auto &fr = kte::Fonts::FontRegistry::Instance();
const std::string &expected =
(cur->GetEditMode() == EditMode::Writing)
? config_.writing_font
: config_.code_font;
if (fr.CurrentFontName() != expected && fr.HasFont(expected)) {
float sz = fr.CurrentFontSize();
if (sz <= 0.0f) sz = config_.font_size;
fr.RequestLoadFont(expected, sz);
}
}
}
// Draw editor UI // Draw
renderer_.Draw(ed); ws.renderer.Draw(wed);
// Render // Render
ImGui::Render(); ImGui::Render();
int display_w, display_h; int display_w, display_h;
SDL_GL_GetDrawableSize(window_, &display_w, &display_h); SDL_GL_GetDrawableSize(ws.window, &display_w, &display_h);
glViewport(0, 0, display_w, display_h); glViewport(0, 0, display_w, display_h);
glClearColor(0.1f, 0.1f, 0.11f, 1.0f); glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT); glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
SDL_GL_SwapWindow(window_); SDL_GL_SwapWindow(ws.window);
}
// Handle deferred new-window requests (must happen outside the render loop
// to avoid corrupting an in-progress ImGui frame).
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
Editor &wed = (wi == 0) ? ed : windows_[wi]->editor;
if (wed.NewWindowRequested()) {
wed.SetNewWindowRequested(false);
OpenNewWindow_(ed);
}
}
// Remove dead secondary windows
for (auto it = windows_.begin() + 1; it != windows_.end();) {
if (!(*it)->alive) {
DestroyWindowResources_(**it);
it = windows_.erase(it);
} else {
++it;
}
}
// Restore primary context
if (!windows_.empty()) {
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
}
} }
void void
GUIFrontend::Shutdown() GUIFrontend::Shutdown()
{ {
ImGui_ImplOpenGL3_Shutdown(); // Destroy all windows (secondary first, then primary)
ImGui_ImplSDL2_Shutdown(); for (auto it = windows_.rbegin(); it != windows_.rend(); ++it) {
ImGui::DestroyContext(); DestroyWindowResources_(**it);
if (gl_ctx_) {
SDL_GL_DeleteContext(gl_ctx_);
gl_ctx_ = nullptr;
}
if (window_) {
SDL_DestroyWindow(window_);
window_ = nullptr;
} }
windows_.clear();
SDL_Quit(); SDL_Quit();
} }
@@ -367,7 +648,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
ImFontConfig config; ImFontConfig config;
config.MergeMode = false; config.MergeMode = false;
// Load Basic Latin + Latin Supplement
io.Fonts->AddFontFromMemoryCompressedTTF( io.Fonts->AddFontFromMemoryCompressedTTF(
kte::Fonts::DefaultFontData, kte::Fonts::DefaultFontData,
kte::Fonts::DefaultFontSize, kte::Fonts::DefaultFontSize,
@@ -375,7 +655,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
&config, &config,
io.Fonts->GetGlyphRangesDefault()); io.Fonts->GetGlyphRangesDefault());
// Merge Greek and Mathematical symbols from IosevkaExtended
config.MergeMode = true; config.MergeMode = true;
static const ImWchar extended_ranges[] = { static const ImWchar extended_ranges[] = {
0x0370, 0x03FF, // Greek and Coptic 0x0370, 0x03FF, // Greek and Coptic
+29 -6
View File
@@ -2,13 +2,18 @@
* GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle * GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
*/ */
#pragma once #pragma once
#include <memory>
#include <vector>
#include "Frontend.h" #include "Frontend.h"
#include "GUIConfig.h" #include "GUIConfig.h"
#include "ImGuiInputHandler.h" #include "ImGuiInputHandler.h"
#include "ImGuiRenderer.h" #include "ImGuiRenderer.h"
#include "Editor.h"
struct SDL_Window; struct SDL_Window;
struct ImGuiContext;
typedef void *SDL_GLContext; typedef void *SDL_GLContext;
class GUIFrontend final : public Frontend { class GUIFrontend final : public Frontend {
@@ -24,13 +29,31 @@ public:
void Shutdown() override; void Shutdown() override;
private: private:
// Per-window state — each window owns its own ImGui context so that
// NewFrame/Render cycles are fully independent (ImGui requires exactly
// one NewFrame per Render per context).
struct WindowState {
SDL_Window *window = nullptr;
SDL_GLContext gl_ctx = nullptr;
ImGuiContext *imgui_ctx = nullptr;
ImGuiInputHandler input{};
ImGuiRenderer renderer{};
Editor editor{};
int width = 1280;
int height = 800;
bool alive = true;
};
// Open a new secondary window sharing the primary editor's buffer list.
// Returns false if window creation fails.
bool OpenNewWindow_(Editor &primary);
// Initialize fonts and theme for a given ImGui context (must be current).
void SetupImGuiStyle_();
static void DestroyWindowResources_(WindowState &ws);
static bool LoadGuiFont_(const char *path, float size_px); static bool LoadGuiFont_(const char *path, float size_px);
GUIConfig config_{}; GUIConfig config_{};
ImGuiInputHandler input_{}; // Primary window (index 0 in windows_); created during Init.
ImGuiRenderer renderer_{}; std::vector<std::unique_ptr<WindowState> > windows_;
SDL_Window *window_ = nullptr;
SDL_GLContext gl_ctx_ = nullptr;
int width_ = 1280;
int height_ = 800;
}; };
+90 -31
View File
@@ -8,6 +8,20 @@
#include "ImGuiInputHandler.h" #include "ImGuiInputHandler.h"
#include "KKeymap.h" #include "KKeymap.h"
#include "Editor.h" #include "Editor.h"
#include "PasteSplit.h"
// Verbose k-prefix suffix logging for debugging macOS/SDL key translation
// issues. Default to off; enable by defining IMGUI_IH_DEBUG=1 at compile
// time.
#ifndef IMGUI_IH_DEBUG
#define IMGUI_IH_DEBUG 0
#endif
#if IMGUI_IH_DEBUG
#define IH_LOGF(...) do { std::fprintf(stderr, __VA_ARGS__); std::fflush(stderr); } while (0)
#else
#define IH_LOGF(...) ((void) 0)
#endif
static bool static bool
@@ -125,7 +139,11 @@ map_key(const SDL_Keycode key,
case SDLK_KP_ENTER: case SDLK_KP_ENTER:
k_prefix = false; k_prefix = false;
k_ctrl_pending = false; k_ctrl_pending = false;
if (mod & KMOD_SHIFT) {
out = {true, CommandId::SmartNewline, "", 0};
} else {
out = {true, CommandId::Newline, "", 0}; out = {true, CommandId::Newline, "", 0};
}
return true; return true;
case SDLK_ESCAPE: case SDLK_ESCAPE:
k_prefix = false; k_prefix = false;
@@ -139,6 +157,24 @@ map_key(const SDL_Keycode key,
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately. // If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
if (k_prefix) { if (k_prefix) {
// A modifier pressed on its own (Shift for C-k C or C-k ^, Ctrl
// pressed again for C-k C-d) is not the suffix: keep waiting.
switch (key) {
case SDLK_LSHIFT:
case SDLK_RSHIFT:
case SDLK_LCTRL:
case SDLK_RCTRL:
case SDLK_LALT:
case SDLK_RALT:
case SDLK_LGUI:
case SDLK_RGUI:
case SDLK_CAPSLOCK:
case SDLK_MODE:
out.hasCommand = false;
return true;
default:
break;
}
esc_meta = false; esc_meta = false;
// Normalize to ASCII; preserve case for letters using Shift // Normalize to ASCII; preserve case for letters using Shift
int ascii_key = 0; int ascii_key = 0;
@@ -178,18 +214,19 @@ map_key(const SDL_Keycode key,
k_ctrl_pending = false; k_ctrl_pending = false;
CommandId id; CommandId id;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id); bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
#if IMGUI_IH_DEBUG
// Diagnostics for u/U // Diagnostics for u/U
if (lower == 'u') { if (lower == 'u') {
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e) char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
? static_cast<char>(ascii_key) ? static_cast<char>(ascii_key)
: '?'; : '?';
std::fprintf(stderr, IH_LOGF(
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n", "[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp, static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0, ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
mapped ? static_cast<int>(id) : -1); mapped ? static_cast<int>(id) : -1);
std::fflush(stderr);
} }
#endif
if (mapped) { if (mapped) {
out = {true, id, "", 0}; out = {true, id, "", 0};
if (ed) if (ed)
@@ -298,7 +335,7 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
// High-resolution trackpads can deliver fractional wheel deltas. Accumulate // High-resolution trackpads can deliver fractional wheel deltas. Accumulate
// precise values and emit one scroll step per whole unit. // precise values and emit one scroll step per whole unit.
float dy = 0.0f; float dy = 0.0f;
#if SDL_VERSION_ATLEAST(2,0,18) #if SDL_VERSION_ATLEAST(2, 0, 18)
dy = e.wheel.preciseY; dy = e.wheel.preciseY;
#else #else
dy = static_cast<float>(e.wheel.y); dy = static_cast<float>(e.wheel.y);
@@ -327,12 +364,49 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
return false; return false;
} }
case SDL_KEYDOWN: { case SDL_KEYDOWN: {
// A suppression request covers only the text event of the key that
// made it, which arrives before the next key press. Chords such as
// Ctrl+V produce no text event at all, and a leftover request ate
// the next character typed (B8).
suppress_text_input_once_ = false;
// Remember state before mapping; used for TEXTINPUT suppression heuristics // Remember state before mapping; used for TEXTINPUT suppression heuristics
const bool was_k_prefix = k_prefix_; const bool was_k_prefix = k_prefix_;
const bool was_esc_meta = esc_meta_; const bool was_esc_meta = esc_meta_;
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod); SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
const SDL_Keycode key = e.key.keysym.sym; const SDL_Keycode key = e.key.keysym.sym;
// New window: Cmd+N (macOS) or Ctrl+Shift+N (Linux/Windows)
{
const bool gui_n = (mods & KMOD_GUI) && !(mods & KMOD_CTRL) && (key == SDLK_n);
const bool ctrl_sn = (mods & KMOD_CTRL) && (mods & KMOD_SHIFT) && (key == SDLK_n);
if (gui_n || ctrl_sn) {
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::NewWindow, std::string(), 0});
suppress_text_input_once_ = true;
return true;
}
}
// Font zoom: Cmd+=/Cmd+-/Cmd+0 (macOS) or Ctrl+=/Ctrl+-/Ctrl+0
if ((mods & (KMOD_CTRL | KMOD_GUI)) && !(mods & KMOD_SHIFT)) {
bool is_zoom = true;
CommandId zoom_cmd = CommandId::FontZoomIn;
if (key == SDLK_EQUALS || key == SDLK_PLUS)
zoom_cmd = CommandId::FontZoomIn;
else if (key == SDLK_MINUS)
zoom_cmd = CommandId::FontZoomOut;
else if (key == SDLK_0)
zoom_cmd = CommandId::FontZoomReset;
else
is_zoom = false;
if (is_zoom) {
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, zoom_cmd, std::string(), 0});
suppress_text_input_once_ = true;
return true;
}
}
// Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS) // Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS)
// Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode. // Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode.
if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) { if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) {
@@ -340,32 +414,15 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
if (clip) { if (clip) {
std::string text(clip); std::string text(clip);
SDL_free(clip); SDL_free(clip);
// Split on '\n' and enqueue as InsertText/Newline commands // Turn line breaks (\n, \r\n, or bare \r) into Newline
// commands and the rest into InsertText; InsertText itself
// rejects any embedded '\r'/'\n'.
// Into a prompt, line breaks must not become Newline: that
// would accept the prompt and send the rest to the buffer.
const bool to_prompt = ed_ && ed_->PromptActive();
std::lock_guard<std::mutex> lk(mu_); std::lock_guard<std::mutex> lk(mu_);
std::size_t start = 0; for (const auto &cmd: to_prompt ? SplitPasteForPrompt(text) : SplitPasteIntoCommands(text))
while (start <= text.size()) { q_.push(cmd);
std::size_t pos = text.find('\n', start);
std::string_view segment;
bool has_nl = (pos != std::string::npos);
if (has_nl) {
segment = std::string_view(text).substr(start, pos - start);
} else {
segment = std::string_view(text).substr(start);
}
if (!segment.empty()) {
MappedInput ins{
true, CommandId::InsertText, std::string(segment), 0
};
q_.push(ins);
}
if (has_nl) {
MappedInput nl{true, CommandId::Newline, std::string(), 0};
q_.push(nl);
start = pos + 1;
} else {
break;
}
}
// Suppress the corresponding TEXTINPUT that may follow // Suppress the corresponding TEXTINPUT that may follow
suppress_text_input_once_ = true; suppress_text_input_once_ = true;
return true; // consumed return true; // consumed
@@ -488,15 +545,17 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
bool pass_ctrl = k_ctrl_pending_; bool pass_ctrl = k_ctrl_pending_;
k_ctrl_pending_ = false; k_ctrl_pending_ = false;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id); bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
#if IMGUI_IH_DEBUG
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping // Diagnostics: log any k-prefix TEXTINPUT suffix mapping
{
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e) char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
? static_cast<char>(ascii_key) ? static_cast<char>(ascii_key)
: '?'; : '?';
std::fprintf(stderr, IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
"[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
ascii_key, disp, mapped ? 1 : 0, ascii_key, disp, mapped ? 1 : 0,
mapped ? static_cast<int>(id) : -1); mapped ? static_cast<int>(id) : -1);
std::fflush(stderr); }
#endif
if (mapped) { if (mapped) {
mi = {true, id, "", 0}; mi = {true, id, "", 0};
if (ed_) if (ed_)
+254 -228
View File
@@ -7,9 +7,10 @@
#include <string> #include <string>
#include <imgui.h> #include <imgui.h>
#include <regex>
#include "ImGuiRenderer.h" #include "ImGuiRenderer.h"
#include "SearchHighlight.h"
#include "TermWidth.h"
#include "Highlight.h" #include "Highlight.h"
#include "GUITheme.h" #include "GUITheme.h"
#include "Buffer.h" #include "Buffer.h"
@@ -66,7 +67,9 @@ ImGuiRenderer::Draw(Editor &ed)
if (!buf) { if (!buf) {
ImGui::TextUnformatted("[no buffer]"); ImGui::TextUnformatted("[no buffer]");
} else { } else {
const auto &lines = buf->Rows(); // Per-row access only: Rows() rebuilds a string for every line of the
// file after each edit, which made every frame O(file size).
const std::size_t nlines = buf->Nrows();
std::size_t cy = buf->Cury(); std::size_t cy = buf->Cury();
std::size_t cx = buf->Curx(); std::size_t cx = buf->Curx();
const float line_h = ImGui::GetTextLineHeight(); const float line_h = ImGui::GetTextLineHeight();
@@ -76,23 +79,18 @@ ImGuiRenderer::Draw(Editor &ed)
// Two-way sync between Buffer::Rowoffs and ImGui scroll position: // Two-way sync between Buffer::Rowoffs and ImGui scroll position:
// - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it. // - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it.
// - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view. // - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view.
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
const long buf_rowoffs = static_cast<long>(buf->Rowoffs()); const long buf_rowoffs = static_cast<long>(buf->Rowoffs());
const long buf_coloffs = static_cast<long>(buf->Coloffs()); const long buf_coloffs = static_cast<long>(buf->Coloffs());
// Detect programmatic change (e.g., page_down command changed rowoffs) // Detect programmatic change (e.g., page_down command changed rowoffs)
// Use SetNextWindowScroll BEFORE BeginChild to set initial scroll position // Use SetNextWindowScroll BEFORE BeginChild to set initial scroll position
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) { if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
float target_y = static_cast<float>(buf_rowoffs) * row_h; float target_y = static_cast<float>(buf_rowoffs) * row_h;
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y)); ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
} }
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) { // Horizontal scroll is handled purely in pixel space (see
float target_x = static_cast<float>(buf_coloffs) * space_w; // cursor-visibility block after the line loop) so we don't
float target_y = static_cast<float>(buf_rowoffs) * row_h; // convert the character-based coloffs to an ImGui scroll here.
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
}
// Reserve space for status bar at bottom. // Reserve space for status bar at bottom.
// We calculate a height that is an exact multiple of the line height // We calculate a height that is an exact multiple of the line height
@@ -111,44 +109,81 @@ ImGuiRenderer::Draw(Editor &ed)
ImVec2 child_window_pos = ImGui::GetWindowPos(); ImVec2 child_window_pos = ImGui::GetWindowPos();
float scroll_y = ImGui::GetScrollY(); float scroll_y = ImGui::GetScrollY();
float scroll_x = ImGui::GetScrollX(); float scroll_x = ImGui::GetScrollX();
std::size_t rowoffs = 0; // we render from the first line; scrolling is handled by ImGui
// Synchronize buffer offsets from ImGui scroll if user scrolled manually // Synchronize buffer offsets from ImGui scroll if user scrolled manually
bool forced_scroll = false; bool forced_scroll = false;
{ {
static float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels
static float prev_scroll_x = -1.0f; // previous frame's ImGui scroll X in pixels
const long scroll_top = static_cast<long>(scroll_y / row_h); const long scroll_top = static_cast<long>(scroll_y / row_h);
const long scroll_left = static_cast<long>(scroll_x / space_w);
// Check if rowoffs was programmatically changed this frame // Check if rowoffs was programmatically changed this frame
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) { if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
forced_scroll = true; forced_scroll = true;
} }
// If user scrolled (not programmatic), update buffer offsets accordingly // If user scrolled vertically (not programmatic), update buffer row offset
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y && !forced_scroll) { if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)), mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
mbuf->Coloffs()); mbuf->Coloffs());
} }
} }
if (prev_scroll_x >= 0.0f && scroll_x != prev_scroll_x && !forced_scroll) { // Horizontal scroll is pixel-based and managed by the cursor
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { // visibility block below; we don't sync it back to coloffs.
mbuf->SetOffsets(mbuf->Rowoffs(),
static_cast<std::size_t>(std::max(0L, scroll_left)));
}
}
// Update trackers for next frame // Update trackers for next frame
prev_scroll_y = scroll_y; prev_scroll_y_ = scroll_y;
prev_scroll_x = scroll_x; prev_scroll_x_ = scroll_x;
}
prev_buf_rowoffs_ = buf_rowoffs;
prev_buf_coloffs_ = buf_coloffs;
// Max-line-width cache: reset when the buffer or font changes. We only
// measure visible lines per frame and maintain a running max, so the
// scrollbar may be slightly conservative on first view of a file until
// the user scrolls, but idle CPU drops dramatically on large files.
// Edits bump the buffer version but we intentionally do NOT reset the
// max on version changes — keeping a conservative (possibly too-wide)
// scrollbar is preferable to per-keystroke jitter.
{
ImFont *cur_font = ImGui::GetFont();
float cur_fsize = ImGui::GetFontSize();
if (buf != max_width_buf_
|| cur_font != max_width_font_
|| cur_fsize != max_width_font_size_) {
max_width_buf_ = buf;
max_width_font_ = cur_font;
max_width_font_size_ = cur_fsize;
max_width_px_ = 0.0f;
}
max_width_version_ = buf->Version();
}
// Search matches: the pattern is compiled once per frame (compiling it
// per line per frame was a large source of idle CPU on macOS).
const kte::SearchHighlight search_hl(ed);
const bool search_mode = search_hl.Active();
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
// Compute the visible row range and skip rendering work for off-screen
// lines. ImGui clips drawing, but string allocation, tab expansion,
// syntax-highlight lookups, and width measurement are not free.
const std::size_t total_rows = nlines;
std::size_t first_vis = 0;
std::size_t last_vis = total_rows; // exclusive
if (row_h > 0.0f && total_rows > 0) {
const long margin = 4; // render a few extra rows above/below for smoother scrolling
long fv = static_cast<long>(std::floor(scroll_y / row_h)) - margin;
long lv = static_cast<long>(
std::ceil((scroll_y + child_h_plan) / row_h)) + margin;
if (fv < 0) fv = 0;
if (lv < 0) lv = 0;
if (static_cast<std::size_t>(fv) > total_rows)
fv = static_cast<long>(total_rows);
if (static_cast<std::size_t>(lv) > total_rows)
lv = static_cast<long>(total_rows);
first_vis = static_cast<std::size_t>(fv);
last_vis = static_cast<std::size_t>(lv);
} }
prev_buf_rowoffs = buf_rowoffs;
prev_buf_coloffs = buf_coloffs;
// Cache current horizontal offset in rendered columns for click handling
const std::size_t coloffs_now = buf->Coloffs();
// Mark selection state (mark -> cursor), in source coordinates // Mark selection state (mark -> cursor), in source coordinates
bool sel_active = false; bool sel_active = false;
@@ -169,7 +204,7 @@ ImGuiRenderer::Draw(Editor &ed)
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0; const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0; const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
static bool mouse_selecting = false; // (mouse_selecting__ is a member variable)
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> { auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
ImVec2 mp = ImGui::GetIO().MousePos; ImVec2 mp = ImGui::GetIO().MousePos;
// Convert mouse pos to buffer row // Convert mouse pos to buffer row
@@ -178,108 +213,155 @@ ImGuiRenderer::Draw(Editor &ed)
if (by_l < 0) if (by_l < 0)
by_l = 0; by_l = 0;
std::size_t by = static_cast<std::size_t>(by_l); std::size_t by = static_cast<std::size_t>(by_l);
if (by >= lines.size()) if (by >= nlines)
by = lines.empty() ? 0 : (lines.size() - 1); by = (nlines == 0) ? 0 : (nlines - 1);
// Convert mouse pos to rendered x if (nlines == 0)
return {0, 0};
// Expand tabs for the clicked line
std::string line_clicked = buf->GetLineString(by);
const std::size_t tabw = kte::kTabWidth;
std::string click_expanded;
click_expanded.reserve(line_clicked.size() + 16);
std::size_t click_rx = 0;
// Map: source column -> expanded column
std::vector<std::size_t> src_to_exp;
src_to_exp.reserve(line_clicked.size() + 1);
for (std::size_t ci = 0; ci < line_clicked.size(); ++ci) {
src_to_exp.push_back(click_rx);
if (line_clicked[ci] == '\t') {
std::size_t adv = (tabw - (click_rx % tabw));
click_expanded.append(adv, ' ');
click_rx += adv;
} else {
click_expanded.push_back(line_clicked[ci]);
click_rx += 1;
}
}
src_to_exp.push_back(click_rx); // past-end position
// Pixel x relative to the line start (accounting for scroll)
float visual_x = mp.x - child_window_pos.x; float visual_x = mp.x - child_window_pos.x;
if (visual_x < 0.0f) if (visual_x < 0.0f)
visual_x = 0.0f; visual_x = 0.0f;
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now; // Add scroll offset in pixels
visual_x += scroll_x;
// Convert rendered column to source column // Find the source column whose expanded position is closest
if (lines.empty()) // to the click pixel, using actual text measurement.
return {0, 0};
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
std::size_t rx = 0;
std::size_t best_col = 0; std::size_t best_col = 0;
float best_dist = std::numeric_limits<float>::infinity(); float best_dist = std::numeric_limits<float>::infinity();
float clicked_rx_f = static_cast<float>(clicked_rx); for (std::size_t ci = 0; ci <= line_clicked.size(); ++ci) {
for (std::size_t i = 0; i <= line_clicked.size(); ++i) { std::size_t exp_col = src_to_exp[ci];
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx)); float px = 0.0f;
if (exp_col > 0 && !click_expanded.empty()) {
std::size_t end = std::min(click_expanded.size(), exp_col);
px = ImGui::CalcTextSize(click_expanded.c_str(),
click_expanded.c_str() + end).x;
}
float dist = std::fabs(visual_x - px);
if (dist < best_dist) { if (dist < best_dist) {
best_dist = dist; best_dist = dist;
best_col = i; best_col = ci;
}
if (i < line_clicked.size()) {
rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1;
} }
} }
return {by, best_col}; return {by, best_col};
}; };
// Mouse-driven selection: set mark on press, update cursor on drag // Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
mouse_selecting = true; mouse_selecting_ = true;
auto [by, bx] = mouse_pos_to_buf(); auto [by, bx] = mouse_pos_to_buf();
char tmp[64]; char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx); std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
// Only set mark on double click.
// Dragging will also set the mark if not already set (handled below).
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetMark(bx, by); mbuf->SetMark(bx, by);
} }
} }
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { }
if (mouse_selecting_ && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
auto [by, bx] = mouse_pos_to_buf(); auto [by, bx] = mouse_pos_to_buf();
// If we are dragging (mouse moved while down), ensure mark is set to start selection
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
if (!mbuf->MarkSet()) {
// We'd need to convert click_pos to buf coords, but it's complex here.
// Setting it to where the cursor was *before* we started moving it
// in this frame is a good approximation, or just using current.
mbuf->SetMark(mbuf->Curx(), mbuf->Cury());
}
}
}
char tmp[64]; char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx); std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
} }
if (mouse_selecting && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
mouse_selecting = false; mouse_selecting_ = false;
} }
for (std::size_t i = rowoffs; i < lines.size(); ++i) { // Advance the cursor Y so the first visible line draws at its correct
// scroll position. Skipped rows simply leave the layout cursor untouched.
if (first_vis > 0) {
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
static_cast<float>(first_vis) * row_h);
}
for (std::size_t i = first_vis; i < last_vis; ++i) {
// Capture the screen position before drawing the line // Capture the screen position before drawing the line
ImVec2 line_pos = ImGui::GetCursorScreenPos(); ImVec2 line_pos = ImGui::GetCursorScreenPos();
std::string line = static_cast<std::string>(lines[i]); std::string line = buf->GetLineString(i);
// Expand tabs to spaces with width=8 and apply horizontal scroll offset // Expand tabs to spaces with width=8, recording each source
const std::size_t tabw = 8; // byte's rendered column (rx_of[line.size()] is the line's width).
const std::size_t tabw = kte::kTabWidth;
std::string expanded; std::string expanded;
expanded.reserve(line.size() + 16); expanded.reserve(line.size() + 16);
std::size_t rx_abs_draw = 0; // rendered column for drawing std::vector<std::size_t> rx_of(line.size() + 1);
// Compute search highlight ranges for this line in source indices for (std::size_t src = 0; src < line.size(); ++src) {
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty(); rx_of[src] = expanded.size();
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges; if (line[src] == '\t')
if (search_mode) { expanded.append(tabw - (expanded.size() % tabw), ' ');
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
if (ed.PromptActive() && (
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
try {
std::regex rx(ed.SearchQuery());
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) {
const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.position());
std::size_t ex = sx + static_cast<std::size_t>(m.length());
hl_src_ranges.emplace_back(sx, ex);
}
} catch (const std::regex_error &) {
// ignore invalid patterns here; status line already shows the error
}
} else {
const std::string &q = ed.SearchQuery();
std::size_t pos = 0;
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
hl_src_ranges.emplace_back(pos, pos + q.size());
pos += q.size();
}
}
}
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
std::size_t rx = 0;
std::size_t s = 0;
while (s < upto_src_exclusive && s < line.size()) {
if (line[s] == '\t')
rx += (tabw - (rx % tabw));
else else
rx += 1; expanded.push_back(line[src]);
++s;
} }
return rx; rx_of[line.size()] = expanded.size();
auto src_to_rx = [&](std::size_t src) -> std::size_t {
return rx_of[std::min(src, line.size())];
}; };
// Rendered column -> pixel offset from the start of the line.
// Measuring from column 0 for every span was quadratic in line
// length; measure on from the nearest earlier position already
// measured (only character boundaries are kept, so a multi-byte
// character is never measured in pieces).
std::vector<std::pair<std::size_t, float> > px_marks{{0, 0.0f}}; // unrounded
auto rx_to_px = [&](std::size_t rx_col) -> float {
const std::size_t end = std::min(expanded.size(), rx_col);
auto it = std::upper_bound(px_marks.begin(), px_marks.end(), end,
[](std::size_t v, const std::pair<std::size_t, float> &m) {
return v < m.first;
});
--it; // px_marks[0] is column 0
float px = it->second;
if (it->first != end) {
px += ImGui::GetFont()->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f,
expanded.c_str() + it->first,
expanded.c_str() + end).x;
if (end == expanded.size() || (static_cast<unsigned char>(expanded[end]) & 0xC0) != 0x80)
px_marks.insert(it + 1, {end, px});
}
// Rounded up as ImGui::CalcTextSize does (sums of rounded
// pieces would drift).
return end == 0 ? 0.0f : static_cast<float>(static_cast<int>(px + 0.99999f));
};
// Search highlight ranges for this line in source indices
search_hl.Ranges(line, hl_src_ranges);
// Draw background highlights (under text) // Draw background highlights (under text)
if (search_mode && !hl_src_ranges.empty()) { if (search_mode && !hl_src_ranges.empty()) {
// Current match emphasis // Current match emphasis
@@ -290,13 +372,8 @@ ImGuiRenderer::Draw(Editor &ed)
std::size_t sx = rg.first, ex = rg.second; std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_start = src_to_rx(sx); std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex); std::size_t rx_end = src_to_rx(ex);
// Apply horizontal scroll offset ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y);
if (rx_end <= coloffs_now) ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
continue; // fully left of view
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
line_pos.y + line_h); line_pos.y + line_h);
// Choose color: current match stronger // Choose color: current match stronger
bool is_current = has_current && sx == cur_x && ex == cur_end; bool is_current = has_current && sx == cur_x && ex == cur_end;
@@ -333,20 +410,14 @@ ImGuiRenderer::Draw(Editor &ed)
if (line_has) { if (line_has) {
std::size_t rx_start = src_to_rx(sx); std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex); std::size_t rx_end = src_to_rx(ex);
if (rx_end > coloffs_now) { ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
std::size_t vx0 = (rx_start > coloffs_now)
? (rx_start - coloffs_now)
: 0;
std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
line_pos.y); line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w, ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
line_pos.y + line_h); line_pos.y + line_h);
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg); ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
} }
}
if (vsel_active && i >= vsel_sy && i <= vsel_ey) { if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot. // Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
const std::size_t spot_sx = std::min(buf->Curx(), line.size()); const std::size_t spot_sx = std::min(buf->Curx(), line.size());
@@ -358,87 +429,31 @@ ImGuiRenderer::Draw(Editor &ed)
// EOL spot: draw a 1-cell highlight just past the last character. // EOL spot: draw a 1-cell highlight just past the last character.
rx_end = rx_start + 1; rx_end = rx_start + 1;
} }
if (rx_end > coloffs_now) { ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
std::size_t vx0 = (rx_start > coloffs_now)
? (rx_start - coloffs_now)
: 0;
std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
line_pos.y); line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w, ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
line_pos.y + line_h); line_pos.y + line_h);
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg); ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
}
// Emit entire line to an expanded buffer (tabs -> spaces)
for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src];
if (c == '\t') {
std::size_t adv = (tabw - (rx_abs_draw % tabw));
expanded.append(adv, ' ');
rx_abs_draw += adv;
} else {
expanded.push_back(c);
rx_abs_draw += 1;
}
}
// Draw syntax-colored runs (text above background highlights) // Draw syntax-colored runs (text above background highlights)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) { if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
kte::LineHighlight lh = buf->Highlighter()->GetLine( const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version()); *buf, static_cast<int>(i), buf->Version());
// Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties std::vector<kte::HighlightSpan> spans; // clamped, ordered, whole characters
struct SSpan { kte::SanitizeSpans(line, lh.spans, spans);
std::size_t s;
std::size_t e;
kte::TokenKind k;
};
std::vector<SSpan> spans;
spans.reserve(lh.spans.size());
const std::size_t line_len = line.size();
for (const auto &sp: lh.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s), std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
spans.push_back(SSpan{s, e, sp.kind});
}
std::sort(spans.begin(), spans.end(), [](const SSpan &a, const SSpan &b) {
return a.s < b.s;
});
// Helper to convert a src column to expanded rx position
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
std::size_t rx = 0;
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
}
return rx;
};
for (const auto &sp: spans) { for (const auto &sp: spans) {
std::size_t rx_s = src_to_rx_full(sp.s); std::size_t rx_s = src_to_rx(static_cast<std::size_t>(sp.col_start));
std::size_t rx_e = src_to_rx_full(sp.e); std::size_t rx_e = src_to_rx(static_cast<std::size_t>(sp.col_end));
if (rx_e <= coloffs_now) std::size_t draw_start = rx_s;
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;
if (draw_start >= expanded.size()) if (draw_start >= expanded.size())
continue; // fully right of expanded text continue;
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size()); std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
if (draw_end <= draw_start) if (draw_end <= draw_start)
continue; continue;
// Screen position is relative to coloffs_now ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
std::size_t screen_x = draw_start - coloffs_now; ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start),
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(screen_x) * space_w,
line_pos.y); line_pos.y);
ImGui::GetWindowDrawList()->AddText( ImGui::GetWindowDrawList()->AddText(
p, col, expanded.c_str() + draw_start, expanded.c_str() + draw_end); p, col, expanded.c_str() + draw_start, expanded.c_str() + draw_end);
@@ -447,49 +462,49 @@ ImGuiRenderer::Draw(Editor &ed)
// Use row_h (with spacing) to match click calculation and ensure consistent line positions. // Use row_h (with spacing) to match click calculation and ensure consistent line positions.
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h)); ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} else { } else {
// No syntax: draw as one run, accounting for horizontal scroll offset // No syntax: draw the full line; ImGui scroll handles clipping.
if (coloffs_now < expanded.size()) { if (!expanded.empty()) {
ImVec2 p = ImVec2(line_pos.x, line_pos.y); ImVec2 p = ImVec2(line_pos.x, line_pos.y);
ImGui::GetWindowDrawList()->AddText( ImGui::GetWindowDrawList()->AddText(
p, ImGui::GetColorU32(ImGuiCol_Text), p, ImGui::GetColorU32(ImGuiCol_Text),
expanded.c_str() + coloffs_now); expanded.c_str());
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} else {
// Line is fully scrolled out of view horizontally
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} }
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
} }
// Draw a visible cursor indicator on the current line // Draw a visible cursor indicator on the current line
if (i == cy) { if (i == cy) {
// Compute rendered X (rx) from source column with tab expansion std::size_t rx_abs = src_to_rx(cx);
std::size_t rx_abs = 0; float cursor_px = rx_to_px(rx_abs);
for (std::size_t k = 0; k < std::min(cx, line.size()); ++k) {
if (line[k] == '\t')
rx_abs += (tabw - (rx_abs % tabw));
else
rx_abs += 1;
}
// Convert to viewport x by subtracting horizontal col offset
std::size_t rx_viewport = (rx_abs > coloffs_now) ? (rx_abs - coloffs_now) : 0;
// For proportional fonts (Linux GUI), avoid accumulating drift by computing
// the exact pixel width of the expanded substring up to the cursor.
// expanded contains the line with tabs expanded to spaces and is what we draw.
float cursor_px = 0.0f;
if (rx_viewport > 0 && coloffs_now < expanded.size()) {
std::size_t start = coloffs_now;
std::size_t end = std::min(expanded.size(), start + rx_viewport);
// Measure substring width in pixels
ImVec2 sz = ImGui::CalcTextSize(expanded.c_str() + start,
expanded.c_str() + end);
cursor_px = sz.x;
}
ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y); ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y);
ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h); ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h);
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
// Track widest line for content width reporting. We only measure
// visible lines and fold the result into a monotonic max cached on
// the renderer (see max_width_* members). Off-screen lines are not
// measured per frame; if the user scrolls or edits, the cache is
// refreshed accordingly.
if (!expanded.empty()) {
const float line_w = rx_to_px(expanded.size());
if (line_w > max_width_px_)
max_width_px_ = line_w;
} }
}
// After the visible-range loop, advance the layout cursor to the end of
// the (virtual) content so ImGui sees the correct total size. Vertical
// height comes from total_rows; horizontal width comes from the cached
// max line width. A Dummy at the final position records both.
if (total_rows > last_vis) {
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
static_cast<float>(total_rows - last_vis) * row_h);
}
if (max_width_px_ > 0.0f) {
ImGui::SetCursorPosX(max_width_px_);
}
ImGui::Dummy(ImVec2(0, 0));
// Synchronize cursor and scrolling after rendering all lines so content size is known. // Synchronize cursor and scrolling after rendering all lines so content size is known.
{ {
float child_h_actual = ImGui::GetWindowHeight(); float child_h_actual = ImGui::GetWindowHeight();
@@ -529,29 +544,40 @@ ImGuiRenderer::Draw(Editor &ed)
last_row = first_row + vis_rows - 1; last_row = first_row + vis_rows - 1;
} }
// Horizontal scroll: ensure cursor column is visible // Horizontal scroll: ensure cursor is visible (pixel-based for proportional fonts)
long vis_cols = static_cast<long>(std::round(child_w_actual / space_w)); float cursor_px_abs = 0.0f;
if (vis_cols < 1) if (cy < nlines) {
vis_cols = 1; std::string cur_line = buf->GetLineString(cy);
long first_col = static_cast<long>(scroll_x_now / space_w); const std::size_t tabw = kte::kTabWidth;
long last_col = first_col + vis_cols - 1; // Expand tabs for cursor line to measure pixel position
std::string cur_expanded;
std::size_t cursor_rx = 0; cur_expanded.reserve(cur_line.size() + 16);
if (cy < lines.size()) { std::size_t cur_rx = 0;
std::string cur_line = static_cast<std::string>(lines[cy]); for (std::size_t ci = 0; ci < cur_line.size(); ++ci) {
const std::size_t tabw = 8; if (cur_line[ci] == '\t') {
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) { std::size_t adv = tabw - (cur_rx % tabw);
if (cur_line[i] == '\t') { cur_expanded.append(adv, ' ');
cursor_rx += tabw - (cursor_rx % tabw); cur_rx += adv;
} else { } else {
cur_expanded.push_back(cur_line[ci]);
cur_rx += 1;
}
}
// Compute rendered column of cursor
std::size_t cursor_rx = 0;
for (std::size_t ci = 0; ci < cx && ci < cur_line.size(); ++ci) {
if (cur_line[ci] == '\t')
cursor_rx += tabw - (cursor_rx % tabw);
else
cursor_rx += 1; cursor_rx += 1;
} }
std::size_t exp_end = std::min(cur_expanded.size(), cursor_rx);
if (exp_end > 0)
cursor_px_abs = ImGui::CalcTextSize(cur_expanded.c_str(),
cur_expanded.c_str() + exp_end).x;
} }
} if (cursor_px_abs < scroll_x_now || cursor_px_abs > scroll_x_now + child_w_actual) {
long cxr = static_cast<long>(cursor_rx); float target_x = cursor_px_abs - (child_w_actual / 2.0f);
if (cxr < first_col || cxr > last_col) {
float target_x = static_cast<float>(cxr) * space_w;
target_x -= (child_w_actual / 2.0f);
if (target_x < 0.f) if (target_x < 0.f)
target_x = 0.f; target_x = 0.f;
float max_x = ImGui::GetScrollMaxX(); float max_x = ImGui::GetScrollMaxX();
@@ -704,7 +730,7 @@ ImGuiRenderer::Draw(Editor &ed)
left += " *"; left += " *";
// Append total line count as "<n>L" // Append total line count as "<n>L"
{ {
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size()); unsigned long lcount = static_cast<unsigned long>(buf->Nrows());
left += " "; left += " ";
left += std::to_string(lcount); left += std::to_string(lcount);
left += "L"; left += "L";
+23
View File
@@ -2,8 +2,13 @@
* ImGuiRenderer - ImGui-based renderer for GUI mode * ImGuiRenderer - ImGui-based renderer for GUI mode
*/ */
#pragma once #pragma once
#include <cstdint>
#include <string>
#include "Renderer.h" #include "Renderer.h"
struct ImFont;
class Buffer;
class ImGuiRenderer final : public Renderer { class ImGuiRenderer final : public Renderer {
public: public:
ImGuiRenderer() = default; ImGuiRenderer() = default;
@@ -11,4 +16,22 @@ public:
~ImGuiRenderer() override = default; ~ImGuiRenderer() override = default;
void Draw(Editor &ed) override; void Draw(Editor &ed) override;
private:
// Per-window scroll tracking for two-way sync between Buffer offsets and ImGui scroll.
// These must be per-instance (not static) so each window maintains independent state.
long prev_buf_rowoffs_ = -1;
long prev_buf_coloffs_ = -1;
float prev_scroll_y_ = -1.0f;
float prev_scroll_x_ = -1.0f;
bool mouse_selecting_ = false;
// Max-line-width cache for the horizontal scrollbar. Measuring every line
// every frame is prohibitively expensive on large files; we only update the
// running max from visible lines and reset when buffer/version/font changes.
const Buffer *max_width_buf_ = nullptr;
std::uint64_t max_width_version_ = 0;
ImFont *max_width_font_ = nullptr;
float max_width_font_size_ = 0.0f;
float max_width_px_ = 0.0f;
}; };
+16 -5
View File
@@ -1,6 +1,4 @@
#include <iostream>
#include <ncurses.h> #include <ncurses.h>
#include <ostream>
#include "KKeymap.h" #include "KKeymap.h"
@@ -40,6 +38,15 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only) out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
return true; return true;
} }
if (ascii_key == 'E') {
// Explicitly rejected and kept distinct from 'e' (OpenFileStart): the
// switch below operates on the lowercased key, so it can't otherwise
// tell 'E' and 'e' apart. Return false and let the caller show its
// normal "unknown k-command" status-line message - curses already
// owns the terminal here, so writing to stderr would corrupt the
// screen.
return false;
}
switch (k_lower) { switch (k_lower) {
case 'a': case 'a':
@@ -66,9 +73,6 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'e': case 'e':
out = CommandId::OpenFileStart; out = CommandId::OpenFileStart;
return true; return true;
case 'E':
std::cerr << "E is not a valid command" << std::endl;
return false;
case 'f': case 'f':
out = CommandId::FlushKillRing; out = CommandId::FlushKillRing;
return true; return true;
@@ -84,6 +88,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'l': case 'l':
out = CommandId::ReloadBuffer; out = CommandId::ReloadBuffer;
return true; return true;
case 'm':
out = CommandId::ToggleEditMode;
return true;
case 'n': case 'n':
out = CommandId::BufferPrev; out = CommandId::BufferPrev;
return true; return true;
@@ -226,6 +233,10 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
case 'q': case 'q':
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph) out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
return true; return true;
case '\n':
case '\r':
out = CommandId::SmartNewline; // Shift+Enter (some terminals send this as Alt+Enter sequences)
return true;
default: default:
break; break;
} }
+12
View File
@@ -0,0 +1,12 @@
.PHONY: configure build test gate
configure:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON -DBUILD_TESTS=ON
build:
cmake --build build
test:
./build/kte_tests
gate: configure build test
-79
View File
@@ -1,79 +0,0 @@
#include "OptimizedSearch.h"
#include <algorithm>
void
OptimizedSearch::build_bad_char(const std::string &pattern)
{
if (pattern == last_pat_)
return;
last_pat_ = pattern;
std::fill(bad_char_.begin(), bad_char_.end(), -1);
for (std::size_t i = 0; i < pattern.size(); ++i) {
bad_char_[static_cast<unsigned char>(pattern[i])] = static_cast<int>(i);
}
}
std::size_t
OptimizedSearch::find_first(const std::string &text, const std::string &pattern, std::size_t start)
{
const std::size_t n = text.size();
const std::size_t m = pattern.size();
if (m == 0)
return start <= n ? start : std::string::npos;
if (m > n || start >= n)
return std::string::npos;
build_bad_char(pattern);
std::size_t s = start;
while (s <= n - m) {
std::size_t j = m;
while (j > 0 && pattern[j - 1] == text[s + j - 1]) {
--j;
}
if (j == 0) {
return s; // match found
}
unsigned char badc = static_cast<unsigned char>(text[s + j - 1]);
int bcidx = bad_char_[badc];
std::size_t shift = (j - 1 > static_cast<std::size_t>(bcidx))
? (j - 1 - static_cast<std::size_t>(bcidx))
: 1;
s += shift;
}
return std::string::npos;
}
std::vector<std::size_t>
OptimizedSearch::find_all(const std::string &text, const std::string &pattern, std::size_t start)
{
std::vector<std::size_t> res;
const std::size_t n = text.size();
const std::size_t m = pattern.size();
if (m == 0)
return res;
if (m > n || start >= n)
return res;
build_bad_char(pattern);
std::size_t s = start;
while (s <= n - m) {
std::size_t j = m;
while (j > 0 && pattern[j - 1] == text[s + j - 1]) {
--j;
}
if (j == 0) {
res.push_back(s);
s += m; // non-overlapping
continue;
}
unsigned char badc = static_cast<unsigned char>(text[s + j - 1]);
int bcidx = bad_char_[badc];
std::size_t shift = (j - 1 > static_cast<std::size_t>(bcidx))
? (j - 1 - static_cast<std::size_t>(bcidx))
: 1;
s += shift;
}
return res;
}
-23
View File
@@ -1,23 +0,0 @@
// OptimizedSearch.h - Boyer–Moore (bad character) based substring search
#pragma once
#include <array>
#include <cstddef>
#include <string>
#include <vector>
class OptimizedSearch {
public:
OptimizedSearch() = default;
// Find first occurrence at or after start. Returns npos if not found.
std::size_t find_first(const std::string &text, const std::string &pattern, std::size_t start = 0);
// Find all non-overlapping matches at or after start. Returns starting indices.
std::vector<std::size_t> find_all(const std::string &text, const std::string &pattern, std::size_t start = 0);
private:
std::array<int, 256> bad_char_{};
std::string last_pat_;
void build_bad_char(const std::string &pattern);
};
+72
View File
@@ -0,0 +1,72 @@
/*
* PasteSplit.h - split pasted/clipboard text into editor commands
*
* Pure logic (no SDL/GUI dependencies) so it can be unit tested and shared
* across frontends. Line breaks in the pasted text must become Newline
* commands; InsertText itself rejects embedded '\r'/'\n' (see Command.cc).
*/
#pragma once
#include <string>
#include <vector>
#include "InputHandler.h"
// Translate a block of pasted text into a sequence of editor commands.
//
// Any of "\n", "\r\n", or a bare "\r" is treated as a single line break and
// emitted as a Newline command; the text between breaks becomes InsertText
// commands. Empty text segments are skipped, but line breaks are always
// emitted so blank lines round-trip correctly.
inline std::vector<MappedInput> SplitPasteIntoCommands(const std::string &text)
{
std::vector<MappedInput> out;
std::string segment;
auto flush_segment = [&]() {
if (!segment.empty()) {
out.push_back(MappedInput{true, CommandId::InsertText, segment, 0});
segment.clear();
}
};
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
if (c == '\n' || c == '\r') {
flush_segment();
out.push_back(MappedInput{true, CommandId::Newline, std::string(), 0});
// Collapse a "\r\n" pair into one line break.
if (c == '\r' && i + 1 < text.size() && text[i + 1] == '\n')
++i;
} else {
segment.push_back(c);
}
}
flush_segment();
return out;
}
// Pasted text destined for a prompt (search, file name, command). Newline
// would accept the prompt, so each line break (\n, \r\n or \r) becomes a
// single space and the whole paste is one InsertText.
inline std::vector<MappedInput> SplitPasteForPrompt(const std::string &text)
{
std::string flat;
flat.reserve(text.size());
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
if (c == '\n' || c == '\r') {
flat.push_back(' ');
if (c == '\r' && i + 1 < text.size() && text[i + 1] == '\n')
++i;
} else {
flat.push_back(c);
}
}
std::vector<MappedInput> out;
if (!flat.empty())
out.push_back(MappedInput{true, CommandId::InsertText, flat, 0});
return out;
}
+286 -64
View File
@@ -1,4 +1,5 @@
#include <algorithm> #include <algorithm>
#include <cstring>
#include <utility> #include <utility>
#include <limits> #include <limits>
#include <ostream> #include <ostream>
@@ -9,13 +10,6 @@
PieceTable::PieceTable() = default; PieceTable::PieceTable() = default;
PieceTable::PieceTable(const std::size_t initialCapacity)
{
add_.reserve(initialCapacity);
materialized_.reserve(initialCapacity);
}
PieceTable::PieceTable(const std::size_t initialCapacity, PieceTable::PieceTable(const std::size_t initialCapacity,
const std::size_t piece_limit, const std::size_t piece_limit,
const std::size_t small_piece_threshold, const std::size_t small_piece_threshold,
@@ -58,6 +52,9 @@ PieceTable::operator=(const PieceTable &other)
version_ = other.version_; version_ = other.version_;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
last_deleted_ = {};
piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
InvalidateLineIndex();
return *this; return *this;
} }
@@ -72,6 +69,8 @@ PieceTable::PieceTable(PieceTable &&other) noexcept
{ {
other.dirty_ = true; other.dirty_ = true;
other.total_size_ = 0; other.total_size_ = 0;
other.InvalidateLineIndex(); // its index described the moved-away text
other.piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
version_ = other.version_; version_ = other.version_;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
@@ -91,9 +90,15 @@ PieceTable::operator=(PieceTable &&other) noexcept
total_size_ = other.total_size_; total_size_ = other.total_size_;
other.dirty_ = true; other.dirty_ = true;
other.total_size_ = 0; other.total_size_ = 0;
other.InvalidateLineIndex(); // its index described the moved-away text
version_ = other.version_; version_ = other.version_;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
last_deleted_ = {};
other.last_deleted_ = {};
piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
other.piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
InvalidateLineIndex();
return *this; return *this;
} }
@@ -101,14 +106,6 @@ PieceTable::operator=(PieceTable &&other) noexcept
PieceTable::~PieceTable() = default; PieceTable::~PieceTable() = default;
void
PieceTable::Reserve(const std::size_t newCapacity)
{
add_.reserve(newCapacity);
materialized_.reserve(newCapacity);
}
// Setter to allow tuning consolidation heuristics // Setter to allow tuning consolidation heuristics
void void
PieceTable::SetConsolidationParams(const std::size_t piece_limit, PieceTable::SetConsolidationParams(const std::size_t piece_limit,
@@ -192,6 +189,7 @@ void
PieceTable::Clear() PieceTable::Clear()
{ {
pieces_.clear(); pieces_.clear();
std::string().swap(original_);
add_.clear(); add_.clear();
materialized_.clear(); materialized_.clear();
total_size_ = 0; total_size_ = 0;
@@ -201,6 +199,58 @@ PieceTable::Clear()
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
last_deleted_ = {};
}
void
PieceTable::AdoptOriginal(std::string &&bytes)
{
// Allocate before touching the current content: if this throws, the
// table is unchanged (everything below is non-throwing).
std::vector<Piece> pieces;
if (!bytes.empty())
pieces.push_back(Piece{Source::Original, 0, bytes.size()});
Clear();
std::string().swap(add_); // release a previous content's capacity
std::string().swap(materialized_);
original_ = std::move(bytes);
total_size_ = original_.size();
pieces_.swap(pieces);
version_++;
}
std::string_view
PieceTable::ContentView() const
{
if (total_size_ == 0)
return {};
if (pieces_.size() == 1) {
const Piece &p = pieces_.front();
return {(p.src == Source::Original ? original_ : add_).data() + p.start, p.len};
}
materialize();
return {materialized_.data(), materialized_.size()};
}
std::string_view
PieceTable::View(std::size_t byte_offset, std::size_t len) const
{
if (byte_offset >= total_size_ || len == 0)
return {};
len = std::min(len, total_size_ - byte_offset);
if (dirty_) {
// Materialized text is stale: serve the range from its piece when it
// fits in one, rather than copying the whole buffer.
auto [idx, inner] = locate(byte_offset);
if (idx < pieces_.size() && inner + len <= pieces_[idx].len) {
const Piece &p = pieces_[idx];
return {(p.src == Source::Original ? original_ : add_).data() + p.start + inner, len};
}
}
return ContentView().substr(byte_offset, len);
} }
@@ -221,6 +271,7 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
last.len += len; last.len += len;
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex();
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
@@ -254,6 +305,7 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
first.len += len; first.len += len;
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex();
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
@@ -273,7 +325,6 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
void void
PieceTable::materialize() const PieceTable::materialize() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (!dirty_) { if (!dirty_) {
return; return;
} }
@@ -300,16 +351,20 @@ PieceTable::locate(const std::size_t byte_offset) const
if (byte_offset >= total_size_) { if (byte_offset >= total_size_) {
return {pieces_.size(), 0}; return {pieces_.size(), 0};
} }
std::size_t off = byte_offset; // Rebuilt once per modification: an edited buffer's lookups (two per
for (std::size_t i = 0; i < pieces_.size(); ++i) { // rendered line per frame) used to walk the piece list each time.
const auto &p = pieces_[i]; if (piece_ends_version_ != version_ || piece_ends_.size() != pieces_.size()) {
if (off < p.len) { piece_ends_.resize(pieces_.size());
return {i, off}; std::size_t end = 0;
for (std::size_t i = 0; i < pieces_.size(); ++i)
piece_ends_[i] = end += pieces_[i].len;
piece_ends_version_ = version_;
} }
off -= p.len; const auto it = std::upper_bound(piece_ends_.begin(), piece_ends_.end(), byte_offset);
} if (it == piece_ends_.end())
// Should not reach here unless inconsistency; return end return {pieces_.size(), 0}; // inconsistent sizes; treat as the end
return {pieces_.size(), 0}; const std::size_t i = static_cast<std::size_t>(it - piece_ends_.begin());
return {i, byte_offset - (i > 0 ? piece_ends_[i - 1] : 0)};
} }
@@ -349,33 +404,102 @@ PieceTable::coalesceNeighbors(std::size_t index)
void void
PieceTable::InvalidateLineIndex() const PieceTable::InvalidateLineIndex() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
line_index_dirty_ = true; line_index_dirty_ = true;
} }
std::size_t
PieceTable::lineUpperBound(const std::size_t byte_offset) const
{
// Line starts ascend; binary search over them with the shift applied.
std::size_t lo = 0, hi = line_index_.size();
while (lo < hi) {
const std::size_t mid = lo + (hi - lo) / 2;
if (lineStart(mid) <= byte_offset)
lo = mid + 1;
else
hi = mid;
}
return lo;
}
void
PieceTable::applyLineShift() const
{
for (std::size_t i = line_shift_from_; i < line_index_.size(); ++i)
line_index_[i] += line_shift_by_;
line_shift_from_ = std::numeric_limits<std::size_t>::max();
line_shift_by_ = 0;
}
void
PieceTable::lineIndexOnInsert(std::size_t offset, const char *text, std::size_t len) const
{
if (line_index_dirty_)
return;
// Line starts after the insertion point move right; a start exactly at
// the insertion point stays (the text joins that line).
const std::size_t first_after = lineUpperBound(offset);
std::vector<std::size_t> added;
const char *end = text + len;
for (const char *p = text; (p = static_cast<const char *>(std::memchr(p, '\n', end - p))) != nullptr; ++p)
added.push_back(offset + static_cast<std::size_t>(p - text) + 1);
// The pending shift can absorb this one only if it starts at the same
// line (the usual case: typing along one line).
if (line_shift_by_ != 0 && line_shift_from_ != first_after)
applyLineShift();
if (!added.empty())
line_index_.insert(line_index_.begin() + static_cast<std::ptrdiff_t>(first_after), added.begin(),
added.end());
// The new starts are exact; the ones after them move by len.
line_shift_from_ = first_after + added.size();
line_shift_by_ += len;
}
void
PieceTable::lineIndexOnDelete(std::size_t offset, std::size_t len) const
{
if (line_index_dirty_)
return;
// Starts in (offset, offset+len] followed a deleted newline: drop them.
// Later starts move left.
const std::size_t lo = lineUpperBound(offset);
const std::size_t hi = lineUpperBound(offset + len);
// After the erase the starts from hi on are at lo; a pending shift
// beginning anywhere in [lo, hi] covers exactly them.
if (line_shift_by_ != 0 && (line_shift_from_ < lo || line_shift_from_ > hi))
applyLineShift();
line_index_.erase(line_index_.begin() + static_cast<std::ptrdiff_t>(lo),
line_index_.begin() + static_cast<std::ptrdiff_t>(hi));
line_shift_from_ = lo;
line_shift_by_ -= len;
}
void void
PieceTable::RebuildLineIndex() const PieceTable::RebuildLineIndex() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (!line_index_dirty_) { if (!line_index_dirty_) {
return; return;
} }
line_index_.clear(); line_index_.clear();
line_index_.push_back(0); line_index_.push_back(0);
line_shift_from_ = std::numeric_limits<std::size_t>::max();
line_shift_by_ = 0;
std::size_t pos = 0; std::size_t pos = 0;
for (const auto &pc: pieces_) { for (const auto &pc: pieces_) {
const std::string &src = pc.src == Source::Original ? original_ : add_; const std::string &src = pc.src == Source::Original ? original_ : add_;
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start); const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
for (std::size_t j = 0; j < pc.len; ++j) { const char *end = base + pc.len;
if (base[j] == '\n') { // The next line starts after each newline.
// next line starts after the newline for (const char *p = base; (p = static_cast<const char *>(std::memchr(p, '\n', end - p))) != nullptr; ++p)
line_index_.push_back(pos + j + 1); line_index_.push_back(pos + static_cast<std::size_t>(p - base) + 1);
}
}
pos += pc.len; pos += pc.len;
} }
@@ -394,6 +518,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
byte_offset = total_size_; byte_offset = total_size_;
} }
last_deleted_ = {};
const std::size_t add_start = add_.size(); const std::size_t add_start = add_.size();
add_.append(text, len); add_.append(text, len);
@@ -401,7 +526,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
pieces_.push_back(Piece{Source::Add, add_start, len}); pieces_.push_back(Piece{Source::Add, add_start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); lineIndexOnInsert(byte_offset, add_.data() + add_start, len);
maybeConsolidate(); maybeConsolidate();
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
@@ -415,7 +540,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
pieces_.push_back(Piece{Source::Add, add_start, len}); pieces_.push_back(Piece{Source::Add, add_start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); lineIndexOnInsert(byte_offset, add_.data() + add_start, len);
coalesceNeighbors(pieces_.size() - 1); coalesceNeighbors(pieces_.size() - 1);
maybeConsolidate(); maybeConsolidate();
version_++; version_++;
@@ -443,7 +568,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); lineIndexOnInsert(byte_offset, add_.data() + add_start, len);
// Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0)) // Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0))
std::size_t ins_index = idx + (inner > 0 ? 1 : 0); std::size_t ins_index = idx + (inner > 0 ? 1 : 0);
coalesceNeighbors(ins_index); coalesceNeighbors(ins_index);
@@ -467,6 +592,16 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
len = total_size_ - byte_offset; len = total_size_ - byte_offset;
} }
// Remember what a large deletion removed, so undo can refer to the
// stored bytes instead of keeping its own copy (see TakeDeletedSpans).
last_deleted_ = {};
if (len >= kCaptureDeletedMin) {
last_deleted_.spans = SpansInRange(byte_offset, len);
last_deleted_.offset = byte_offset;
last_deleted_.len = len;
last_deleted_.valid = true;
}
auto [idx, inner] = locate(byte_offset); auto [idx, inner] = locate(byte_offset);
std::size_t remaining = len; std::size_t remaining = len;
@@ -513,7 +648,7 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
total_size_ -= len; total_size_ -= len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); lineIndexOnDelete(byte_offset, len);
if (idx < pieces_.size()) if (idx < pieces_.size())
coalesceNeighbors(idx); coalesceNeighbors(idx);
if (idx > 0) if (idx > 0)
@@ -525,6 +660,104 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
} }
// ===== Span access =====
std::vector<TextSpan>
PieceTable::SpansInRange(std::size_t byte_offset, std::size_t len) const
{
std::vector<TextSpan> out;
if (byte_offset >= total_size_ || len == 0)
return out;
len = std::min(len, total_size_ - byte_offset);
auto [idx, inner] = locate(byte_offset);
while (len > 0 && idx < pieces_.size()) {
const Piece &p = pieces_[idx];
const std::size_t take = std::min(p.len - inner, len);
out.push_back(TextSpan{p.src == Source::Add, p.start + inner, take});
len -= take;
inner = 0;
++idx;
}
return out;
}
bool
PieceTable::SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const
{
std::size_t pos = 0;
bool equal = true;
VisitSpans(spans, [&](const char *data, std::size_t n) {
if (!equal || pos + n > text.size() || std::memcmp(data, text.data() + pos, n) != 0)
equal = false;
pos += n;
});
return equal && pos == text.size();
}
void
PieceTable::InsertSpans(std::size_t byte_offset, const std::vector<TextSpan> &spans)
{
if (byte_offset > total_size_)
byte_offset = total_size_;
std::vector<Piece> ins;
std::size_t len = 0;
for (const TextSpan &sp: spans) {
const std::string &src = sp.add ? add_ : original_;
if (sp.len == 0 || sp.start + sp.len > src.size())
continue;
ins.push_back(Piece{sp.add ? Source::Add : Source::Original, sp.start, sp.len});
len += sp.len;
}
if (len == 0)
return;
std::size_t at = pieces_.size();
if (byte_offset < total_size_) {
auto [idx, inner] = locate(byte_offset);
if (inner > 0) {
// Split the piece containing the insertion point.
const Piece target = pieces_[idx];
pieces_[idx].len = inner;
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx + 1),
Piece{target.src, target.start + inner, target.len - inner});
at = idx + 1;
} else {
at = idx;
}
}
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(at), ins.begin(), ins.end());
total_size_ += len;
dirty_ = true;
std::size_t off = byte_offset;
for (const Piece &p: ins) {
const std::string &src = p.src == Source::Add ? add_ : original_;
lineIndexOnInsert(off, src.data() + p.start, p.len);
off += p.len;
}
coalesceNeighbors(std::min(at + ins.size(), pieces_.size() - 1));
if (at > 0)
coalesceNeighbors(at - 1);
maybeConsolidate();
version_++;
range_cache_ = {};
find_cache_ = {};
last_deleted_ = {};
}
std::vector<TextSpan>
PieceTable::TakeDeletedSpans(std::size_t byte_offset, std::size_t len)
{
std::vector<TextSpan> out;
if (last_deleted_.valid && last_deleted_.offset == byte_offset && last_deleted_.len == len)
out = std::move(last_deleted_.spans);
last_deleted_ = {};
return out;
}
// ===== Consolidation implementation ===== // ===== Consolidation implementation =====
void void
@@ -562,9 +795,10 @@ PieceTable::consolidateRange(std::size_t start_idx, std::size_t end_idx)
pieces_.begin() + static_cast<std::ptrdiff_t>(end_idx)); pieces_.begin() + static_cast<std::ptrdiff_t>(end_idx));
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx), consolidated); pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx), consolidated);
// total_size_ unchanged // total_size_ unchanged. Content and byte offsets are unchanged too, so
// the line index stays valid (invalidating it here forced a full rescan
// on every edit once a buffer had many pieces).
dirty_ = true; dirty_ = true;
InvalidateLineIndex();
coalesceNeighbors(start_idx); coalesceNeighbors(start_idx);
// Layout changed; invalidate caches/version // Layout changed; invalidate caches/version
version_++; version_++;
@@ -631,8 +865,8 @@ PieceTable::GetLineRange(std::size_t line_num) const
return {0, 0}; return {0, 0};
if (line_num >= line_index_.size()) if (line_num >= line_index_.size())
return {0, 0}; return {0, 0};
std::size_t start = line_index_[line_num]; std::size_t start = lineStart(line_num);
std::size_t end = (line_num + 1 < line_index_.size()) ? line_index_[line_num + 1] : total_size_; std::size_t end = (line_num + 1 < line_index_.size()) ? lineStart(line_num + 1) : total_size_;
return {start, end}; return {start, end};
} }
@@ -643,14 +877,10 @@ PieceTable::GetLine(std::size_t line_num) const
auto [start, end] = GetLineRange(line_num); auto [start, end] = GetLineRange(line_num);
if (end < start) if (end < start)
return std::string(); return std::string();
// Trim trailing '\n' // Every line but the last ends at a newline (each newline starts an
if (end > start) { // index entry); leave it out.
// To check last char, we can get it via GetRange of len 1 at end-1 without materializing whole if (end > start && line_num + 1 < line_index_.size())
std::string last = GetRange(end - 1, 1);
if (!last.empty() && last[0] == '\n') {
end -= 1; end -= 1;
}
}
return GetRange(start, end - start); return GetRange(start, end - start);
} }
@@ -663,9 +893,9 @@ PieceTable::ByteOffsetToLineCol(std::size_t byte_offset) const
RebuildLineIndex(); RebuildLineIndex();
if (line_index_.empty()) if (line_index_.empty())
return {0, 0}; return {0, 0};
auto it = std::upper_bound(line_index_.begin(), line_index_.end(), byte_offset); const std::size_t ub = lineUpperBound(byte_offset);
std::size_t row = (it == line_index_.begin()) ? 0 : static_cast<std::size_t>((it - line_index_.begin()) - 1); std::size_t row = ub == 0 ? 0 : ub - 1;
std::size_t col = byte_offset - line_index_[row]; std::size_t col = byte_offset - lineStart(row);
return {row, col}; return {row, col};
} }
@@ -678,15 +908,12 @@ PieceTable::LineColToByteOffset(std::size_t row, std::size_t col) const
return 0; return 0;
if (row >= line_index_.size()) if (row >= line_index_.size())
return total_size_; return total_size_;
std::size_t start = line_index_[row]; std::size_t start = lineStart(row);
std::size_t end = (row + 1 < line_index_.size()) ? line_index_[row + 1] : total_size_; std::size_t end = (row + 1 < line_index_.size()) ? lineStart(row + 1) : total_size_;
// Clamp col to line length excluding trailing newline // Clamp col to the line's length without its newline (every line but
if (end > start) { // the last has one).
std::string last = GetRange(end - 1, 1); if (end > start && row + 1 < line_index_.size())
if (!last.empty() && last[0] == '\n') {
end -= 1; end -= 1;
}
}
std::size_t target = start + std::min(col, end - start); std::size_t target = start + std::min(col, end - start);
return target; return target;
} }
@@ -702,7 +929,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
// Fast path: return cached value if version/offset/len match // Fast path: return cached value if version/offset/len match
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (range_cache_.valid && range_cache_.version == version_ && if (range_cache_.valid && range_cache_.version == version_ &&
range_cache_.off == byte_offset && range_cache_.len == len) { range_cache_.off == byte_offset && range_cache_.len == len) {
return range_cache_.data; return range_cache_.data;
@@ -712,7 +938,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
std::string out; std::string out;
out.reserve(len); out.reserve(len);
if (!dirty_) { if (!dirty_) {
std::lock_guard<std::mutex> lock(mutex_);
// Already materialized; slice directly // Already materialized; slice directly
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len); out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
} else { } else {
@@ -737,7 +962,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
// Update cache // Update cache
{ {
std::lock_guard<std::mutex> lock(mutex_);
range_cache_.valid = true; range_cache_.valid = true;
range_cache_.version = version_; range_cache_.version = version_;
range_cache_.off = byte_offset; range_cache_.off = byte_offset;
@@ -756,7 +980,6 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
if (start > total_size_) if (start > total_size_)
return std::numeric_limits<std::size_t>::max(); return std::numeric_limits<std::size_t>::max();
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (find_cache_.valid && if (find_cache_.valid &&
find_cache_.version == version_ && find_cache_.version == version_ &&
find_cache_.needle == needle && find_cache_.needle == needle &&
@@ -768,7 +991,6 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
materialize(); materialize();
std::size_t pos; std::size_t pos;
{ {
std::lock_guard<std::mutex> lock(mutex_);
pos = materialized_.find(needle, start); pos = materialized_.find(needle, start);
if (pos == std::string::npos) if (pos == std::string::npos)
pos = std::numeric_limits<std::size_t>::max(); pos = std::numeric_limits<std::size_t>::max();
+136 -22
View File
@@ -1,5 +1,41 @@
/* /*
* PieceTable.h - Alternative to GapBuffer using a piece table representation * PieceTable.h - Alternative to GapBuffer using a piece table representation
*
* PieceTable is kte's core text storage data structure. It provides efficient
* insert/delete operations without copying the entire buffer by maintaining a
* sequence of "pieces" that reference ranges in two underlying buffers:
* - original_: A loaded file's content (AdoptOriginal), never modified
* - add_: All text added during editing
*
* Key advantages:
* - O(1) append/prepend operations (common case)
* - O(n) insert/delete at arbitrary positions (n = number of pieces, not bytes)
* - Efficient undo: just restore the piece list
* - Memory efficient: no gap buffer waste
*
* Performance characteristics:
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
* - Materialization (Data() call) is O(total_size) but cached until next edit;
* a single-piece table (an unedited file) is viewed in place, not copied
* - Line index is lazily rebuilt on first line-based query after edits
* - Range and Find operations use lightweight caches for repeated queries
*
* API evolution:
* 1. Legacy API (GapBuffer compatibility):
* - Append/Prepend: Build content sequentially
* - Data(): Materialize entire buffer
*
* 2. New buffer-wide API (Phase 1):
* - Insert/Delete: Edit at arbitrary byte offsets
* - Line-based queries: LineCount, GetLine, GetLineRange
* - Position conversion: ByteOffsetToLineCol, LineColToByteOffset
* - Efficient extraction: GetRange, Find, WriteToStream
*
* Implementation notes:
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
* - Not thread-safe: even reads update caches. Buffers are used from the
* main thread only (the swap writer thread gets copies of the bytes).
* - Version tracking invalidates caches on mutations
*/ */
#pragma once #pragma once
#include <cstddef> #include <cstddef>
@@ -8,15 +44,15 @@
#include <ostream> #include <ostream>
#include <vector> #include <vector>
#include <limits> #include <limits>
#include <mutex> #include <string_view>
#include "TextSpan.h"
class PieceTable { class PieceTable {
public: public:
PieceTable(); PieceTable();
explicit PieceTable(std::size_t initialCapacity);
// Advanced constructor allowing configuration of consolidation heuristics // Advanced constructor allowing configuration of consolidation heuristics
PieceTable(std::size_t initialCapacity, PieceTable(std::size_t initialCapacity,
std::size_t piece_limit, std::size_t piece_limit,
@@ -33,9 +69,6 @@ public:
~PieceTable(); ~PieceTable();
// Public API mirrors GapBuffer
void Reserve(std::size_t newCapacity);
void AppendChar(char c); void AppendChar(char c);
void Append(const char *s, std::size_t len); void Append(const char *s, std::size_t len);
@@ -51,18 +84,40 @@ public:
// Content management // Content management
void Clear(); void Clear();
// Replace the content with `bytes`, taken over without copying (a loaded
// file). Undo spans into the old storage are invalidated, as by Clear().
// If it throws (std::bad_alloc), the content is unchanged.
void AdoptOriginal(std::string &&bytes);
// Accessors // Accessors
char *Data() // The whole text, contiguous; nullptr when empty. Valid until the next
// modification.
[[nodiscard]] const char *Data() const
{ {
materialize(); const std::string_view v = ContentView();
return materialized_.empty() ? nullptr : materialized_.data(); return v.empty() ? nullptr : v.data();
} }
[[nodiscard]] const char *Data() const // The whole text as one view: in place for a single piece, else the
// materialized copy (built once per modification). Valid until the next
// modification.
[[nodiscard]] std::string_view ContentView() const;
// A view of [byte_offset, byte_offset + len), clamped to the content: in
// place when the range lies within one piece (no copy), else into the
// materialized text. Valid until the next modification.
[[nodiscard]] std::string_view View(std::size_t byte_offset, std::size_t len) const;
// Call fn(const char *data, std::size_t len) for each piece, in order:
// the content without materializing it.
template<typename Fn>
void ForEachChunk(Fn &&fn) const
{ {
const_cast<PieceTable *>(this)->materialize(); for (const Piece &p: pieces_) {
return materialized_.empty() ? nullptr : materialized_.data(); if (p.len > 0)
fn((p.src == Source::Original ? original_ : add_).data() + p.start, p.len);
}
} }
@@ -72,13 +127,6 @@ public:
} }
[[nodiscard]] std::size_t Capacity() const
{
// Capacity for piece table isn't directly meaningful; report materialized capacity
return materialized_.capacity();
}
// ===== New buffer-wide API (Phase 1) ===== // ===== New buffer-wide API (Phase 1) =====
// Byte-based editing operations // Byte-based editing operations
void Insert(std::size_t byte_offset, const char *text, std::size_t len); void Insert(std::size_t byte_offset, const char *text, std::size_t len);
@@ -105,6 +153,36 @@ public:
// Stream out content without materializing the entire buffer // Stream out content without materializing the entire buffer
void WriteToStream(std::ostream &out) const; void WriteToStream(std::ostream &out) const;
// ===== Span access (see TextSpan.h) =====
// The storage spans backing [byte_offset, byte_offset + len).
[[nodiscard]] std::vector<TextSpan> SpansInRange(std::size_t byte_offset, std::size_t len) const;
// Call fn(const char *data, std::size_t len) for each span's bytes, in order.
template<typename Fn>
void VisitSpans(const std::vector<TextSpan> &spans, Fn &&fn) const
{
for (const TextSpan &sp: spans) {
const std::string &src = sp.add ? add_ : original_;
if (sp.len > 0 && sp.start + sp.len <= src.size())
fn(src.data() + sp.start, sp.len);
}
}
// Whether the spans hold exactly `text`.
[[nodiscard]] bool SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const;
// Insert the spans' bytes at byte_offset without copying them into
// storage again (the new pieces reference the existing bytes).
void InsertSpans(std::size_t byte_offset, const std::vector<TextSpan> &spans);
// Deletions of at least kCaptureDeletedMin bytes remember the spans they
// removed; this returns them if the most recent deletion was exactly
// [byte_offset, byte_offset + len), else nothing. Consumes the capture.
static constexpr std::size_t kCaptureDeletedMin = 4096;
[[nodiscard]] std::vector<TextSpan> TakeDeletedSpans(std::size_t byte_offset, std::size_t len);
// Heuristic configuration // Heuristic configuration
void SetConsolidationParams(std::size_t piece_limit, void SetConsolidationParams(std::size_t piece_limit,
std::size_t small_piece_threshold, std::size_t small_piece_threshold,
@@ -141,10 +219,16 @@ private:
// Line index support (rebuilt lazily on demand) // Line index support (rebuilt lazily on demand)
void InvalidateLineIndex() const; void InvalidateLineIndex() const;
// Keep a valid line index in step with an edit instead of rescanning the
// whole buffer on the next query. No-ops if the index is already dirty.
void lineIndexOnInsert(std::size_t offset, const char *text, std::size_t len) const;
void lineIndexOnDelete(std::size_t offset, std::size_t len) const;
void RebuildLineIndex() const; void RebuildLineIndex() const;
// Underlying storages // Underlying storages
std::string original_; // unused for builder use-case, but kept for API symmetry std::string original_; // a loaded file's bytes (AdoptOriginal)
std::string add_; std::string add_;
std::vector<Piece> pieces_; std::vector<Piece> pieces_;
@@ -154,9 +238,34 @@ private:
mutable std::uint64_t version_ = 0; mutable std::uint64_t version_ = 0;
std::size_t total_size_ = 0; std::size_t total_size_ = 0;
// End offset of each piece (prefix sums) for binary-search lookup,
// valid while piece_ends_version_ == version_ (every mutation bumps it).
mutable std::vector<std::size_t> piece_ends_;
mutable std::uint64_t piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
// Cached line index: starting byte offset of each line (always contains at least 1 entry: 0) // Cached line index: starting byte offset of each line (always contains at least 1 entry: 0)
mutable std::vector<std::size_t> line_index_; mutable std::vector<std::size_t> line_index_;
mutable bool line_index_dirty_ = true; mutable bool line_index_dirty_ = true;
// A shift not yet applied to line_index_: entries from index
// line_shift_from_ on are really line_index_[i] + line_shift_by_ (mod
// 2^64, so it may be negative). An edit moves every later line start;
// applying that at once cost O(lines) per keystroke, so consecutive edits
// on one line only adjust the pending shift.
mutable std::size_t line_shift_from_ = std::numeric_limits<std::size_t>::max();
mutable std::size_t line_shift_by_ = 0;
// Start of line i (i < line_index_.size()), with the pending shift.
[[nodiscard]] std::size_t lineStart(std::size_t i) const
{
return i >= line_shift_from_ ? line_index_[i] + line_shift_by_ : line_index_[i];
}
// Index of the first line starting after byte_offset.
[[nodiscard]] std::size_t lineUpperBound(std::size_t byte_offset) const;
// Fold the pending shift into line_index_.
void applyLineShift() const;
// Heuristic knobs // Heuristic knobs
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
@@ -180,8 +289,13 @@ private:
std::size_t result = std::numeric_limits<std::size_t>::max(); std::size_t result = std::numeric_limits<std::size_t>::max();
}; };
struct DeletedCapture {
bool valid = false;
std::size_t offset = 0;
std::size_t len = 0;
std::vector<TextSpan> spans;
} last_deleted_;
mutable RangeCache range_cache_; mutable RangeCache range_cache_;
mutable FindCache find_cache_; mutable FindCache find_cache_;
mutable std::mutex mutex_;
}; };
-985
View File
@@ -1,985 +0,0 @@
#include "QtFrontend.h"
#include <QApplication>
#include <QWidget>
#include <QKeyEvent>
#include <QTimer>
#include <QScreen>
#include <QFont>
#include <QFontMetrics>
#include <QFontDatabase>
#include <QFileDialog>
#include <QFontDialog>
#include <QPainter>
#include <QPaintEvent>
#include <QWheelEvent>
#include <regex>
#include "Editor.h"
#include "Command.h"
#include "Buffer.h"
#include "GUITheme.h"
#include "Highlight.h"
namespace {
class MainWindow : public QWidget {
public:
explicit MainWindow(class QtInputHandler &ih, QWidget *parent = nullptr)
: QWidget(parent), input_(ih)
{
// Match ImGui window title format
setWindowTitle(QStringLiteral("kge - kyle's graphical editor ")
+ QStringLiteral(KTE_VERSION_STR));
resize(1280, 800);
setFocusPolicy(Qt::StrongFocus);
}
bool WasClosed() const
{
return closed_;
}
void SetEditor(Editor *ed)
{
ed_ = ed;
}
void SetFontFamilyAndSize(QString family, int px)
{
if (family.isEmpty())
family = QStringLiteral("Brass Mono");
if (px <= 0)
px = 18;
font_family_ = std::move(family);
font_px_ = px;
update();
}
protected:
void keyPressEvent(QKeyEvent *event) override
{
// Route to editor keymap; if handled, accept and stop propagation so
// Qt doesn't trigger any default widget shortcuts.
if (input_.ProcessKeyEvent(*event)) {
event->accept();
return;
}
QWidget::keyPressEvent(event);
}
void paintEvent(QPaintEvent *event) override
{
Q_UNUSED(event);
QPainter p(this);
p.setRenderHint(QPainter::TextAntialiasing, true);
// Colors from GUITheme palette (Qt branch)
auto to_qcolor = [](const KteColor &c) -> QColor {
int r = int(std::round(c.x * 255.0f));
int g = int(std::round(c.y * 255.0f));
int b = int(std::round(c.z * 255.0f));
int a = int(std::round(c.w * 255.0f));
return QColor(r, g, b, a);
};
const auto pal = kte::GetPalette();
const QColor bg = to_qcolor(pal.bg);
const QColor fg = to_qcolor(pal.fg);
const QColor sel_bg = to_qcolor(pal.sel_bg);
const QColor cur_bg = to_qcolor(pal.cur_bg);
const QColor status_bg = to_qcolor(pal.status_bg);
const QColor status_fg = to_qcolor(pal.status_fg);
// Background
p.fillRect(rect(), bg);
// Font/metrics (configured or defaults)
QFont f(font_family_, font_px_);
p.setFont(f);
QFontMetrics fm(f);
const int line_h = fm.height();
const int ch_w = std::max(1, fm.horizontalAdvance(QStringLiteral(" ")));
// Layout metrics
const int pad_l = 8;
const int pad_t = 6;
const int pad_r = 8;
const int pad_b = 6;
const int status_h = line_h + 6; // status bar height
// Content area (text viewport)
const QRect content_rect(pad_l,
pad_t,
width() - pad_l - pad_r,
height() - pad_t - pad_b - status_h);
// Text viewport occupies all content area (no extra title row)
QRect viewport(content_rect.x(), content_rect.y(), content_rect.width(), content_rect.height());
// Draw buffer contents
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
const Buffer *buf = ed_->CurrentBuffer();
if (buf) {
const auto &lines = buf->Rows();
const std::size_t nrows = lines.size();
const std::size_t rowoffs = buf->Rowoffs();
const std::size_t coloffs = buf->Coloffs();
const std::size_t cy = buf->Cury();
const std::size_t cx = buf->Curx();
// Visible line count
const int max_lines = (line_h > 0) ? (viewport.height() / line_h) : 0;
const std::size_t last_row = std::min<std::size_t>(
nrows, rowoffs + std::max(0, max_lines));
// Tab width: follow ImGuiRenderer default of 4
const std::size_t tabw = 4;
// Prepare painter clip to viewport
p.save();
p.setClipRect(viewport);
// Iterate visible lines
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
// Materialize the Buffer::Line into a std::string for
// regex/iterator usage and general string ops.
const std::string line = static_cast<std::string>(lines[i]);
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
const int baseline = y + fm.ascent();
// Helper: convert src col -> rx with tab expansion
auto src_to_rx_line = [&](std::size_t src_col) -> std::size_t {
std::size_t rx = 0;
for (std::size_t k = 0; k < src_col && k < line.size(); ++k) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
}
return rx;
};
// Search-match background highlights first (under text)
if (ed_->SearchActive() && !ed_->SearchQuery().empty()) {
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
// Compute ranges per line (source indices)
if (ed_->PromptActive() &&
(ed_->CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ed_->CurrentPromptKind() ==
Editor::PromptKind::RegexReplaceFind)) {
try {
std::regex rx(ed_->SearchQuery());
for (auto it = std::sregex_iterator(
line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) {
const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.
position());
std::size_t ex =
sx + static_cast<std::size_t>(m.
length());
hl_src_ranges.emplace_back(sx, ex);
}
} catch (const std::regex_error &) {
// Invalid regex: ignore, status line already shows errors
}
} else {
const std::string &q = ed_->SearchQuery();
if (!q.empty()) {
std::size_t pos = 0;
while ((pos = line.find(q, pos)) != std::string::npos) {
hl_src_ranges.emplace_back(pos, pos + q.size());
pos += q.size();
}
}
}
if (!hl_src_ranges.empty()) {
const bool has_current =
ed_->SearchMatchLen() > 0 && ed_->SearchMatchY() == i;
const std::size_t cur_x = has_current ? ed_->SearchMatchX() : 0;
const std::size_t cur_end = has_current
? (ed_->SearchMatchX() + ed_->SearchMatchLen())
: 0;
for (const auto &rg: hl_src_ranges) {
std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_s = src_to_rx_line(sx);
std::size_t rx_e = src_to_rx_line(ex);
if (rx_e <= coloffs)
continue; // fully left of view
int vx0 = viewport.x() + static_cast<int>((
(rx_s > coloffs ? rx_s - coloffs : 0)
* ch_w));
int vx1 = viewport.x() + static_cast<int>((
(rx_e - coloffs) * ch_w));
QRect r(vx0, y, std::max(0, vx1 - vx0), line_h);
if (r.width() <= 0)
continue;
bool is_current =
has_current && sx == cur_x && ex == cur_end;
QColor col = is_current
? QColor(255, 220, 120, 140)
: QColor(200, 200, 0, 90);
p.fillRect(r, col);
}
}
}
// Selection background (if active on this line)
if (buf->MarkSet() && (
i == buf->MarkCury() || i == cy || (
i > std::min(buf->MarkCury(), cy) && i < std::max(
buf->MarkCury(), cy)))) {
std::size_t sx = 0, ex = 0;
if (buf->MarkCury() == i && cy == i) {
sx = std::min(buf->MarkCurx(), cx);
ex = std::max(buf->MarkCurx(), cx);
} else if (i == buf->MarkCury()) {
sx = buf->MarkCurx();
ex = line.size();
} else if (i == cy) {
sx = 0;
ex = cx;
} else {
sx = 0;
ex = line.size();
}
std::size_t rx_s = src_to_rx_line(sx);
std::size_t rx_e = src_to_rx_line(ex);
if (rx_e > coloffs) {
int vx0 = viewport.x() + static_cast<int>((rx_s > coloffs
? rx_s - coloffs
: 0) * ch_w);
int vx1 = viewport.x() + static_cast<int>(
(rx_e - coloffs) * ch_w);
QRect sel_r(vx0, y, std::max(0, vx1 - vx0), line_h);
if (sel_r.width() > 0)
p.fillRect(sel_r, sel_bg);
}
}
// Build expanded line (tabs -> spaces) for drawing
std::string expanded;
expanded.reserve(line.size() + 8);
std::size_t rx_acc = 0;
for (char c: line) {
if (c == '\t') {
std::size_t adv = (tabw - (rx_acc % tabw));
expanded.append(adv, ' ');
rx_acc += adv;
} else {
expanded.push_back(c);
rx_acc += 1;
}
}
// Syntax highlighting spans or plain text
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) {
kte::LineHighlight lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version());
struct SSpan {
std::size_t s;
std::size_t e;
kte::TokenKind k;
};
std::vector<SSpan> spans;
spans.reserve(lh.spans.size());
const std::size_t line_len = line.size();
for (const auto &sp: lh.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, (int) line_len)));
std::size_t e = static_cast<std::size_t>(std::max(
(int) s, std::min(e_raw, (int) line_len)));
if (s < e)
spans.push_back({s, e, sp.kind});
}
std::sort(spans.begin(), spans.end(),
[](const SSpan &a, const SSpan &b) {
return a.s < b.s;
});
auto colorFor = [](kte::TokenKind k) -> QColor {
// GUITheme provides colors via ImGui vector; avoid direct dependency types
const auto v = kte::SyntaxInk(k);
return QColor(int(v.x * 255.0f), int(v.y * 255.0f),
int(v.z * 255.0f), int(v.w * 255.0f));
};
// Helper to convert src col to expanded rx
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
std::size_t rx = 0;
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
}
return rx;
};
if (spans.empty()) {
// No highlight spans: draw the whole (visible) expanded line in default fg
if (coloffs < expanded.size()) {
const char *start =
expanded.c_str() + static_cast<int>(coloffs);
p.setPen(fg);
p.drawText(viewport.x(), baseline,
QString::fromUtf8(start));
}
} else {
// Draw colored spans
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)
continue; // left of viewport
std::size_t draw_start = (rx_s > coloffs)
? rx_s
: coloffs;
std::size_t draw_end = std::min<std::size_t>(
rx_e, expanded.size());
if (draw_end <= draw_start)
continue;
std::size_t screen_x = draw_start - coloffs;
int px = viewport.x() + int(screen_x * ch_w);
int len = int(draw_end - draw_start);
p.setPen(colorFor(sp.k));
p.drawText(px, baseline,
QString::fromUtf8(
expanded.c_str() + draw_start, len));
}
}
} else {
// Draw expanded text clipped by coloffs
if (static_cast<std::size_t>(coloffs) < expanded.size()) {
const char *start =
expanded.c_str() + static_cast<int>(coloffs);
p.setPen(fg);
p.drawText(viewport.x(), baseline, QString::fromUtf8(start));
}
}
// Cursor indicator on current line
if (i == cy) {
std::size_t rx_cur = src_to_rx_line(cx);
if (rx_cur >= coloffs) {
// Compute exact pixel x by measuring expanded substring [coloffs, rx_cur)
std::size_t start = std::min<std::size_t>(
coloffs, expanded.size());
std::size_t end = std::min<
std::size_t>(rx_cur, expanded.size());
int px_advance = 0;
if (end > start) {
const QString sub = QString::fromUtf8(
expanded.c_str() + start,
static_cast<int>(end - start));
px_advance = fm.horizontalAdvance(sub);
}
int x0 = viewport.x() + px_advance;
QRect r(x0, y, ch_w, line_h);
p.fillRect(r, cur_bg);
}
}
}
p.restore();
}
}
// Status bar
const int bar_y = height() - status_h;
QRect status_rect(0, bar_y, width(), status_h);
p.fillRect(status_rect, status_bg);
p.setPen(status_fg);
if (ed_) {
const int pad = 6;
const int left_x = status_rect.x() + pad;
const int right_x_max = status_rect.x() + status_rect.width() - pad;
const int baseline_y = bar_y + (status_h + fm.ascent() - fm.descent()) / 2;
// If a prompt is active, mirror ImGui/TUI: show only the prompt across the bar
if (ed_->PromptActive()) {
std::string label = ed_->PromptLabel();
std::string text = ed_->PromptText();
// Map $HOME to ~ for path prompts (Open/Save/Chdir)
auto kind = ed_->CurrentPromptKind();
if (kind == Editor::PromptKind::OpenFile ||
kind == Editor::PromptKind::SaveAs ||
kind == Editor::PromptKind::Chdir) {
const char *home_c = std::getenv("HOME");
if (home_c && *home_c) {
std::string home(home_c);
if (text.rfind(home, 0) == 0) {
std::string rest = text.substr(home.size());
if (rest.empty())
text = "~";
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
text = std::string("~") + rest;
}
}
}
std::string prefix;
if (kind == Editor::PromptKind::Command)
prefix = ": ";
else if (!label.empty())
prefix = label + ": ";
// Compose text and elide per behavior:
const int max_w = status_rect.width() - 2 * pad;
QString qprefix = QString::fromStdString(prefix);
QString qtext = QString::fromStdString(text);
int avail_w = std::max(0, max_w - fm.horizontalAdvance(qprefix));
Qt::TextElideMode mode = Qt::ElideRight;
if (kind == Editor::PromptKind::OpenFile ||
kind == Editor::PromptKind::SaveAs ||
kind == Editor::PromptKind::Chdir) {
mode = Qt::ElideLeft;
}
QString shown = fm.elidedText(qtext, mode, avail_w);
p.drawText(left_x, baseline_y, qprefix + shown);
} else {
// Build left segment: app/version, buffer idx/total, filename [+dirty], line count
QString left;
left += QStringLiteral("kge ");
left += QStringLiteral(KTE_VERSION_STR);
const Buffer *buf = ed_->CurrentBuffer();
if (buf) {
// buffer index/total
std::size_t total = ed_->BufferCount();
if (total > 0) {
std::size_t idx1 = ed_->CurrentBufferIndex() + 1; // 1-based
left += QStringLiteral(" [");
left += QString::number(static_cast<qlonglong>(idx1));
left += QStringLiteral("/");
left += QString::number(static_cast<qlonglong>(total));
left += QStringLiteral("] ");
} else {
left += QStringLiteral(" ");
}
// buffer display name
std::string disp;
try {
disp = ed_->DisplayNameFor(*buf);
} catch (...) {
disp = buf->Filename();
}
if (disp.empty())
disp = "[No Name]";
left += QString::fromStdString(disp);
if (buf->Dirty())
left += QStringLiteral(" *");
// total lines suffix " <n>L"
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += QStringLiteral(" ");
left += QString::number(static_cast<qlonglong>(lcount));
left += QStringLiteral("L");
}
// Build right segment: cursor and mark
QString right;
if (buf) {
int row1 = static_cast<int>(buf->Cury()) + 1;
int col1 = static_cast<int>(buf->Curx()) + 1;
bool have_mark = buf->MarkSet();
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
if (have_mark)
right = QString("%1,%2 | M: %3,%4").arg(row1).arg(col1).arg(mrow1).arg(
mcol1);
else
right = QString("%1,%2 | M: not set").arg(row1).arg(col1);
}
// Middle message: status text
QString mid = QString::fromStdString(ed_->Status());
// Measure and layout
int left_w = fm.horizontalAdvance(left);
int right_w = fm.horizontalAdvance(right);
int lx = left_x;
int rx = std::max(left_x, right_x_max - right_w);
// If overlap, elide left to make space for right
if (lx + left_w + pad > rx) {
int max_left_w = std::max(0, rx - lx - pad);
left = fm.elidedText(left, Qt::ElideRight, max_left_w);
left_w = fm.horizontalAdvance(left);
}
// Draw left and right
p.drawText(lx, baseline_y, left);
if (!right.isEmpty())
p.drawText(rx, baseline_y, right);
// Middle message clipped between end of left and start of right
int mid_left = lx + left_w + pad;
int mid_right = std::max(mid_left, rx - pad);
int mid_w = std::max(0, mid_right - mid_left);
if (mid_w > 0 && !mid.isEmpty()) {
QString mid_show = fm.elidedText(mid, Qt::ElideRight, mid_w);
p.save();
p.setClipRect(QRect(mid_left, bar_y, mid_w, status_h));
p.drawText(mid_left, baseline_y, mid_show);
p.restore();
}
}
}
}
void resizeEvent(QResizeEvent *event) override
{
QWidget::resizeEvent(event);
if (!ed_)
return;
// Update editor dimensions based on new size
QFont f(font_family_, font_px_);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
const int pad_l = 8, pad_r = 8, pad_t = 6, pad_b = 6;
const int status_h = line_h + 6;
const int avail_w = std::max(0, width() - pad_l - pad_r);
const int avail_h = std::max(0, height() - pad_t - pad_b - status_h);
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h));
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
ed_->SetDimensions(rows, cols);
}
void wheelEvent(QWheelEvent *event) override
{
if (!ed_) {
QWidget::wheelEvent(event);
return;
}
Buffer *buf = ed_->CurrentBuffer();
if (!buf) {
QWidget::wheelEvent(event);
return;
}
// Recompute metrics to map pixel deltas to rows/cols
QFont f(font_family_, font_px_);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
// Determine scroll intent: use pixelDelta when available (trackpads), otherwise angleDelta
QPoint pixel = event->pixelDelta();
QPoint angle = event->angleDelta();
double v_lines_delta = 0.0;
double h_cols_delta = 0.0;
// Horizontal scroll with Shift or explicit horizontal delta
bool horiz_mode = (event->modifiers() & Qt::ShiftModifier) || (!pixel.isNull() && pixel.x() != 0) || (
!angle.isNull() && angle.x() != 0);
if (!pixel.isNull()) {
// Trackpad smooth scrolling (pixels)
v_lines_delta = -static_cast<double>(pixel.y()) / std::max(1, line_h);
h_cols_delta = -static_cast<double>(pixel.x()) / std::max(1, ch_w);
} else if (!angle.isNull()) {
// Mouse wheel: 120 units per notch; map one notch to 3 lines similar to ImGui UX
v_lines_delta = -static_cast<double>(angle.y()) / 120.0 * 3.0;
// For horizontal wheels, each notch scrolls 8 columns
h_cols_delta = -static_cast<double>(angle.x()) / 120.0 * 8.0;
}
// Accumulate fractional deltas across events
v_scroll_accum_ += v_lines_delta;
h_scroll_accum_ += h_cols_delta;
int d_rows = 0;
int d_cols = 0;
if (std::fabs(v_scroll_accum_) >= 1.0 && (!horiz_mode || std::fabs(v_scroll_accum_) > std::fabs(
h_scroll_accum_))) {
d_rows = static_cast<int>(v_scroll_accum_);
v_scroll_accum_ -= d_rows;
}
if (std::fabs(h_scroll_accum_) >= 1.0 && (horiz_mode || std::fabs(h_scroll_accum_) >= std::fabs(
v_scroll_accum_))) {
d_cols = static_cast<int>(h_scroll_accum_);
h_scroll_accum_ -= d_cols;
}
if (d_rows != 0 || d_cols != 0) {
std::size_t new_rowoffs = buf->Rowoffs();
std::size_t new_coloffs = buf->Coloffs();
// Clamp vertical between 0 and last row (leaving at least one visible line)
if (d_rows != 0) {
long nr = static_cast<long>(new_rowoffs) + d_rows;
if (nr < 0)
nr = 0;
const auto nrows = static_cast<long>(buf->Rows().size());
if (nr > std::max(0L, nrows - 1))
nr = std::max(0L, nrows - 1);
new_rowoffs = static_cast<std::size_t>(nr);
}
if (d_cols != 0) {
long nc = static_cast<long>(new_coloffs) + d_cols;
if (nc < 0)
nc = 0;
new_coloffs = static_cast<std::size_t>(nc);
}
buf->SetOffsets(new_rowoffs, new_coloffs);
update();
event->accept();
return;
}
QWidget::wheelEvent(event);
}
void closeEvent(QCloseEvent *event) override
{
closed_ = true;
QWidget::closeEvent(event);
}
private:
QtInputHandler &input_;
bool closed_ = false;
Editor *ed_ = nullptr;
double v_scroll_accum_ = 0.0;
double h_scroll_accum_ = 0.0;
QString font_family_ = QStringLiteral("Brass Mono");
int font_px_ = 18;
};
} // namespace
bool
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{
app_ = new QApplication(argc, argv);
window_ = new MainWindow(input_);
window_->show();
// Ensure the window becomes the active, focused window so it receives key events
window_->activateWindow();
window_->raise();
window_->setFocus(Qt::OtherFocusReason);
renderer_.Attach(window_);
input_.Attach(&ed);
if (auto *mw = dynamic_cast<MainWindow *>(window_))
mw->SetEditor(&ed);
// Load GUI configuration (kge.ini) and configure font for Qt
config_ = GUIConfig::Load();
// Apply background mode from config to match ImGui frontend behavior
if (config_.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light);
else
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
// Apply theme by name for Qt palette-based theming (maps to named palettes).
// If unknown, falls back to the generic light/dark palette.
(void) kte::ApplyQtThemeByName(config_.theme);
if (window_)
window_->update();
// Map GUIConfig font name to a system family (Qt uses installed fonts)
auto choose_family = [](const std::string &name) -> QString {
QString fam;
std::string n = name;
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
if (n.empty() || n == "default" || n == "brassmono" || n == "brassmonocode") {
fam = QStringLiteral("Brass Mono");
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
fam = QStringLiteral("JetBrains Mono");
} else if (n == "iosevka") {
fam = QStringLiteral("Iosevka");
} else if (n == "inconsolata" || n == "inconsolataex") {
fam = QStringLiteral("Inconsolata");
} else if (n == "space" || n == "spacemono" || n == "space mono") {
fam = QStringLiteral("Space Mono");
} else if (n == "go") {
fam = QStringLiteral("Go Mono");
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
fam = QStringLiteral("IBM Plex Mono");
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
fam = QStringLiteral("Fira Code");
} else if (!name.empty()) {
fam = QString::fromStdString(name);
}
// Validate availability; choose a fallback if needed
const auto families = QFontDatabase::families();
if (!fam.isEmpty() && families.contains(fam)) {
return fam;
}
// Preferred fallback chain on macOS; otherwise, try common monospace families
const QStringList fallbacks = {
QStringLiteral("Brass Mono"),
QStringLiteral("JetBrains Mono"),
QStringLiteral("SF Mono"),
QStringLiteral("Menlo"),
QStringLiteral("Monaco"),
QStringLiteral("Courier New"),
QStringLiteral("Courier"),
QStringLiteral("Monospace")
};
for (const auto &fb: fallbacks) {
if (families.contains(fb))
return fb;
}
// As a last resort, return the request (Qt will substitute)
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
};
QString family = choose_family(config_.font);
int px_size = (config_.font_size > 0.0f) ? (int) std::lround(config_.font_size) : 18;
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
mw->SetFontFamilyAndSize(family, px_size);
}
// Track current font in globals for command/status queries
kte::gCurrentFontFamily = family.toStdString();
kte::gCurrentFontSize = static_cast<float>(px_size);
// Set initial dimensions based on font metrics
QFont f(family, px_size);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
const int w = window_->width();
const int h = window_->height();
const int pad = 16;
const int status_h = line_h + 4;
const int avail_w = std::max(0, w - 2 * pad);
const int avail_h = std::max(0, h - 2 * pad - status_h);
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
ed.SetDimensions(rows, cols);
return true;
}
void
GUIFrontend::Step(Editor &ed, bool &running)
{
// Pump Qt events
if (app_)
app_->processEvents();
// Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens();
// Drain input queue
for (;;) {
MappedInput mi;
if (!input_.Poll(mi))
break;
if (mi.hasCommand) {
Execute(ed, mi.id, mi.arg, mi.count);
}
}
if (ed.QuitRequested()) {
running = false;
}
// --- Visual File Picker (Qt): invoked via CommandId::VisualFilePickerToggle ---
if (ed.FilePickerVisible()) {
QString startDir;
if (!ed.FilePickerDir().empty()) {
startDir = QString::fromStdString(ed.FilePickerDir());
}
QFileDialog dlg(window_, QStringLiteral("Open File"), startDir);
dlg.setFileMode(QFileDialog::ExistingFile);
if (dlg.exec() == QDialog::Accepted) {
const QStringList files = dlg.selectedFiles();
if (!files.isEmpty()) {
const QString fp = files.front();
ed.RequestOpenFile(fp.toStdString());
(void) ed.ProcessPendingOpens();
// Update picker dir for next time
QFileInfo info(fp);
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
}
}
// Close picker overlay regardless of outcome
ed.SetFilePickerVisible(false);
if (window_)
window_->update();
}
// Apply any queued theme change requests (from command handler)
if (kte::gThemeChangePending) {
if (!kte::gThemeChangeRequest.empty()) {
// Apply Qt palette theme by name; if unknown, keep current palette
(void) kte::ApplyQtThemeByName(kte::gThemeChangeRequest);
}
kte::gThemeChangePending = false;
kte::gThemeChangeRequest.clear();
if (window_)
window_->update();
}
// Visual font picker request (Qt only)
if (kte::gFontDialogRequested) {
// Seed initial font from current or default
QFont seed;
if (!kte::gCurrentFontFamily.empty()) {
seed = QFont(QString::fromStdString(kte::gCurrentFontFamily),
(int) std::lround(kte::gCurrentFontSize > 0 ? kte::gCurrentFontSize : 18));
} else {
seed = window_ ? window_->font() : QFont();
}
bool ok = false;
const QFont chosen = QFontDialog::getFont(&ok, seed, window_, QStringLiteral("Choose Editor Font"));
if (ok) {
// Queue font change via existing hooks
kte::gFontFamilyRequest = chosen.family().toStdString();
// Use pixel size if available, otherwise convert from point size approximately
int px = chosen.pixelSize();
if (px <= 0) {
// Approximate points to pixels (96 DPI assumption); Qt will rasterize appropriately
px = (int) std::lround(chosen.pointSizeF() * 96.0 / 72.0);
if (px <= 0)
px = 18;
}
kte::gFontSizeRequest = static_cast<float>(px);
kte::gFontChangePending = true;
}
kte::gFontDialogRequested = false;
if (window_)
window_->update();
}
// Apply any queued font change requests (Qt)
if (kte::gFontChangePending) {
// Derive target family
auto map_family = [](const std::string &name) -> QString {
std::string n = name;
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
QString fam;
if (n == "brass" || n == "brassmono" || n == "brass mono") {
fam = QStringLiteral("Brass Mono");
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
fam = QStringLiteral("JetBrains Mono");
} else if (n == "iosevka") {
fam = QStringLiteral("Iosevka");
} else if (n == "inconsolata" || n == "inconsolataex") {
fam = QStringLiteral("Inconsolata");
} else if (n == "space" || n == "spacemono" || n == "space mono") {
fam = QStringLiteral("Space Mono");
} else if (n == "go") {
fam = QStringLiteral("Go Mono");
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
fam = QStringLiteral("IBM Plex Mono");
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
fam = QStringLiteral("Fira Code");
} else if (!name.empty()) {
fam = QString::fromStdString(name);
}
// Validate availability; choose fallback if needed
const auto families = QFontDatabase::families();
if (!fam.isEmpty() && families.contains(fam)) {
return fam;
}
// Fallback chain
const QStringList fallbacks = {
QStringLiteral("Brass Mono"),
QStringLiteral("JetBrains Mono"),
QStringLiteral("SF Mono"),
QStringLiteral("Menlo"),
QStringLiteral("Monaco"),
QStringLiteral("Courier New"),
QStringLiteral("Courier"),
QStringLiteral("Monospace")
};
for (const auto &fb: fallbacks) {
if (families.contains(fb))
return fb;
}
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
};
QString target_family;
if (!kte::gFontFamilyRequest.empty()) {
target_family = map_family(kte::gFontFamilyRequest);
} else if (!kte::gCurrentFontFamily.empty()) {
target_family = QString::fromStdString(kte::gCurrentFontFamily);
}
int target_px = 0;
if (kte::gFontSizeRequest > 0.0f) {
target_px = (int) std::lround(kte::gFontSizeRequest);
} else if (kte::gCurrentFontSize > 0.0f) {
target_px = (int) std::lround(kte::gCurrentFontSize);
}
if (target_px <= 0)
target_px = 18;
if (target_family.isEmpty())
target_family = QStringLiteral("Monospace");
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
mw->SetFontFamilyAndSize(target_family, target_px);
}
// Update globals
kte::gCurrentFontFamily = target_family.toStdString();
kte::gCurrentFontSize = static_cast<float>(target_px);
// Reset requests
kte::gFontChangePending = false;
kte::gFontFamilyRequest.clear();
kte::gFontSizeRequest = 0.0f;
// Recompute editor dimensions to match new metrics
QFont f(target_family, target_px);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
const int w = window_ ? window_->width() : 0;
const int h = window_ ? window_->height() : 0;
const int pad = 16;
const int status_h = line_h + 4;
const int avail_w = std::max(0, w - 2 * pad);
const int avail_h = std::max(0, h - 2 * pad - status_h);
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
ed.SetDimensions(rows, cols);
if (window_)
window_->update();
}
// Draw current frame (request repaint)
renderer_.Draw(ed);
// Detect window close
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
if (mw->WasClosed()) {
running = false;
}
}
}
void
GUIFrontend::Shutdown()
{
if (window_) {
window_->close();
delete window_;
window_ = nullptr;
}
if (app_) {
delete app_;
app_ = nullptr;
}
}
-36
View File
@@ -1,36 +0,0 @@
/*
* QtFrontend - couples QtInputHandler + QtRenderer and owns Qt lifecycle
*/
#pragma once
#include "Frontend.h"
#include "GUIConfig.h"
#include "QtInputHandler.h"
#include "QtRenderer.h"
class QApplication;
class QWidget;
// Keep the public class name GUIFrontend to match main.cc selection logic.
class GUIFrontend final : public Frontend {
public:
GUIFrontend() = default;
~GUIFrontend() override = default;
bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override;
void Shutdown() override;
private:
GUIConfig config_{};
QtInputHandler input_{};
QtRenderer renderer_{};
QApplication *app_ = nullptr; // owned
QWidget *window_ = nullptr; // owned
int width_ = 1280;
int height_ = 800;
};
-538
View File
@@ -1,538 +0,0 @@
// Refactor: route all key processing through command subsystem, mirroring ImGuiInputHandler
#include "QtInputHandler.h"
#include <QKeyEvent>
#include <ncurses.h>
#include "Editor.h"
#include "KKeymap.h"
// Temporary verbose logging to debug macOS Qt key translation issues
// Default to off; enable by defining QT_IH_DEBUG=1 at compile time when needed.
#ifndef QT_IH_DEBUG
#define QT_IH_DEBUG 0
#endif
#if QT_IH_DEBUG
#include <cstdio>
static const char *
mods_str(Qt::KeyboardModifiers m)
{
static thread_local char buf[64];
buf[0] = '\0';
bool first = true;
auto add = [&](const char *s) {
if (!first)
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "|");
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "%s", s);
first = false;
};
if (m & Qt::ShiftModifier)
add("Shift");
if (m & Qt::ControlModifier)
add("Ctrl");
if (m & Qt::AltModifier)
add("Alt");
if (m & Qt::MetaModifier)
add("Meta");
if (first)
std::snprintf(buf, sizeof(buf), "none");
return buf;
}
#define LOGF(...) std::fprintf(stderr, __VA_ARGS__)
#else
#define LOGF(...) ((void)0)
#endif
static bool
IsPrintableQt(const QKeyEvent &e)
{
// Printable if it yields non-empty text and no Ctrl/Meta modifier
if (e.modifiers() & (Qt::ControlModifier | Qt::MetaModifier))
return false;
const QString t = e.text();
return !t.isEmpty() && !t.at(0).isNull();
}
static int
ToAsciiKey(const QKeyEvent &e)
{
const QString t = e.text();
if (!t.isEmpty()) {
const QChar c = t.at(0);
if (!c.isNull())
return KLowerAscii(c.unicode());
}
// When modifiers (like Control) are held, Qt::text() can be empty on macOS.
// Fall back to mapping common virtual keys to ASCII.
switch (e.key()) {
case Qt::Key_A:
return 'a';
case Qt::Key_B:
return 'b';
case Qt::Key_C:
return 'c';
case Qt::Key_D:
return 'd';
case Qt::Key_E:
return 'e';
case Qt::Key_F:
return 'f';
case Qt::Key_G:
return 'g';
case Qt::Key_H:
return 'h';
case Qt::Key_I:
return 'i';
case Qt::Key_J:
return 'j';
case Qt::Key_K:
return 'k';
case Qt::Key_L:
return 'l';
case Qt::Key_M:
return 'm';
case Qt::Key_N:
return 'n';
case Qt::Key_O:
return 'o';
case Qt::Key_P:
return 'p';
case Qt::Key_Q:
return 'q';
case Qt::Key_R:
return 'r';
case Qt::Key_S:
return 's';
case Qt::Key_T:
return 't';
case Qt::Key_U:
return 'u';
case Qt::Key_V:
return 'v';
case Qt::Key_W:
return 'w';
case Qt::Key_X:
return 'x';
case Qt::Key_Y:
return 'y';
case Qt::Key_Z:
return 'z';
case Qt::Key_0:
return '0';
case Qt::Key_1:
return '1';
case Qt::Key_2:
return '2';
case Qt::Key_3:
return '3';
case Qt::Key_4:
return '4';
case Qt::Key_5:
return '5';
case Qt::Key_6:
return '6';
case Qt::Key_7:
return '7';
case Qt::Key_8:
return '8';
case Qt::Key_9:
return '9';
case Qt::Key_Comma:
return ',';
case Qt::Key_Period:
return '.';
case Qt::Key_Semicolon:
return ';';
case Qt::Key_Apostrophe:
return '\'';
case Qt::Key_Minus:
return '-';
case Qt::Key_Equal:
return '=';
case Qt::Key_Slash:
return '/';
case Qt::Key_Backslash:
return '\\';
case Qt::Key_BracketLeft:
return '[';
case Qt::Key_BracketRight:
return ']';
case Qt::Key_QuoteLeft:
return '`';
case Qt::Key_Space:
return ' ';
default:
break;
}
return 0;
}
// Case-preserving ASCII derivation for k-prefix handling where we need to
// distinguish between 'C' and 'c'. Falls back to virtual-key mapping if
// event text is unavailable (common when Control/Meta held on macOS).
static int
ToAsciiKeyPreserveCase(const QKeyEvent &e)
{
const QString t = e.text();
if (!t.isEmpty()) {
const QChar c = t.at(0);
if (!c.isNull())
return c.unicode();
}
// Fall back to virtual key mapping (letters as uppercase A..Z)
switch (e.key()) {
case Qt::Key_A:
return 'A';
case Qt::Key_B:
return 'B';
case Qt::Key_C:
return 'C';
case Qt::Key_D:
return 'D';
case Qt::Key_E:
return 'E';
case Qt::Key_F:
return 'F';
case Qt::Key_G:
return 'G';
case Qt::Key_H:
return 'H';
case Qt::Key_I:
return 'I';
case Qt::Key_J:
return 'J';
case Qt::Key_K:
return 'K';
case Qt::Key_L:
return 'L';
case Qt::Key_M:
return 'M';
case Qt::Key_N:
return 'N';
case Qt::Key_O:
return 'O';
case Qt::Key_P:
return 'P';
case Qt::Key_Q:
return 'Q';
case Qt::Key_R:
return 'R';
case Qt::Key_S:
return 'S';
case Qt::Key_T:
return 'T';
case Qt::Key_U:
return 'U';
case Qt::Key_V:
return 'V';
case Qt::Key_W:
return 'W';
case Qt::Key_X:
return 'X';
case Qt::Key_Y:
return 'Y';
case Qt::Key_Z:
return 'Z';
case Qt::Key_Comma:
return ',';
case Qt::Key_Period:
return '.';
case Qt::Key_Semicolon:
return ';';
case Qt::Key_Apostrophe:
return '\'';
case Qt::Key_Minus:
return '-';
case Qt::Key_Equal:
return '=';
case Qt::Key_Slash:
return '/';
case Qt::Key_Backslash:
return '\\';
case Qt::Key_BracketLeft:
return '[';
case Qt::Key_BracketRight:
return ']';
case Qt::Key_QuoteLeft:
return '`';
case Qt::Key_Space:
return ' ';
default:
break;
}
return 0;
}
bool
QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
{
const Qt::KeyboardModifiers mods = e.modifiers();
LOGF("[QtIH] keyPress key=0x%X mods=%s text='%s' k_prefix=%d k_ctrl_pending=%d esc_meta=%d\n",
e.key(), mods_str(mods), e.text().toUtf8().constData(), (int)k_prefix_, (int)k_ctrl_pending_,
(int)esc_meta_);
// Control-chord detection: only treat the physical Control key as control-like.
// Do NOT include Meta (Command) here so that ⌘-letter shortcuts do not fall into
// the Ctrl map (prevents ⌘-T being mistaken for C-t).
const bool ctrl_like = (mods & Qt::ControlModifier);
// 1) Universal argument digits (when active), consume digits without enqueuing commands
if (ed_ &&ed_
->
UArg() != 0
)
{
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
int d = e.key() - Qt::Key_0;
ed_->UArgDigit(d);
// request status refresh
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::UArgStatus, std::string(), 0});
LOGF("[QtIH] UArg digit %d -> enqueue UArgStatus\n", d);
return true;
}
}
}
// 2) Enter k-prefix on C-k
if (ctrl_like && (e.key() == Qt::Key_K)) {
k_prefix_ = true;
k_ctrl_pending_ = false;
LOGF("[QtIH] Enter KPrefix\n");
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::KPrefix, std::string(), 0});
return true;
}
// 3) If currently in k-prefix, resolve next key via KLookupKCommand
if (k_prefix_) {
// ESC/meta prefix should not interfere with k-suffix resolution
esc_meta_ = false;
// Support literal 'C' (uppercase) or '^' to indicate the next key is Ctrl-qualified.
// Use case-preserving derivation so that 'c' (lowercase) can still be a valid suffix
// like C-k c (BufferClose).
int ascii_raw = ToAsciiKeyPreserveCase(e);
if (ascii_raw == 'C' || ascii_raw == '^') {
k_ctrl_pending_ = true;
if (ed_)
ed_->SetStatus("C-k C _");
LOGF("[QtIH] KPrefix: set k_ctrl_pending via '%c'\n", (ascii_raw == 'C') ? 'C' : '^');
return true; // consume, wait for next key
}
int ascii_key = (ascii_raw != 0) ? ascii_raw : ToAsciiKey(e);
int lower = KLowerAscii(ascii_key);
// Only pass a control suffix for specific supported keys (d/x/q),
// matching ImGui behavior so that holding Ctrl during the suffix
// doesn't break other mappings like C-k c (BufferClose).
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
bool pass_ctrl = (ctrl_like || k_ctrl_pending_) && ctrl_suffix_supported;
k_ctrl_pending_ = false; // consume pending qualifier on any suffix
LOGF("[QtIH] KPrefix: ascii_key=%d lower=%d pass_ctrl=%d\n", ascii_key, lower, (int)pass_ctrl);
if (ascii_key != 0) {
CommandId id;
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
LOGF("[QtIH] KPrefix: mapped to command id=%d\n", (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
} else {
// Unknown k-command: notify
std::string a;
a.push_back(static_cast<char>(ascii_key));
LOGF("[QtIH] KPrefix: unknown command for '%c'\n", (char)ascii_key);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::UnknownKCommand, a, 0});
}
k_prefix_ = false;
return true;
}
// If not resolvable, consume and exit k-prefix
k_prefix_ = false;
LOGF("[QtIH] KPrefix: unresolved key; exiting prefix\n");
return true;
}
// 3.5) GUI shortcut: Command/Meta + T opens the visual font picker (Qt only).
// Require Meta present and Control NOT present so Ctrl-T never triggers this.
if ((mods & Qt::MetaModifier) && !(mods & Qt::ControlModifier) && e.key() == Qt::Key_T) {
LOGF("[QtIH] Meta/Super-T -> VisualFontPickerToggle\n");
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::VisualFontPickerToggle, std::string(), 0});
return true;
}
// 4) ESC as Meta prefix (set state). Alt/Meta chord handled below directly.
if (e.key() == Qt::Key_Escape) {
esc_meta_ = true;
LOGF("[QtIH] ESC: set esc_meta\n");
return true; // consumed
}
// 5) Alt/Meta bindings (ESC f/b equivalent). Handle either Alt/Meta or pending esc_meta_
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
#if defined(__APPLE__)
if (esc_meta_ || (mods & Qt::AltModifier)) {
#else
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
#endif
int ascii_key = 0;
if (e.key() == Qt::Key_Backspace) {
ascii_key = KEY_BACKSPACE;
} else if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
ascii_key = 'a' + (e.key() - Qt::Key_A);
} else if (e.key() == Qt::Key_Comma) {
ascii_key = '<';
} else if (e.key() == Qt::Key_Period) {
ascii_key = '>';
}
// If still unknown, try deriving from text (covers digits, punctuation, locale)
if (ascii_key == 0) {
ascii_key = ToAsciiKey(e);
}
esc_meta_ = false; // one-shot regardless
if (ascii_key != 0) {
ascii_key = KLowerAscii(ascii_key);
CommandId id;
if (KLookupEscCommand(ascii_key, id)) {
LOGF("[QtIH] ESC/Meta: mapped '%d' -> id=%d\n", ascii_key, (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
return true;
} else {
// Report invalid ESC sequence just like ImGui path
LOGF("[QtIH] ESC/Meta: unknown command for ascii=%d\n", ascii_key);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::UnknownEscCommand, std::string(), 0});
return true;
}
}
// Nothing derivable: consume (ESC prefix cleared) and do not insert text
return true;
}
// 6) Control-chord direct mappings (e.g., C-n/C-p/C-f/C-b...)
if (ctrl_like) {
// Universal argument handling: C-u starts collection; C-g cancels
if (e.key() == Qt::Key_U) {
if (ed_)
ed_->UArgStart();
LOGF("[QtIH] Ctrl-chord: start universal argument\n");
return true;
}
if (e.key() == Qt::Key_G) {
if (ed_)
ed_->UArgClear();
k_ctrl_pending_ = false;
k_prefix_ = false;
LOGF("[QtIH] Ctrl-chord: cancel universal argument and k-prefix via C-g\n");
// Fall through to map C-g to Refresh via ctrl map
}
if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
int ascii_key = 'a' + (e.key() - Qt::Key_A);
CommandId id;
if (KLookupCtrlCommand(ascii_key, id)) {
LOGF("[QtIH] Ctrl-chord: 'C-%c' -> id=%d\n", (char)ascii_key, (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
return true;
}
}
// If no mapping, continue to allow other keys below
}
// 7) Special navigation/edit keys (match ImGui behavior)
{
CommandId id;
bool has = false;
switch (e.key()) {
case Qt::Key_Return:
case Qt::Key_Enter:
id = CommandId::Newline;
has = true;
break;
case Qt::Key_Backspace:
id = CommandId::Backspace;
has = true;
break;
case Qt::Key_Delete:
id = CommandId::DeleteChar;
has = true;
break;
case Qt::Key_Left:
id = CommandId::MoveLeft;
has = true;
break;
case Qt::Key_Right:
id = CommandId::MoveRight;
has = true;
break;
case Qt::Key_Up:
id = CommandId::MoveUp;
has = true;
break;
case Qt::Key_Down:
id = CommandId::MoveDown;
has = true;
break;
case Qt::Key_Home:
id = CommandId::MoveHome;
has = true;
break;
case Qt::Key_End:
id = CommandId::MoveEnd;
has = true;
break;
case Qt::Key_PageUp:
id = CommandId::PageUp;
has = true;
break;
case Qt::Key_PageDown:
id = CommandId::PageDown;
has = true;
break;
default:
break;
}
if (has) {
LOGF("[QtIH] Special key -> id=%d\n", (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
return true;
}
}
// 8) Insert printable text
if (IsPrintableQt(e)) {
std::string s = e.text().toStdString();
if (!s.empty()) {
LOGF("[QtIH] InsertText '%s'\n", s.c_str());
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::InsertText, s, 0});
return true;
}
}
LOGF("[QtIH] Unhandled key\n");
return false;
}
bool
QtInputHandler::Poll(MappedInput &out)
{
std::lock_guard<std::mutex> lock(mu_);
if (q_.empty())
return false;
out = q_.front();
q_.pop();
return true;
}
-40
View File
@@ -1,40 +0,0 @@
/*
* QtInputHandler - Qt-based input mapping for GUI mode
*/
#pragma once
#include <mutex>
#include <queue>
#include "InputHandler.h"
class QKeyEvent;
class QtInputHandler final : public InputHandler {
public:
QtInputHandler() = default;
~QtInputHandler() override = default;
void Attach(Editor *ed) override
{
ed_ = ed;
}
// Translate a Qt key event to editor command and enqueue if applicable.
// Returns true if it produced a mapped command or consumed input.
bool ProcessKeyEvent(const QKeyEvent &e);
bool Poll(MappedInput &out) override;
private:
std::mutex mu_;
std::queue<MappedInput> q_;
bool k_prefix_ = false;
bool k_ctrl_pending_ = false; // C-k C-… qualifier
bool esc_meta_ = false; // ESC-prefix for next key
bool suppress_text_input_once_ = false; // reserved (Qt sends text in keyPressEvent)
Editor *ed_ = nullptr;
};
-76
View File
@@ -1,76 +0,0 @@
#include "QtRenderer.h"
#include <QWidget>
#include <QPainter>
#include <QPaintEvent>
#include <QFont>
#include <QFontMetrics>
#include "Editor.h"
namespace {
class EditorWidget : public QWidget {
public:
explicit EditorWidget(QWidget *parent = nullptr) : QWidget(parent)
{
setAttribute(Qt::WA_OpaquePaintEvent);
setFocusPolicy(Qt::StrongFocus);
}
void SetEditor(Editor *ed)
{
ed_ = ed;
}
protected:
void paintEvent(QPaintEvent *event) override
{
Q_UNUSED(event);
QPainter p(this);
// Background
const QColor bg(28, 28, 30);
p.fillRect(rect(), bg);
// Font and metrics
QFont f("JetBrains Mono", 13);
p.setFont(f);
QFontMetrics fm(f);
const int line_h = fm.height();
// Title
p.setPen(QColor(220, 220, 220));
p.drawText(8, fm.ascent() + 4, QStringLiteral("kte (Qt frontend)"));
// Status bar at bottom
const int bar_h = line_h + 6; // padding
const int bar_y = height() - bar_h;
QRect status_rect(0, bar_y, width(), bar_h);
p.fillRect(status_rect, QColor(40, 40, 44));
p.setPen(QColor(180, 180, 140));
if (ed_) {
const QString status = QString::fromStdString(ed_->Status());
// draw at baseline within the bar
const int baseline = bar_y + 3 + fm.ascent();
p.drawText(8, baseline, status);
}
}
private:
Editor *ed_ = nullptr;
};
} // namespace
void
QtRenderer::Draw(Editor &ed)
{
if (!widget_)
return;
// If our widget is an EditorWidget, pass the editor pointer for painting
if (auto *ew = dynamic_cast<EditorWidget *>(widget_)) {
ew->SetEditor(&ed);
}
// Request a repaint
widget_->update();
}
-27
View File
@@ -1,27 +0,0 @@
/*
* QtRenderer - minimal Qt-based renderer
*/
#pragma once
#include "Renderer.h"
class QWidget;
class QtRenderer final : public Renderer {
public:
QtRenderer() = default;
~QtRenderer() override = default;
void Attach(QWidget *widget)
{
widget_ = widget;
}
void Draw(Editor &ed) override;
private:
QWidget *widget_ = nullptr; // not owned
};
+4 -6
View File
@@ -39,15 +39,13 @@ subject to refinement):
`C-g`. `C-g`.
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit), - Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
`C-k q` (quit with confirm), `C-k C-q` (quit immediately). `C-k q` (quit with confirm), `C-k C-q` (quit immediately).
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-k - Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u` region), `C-y` (yank), `C-u` (universal argument).
(universal argument).
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search), - Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word). `ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c` - Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
(close), `C-k C-r` (reload). (close), `C-k l` (reload).
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g` - Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
(goto line).
See `ke.md` for the canonical ke reference retained for now. See `ke.md` for the canonical ke reference retained for now.
+4 -5
View File
@@ -848,7 +848,6 @@ pub enum CommandId {
// GUI-specific // GUI-specific
VisualFilePickerToggle, VisualFilePickerToggle,
VisualFontPickerToggle,
ThemeNext, ThemeNext,
ThemePrev, ThemePrev,
ThemeSetByName, ThemeSetByName,
@@ -938,7 +937,7 @@ pub enum CommandError {
**Current C++ Implementation:** **Current C++ Implementation:**
- `Frontend` trait with Init/Step/Shutdown lifecycle - `Frontend` trait with Init/Step/Shutdown lifecycle
- Three implementations: Terminal (ncurses), ImGui (SDL2/OpenGL), Qt - Two implementations: Terminal (ncurses), ImGui (SDL2/OpenGL)
- Step() does one iteration: poll input, dispatch commands, render - Step() does one iteration: poll input, dispatch commands, render
- Each frontend owns InputHandler and Renderer instances - Each frontend owns InputHandler and Renderer instances
@@ -986,7 +985,7 @@ pub enum FrontendError {
- `InputHandler` trait with Poll() returning `MappedInput` - `InputHandler` trait with Poll() returning `MappedInput`
- `MappedInput`: hasCommand bool, CommandId, arg string, count - `MappedInput`: hasCommand bool, CommandId, arg string, count
- Terminal: ncurses-based, translates keys to commands - Terminal: ncurses-based, translates keys to commands
- ImGui/Qt: event-based, handles text input differently - ImGui: event-based, handles text input differently
- Keymap lookups via `KKeymap` functions (KCommand, Ctrl, Esc) - Keymap lookups via `KKeymap` functions (KCommand, Ctrl, Esc)
**Rust Design:** **Rust Design:**
@@ -1075,7 +1074,7 @@ impl InputHandler for TerminalInputHandler {
- `Renderer` trait with single Draw(Editor&) method - `Renderer` trait with single Draw(Editor&) method
- Terminal: ncurses-based, manual cursor positioning - Terminal: ncurses-based, manual cursor positioning
- ImGui/Qt: retained mode GUI, draw calls within Begin/End blocks - ImGui: immediate mode GUI, draw calls within Begin/End blocks
- Syntax highlighting integrated via Buffer::Highlighter() - Syntax highlighting integrated via Buffer::Highlighter()
**Rust Design:** **Rust Design:**
@@ -1200,7 +1199,7 @@ impl Frontend for TerminalFrontend {
#### 9.2 GUI Frontend (egui-based) #### 9.2 GUI Frontend (egui-based)
For GUI, consider replacing ImGui/Qt with `egui` (pure Rust, immediate For GUI, consider replacing ImGui with `egui` (pure Rust, immediate
mode): mode):
```rust ```rust
+344
View File
@@ -0,0 +1,344 @@
#include "RegexEngine.h"
#include <regex>
#include "RegexGuard.h"
#if defined(KTE_USE_PCRE2)
#define PCRE2_CODE_UNIT_WIDTH 8
#include <pcre2.h>
#endif
namespace kte {
namespace {
// Expand an ECMAScript replacement for one match, as std::regex_replace
// does: $` is the text since the previous match (match_results::prefix()).
// group(i, begin, end) returns whether group i participated and where;
// ngroups counts group 0.
template<typename GroupFn>
void
append_format(std::string &out, std::string_view fmt, std::string_view subject, std::size_t prev_end,
std::size_t ms, std::size_t me, std::size_t ngroups, GroupFn &&group)
{
for (std::size_t i = 0; i < fmt.size(); ++i) {
const char c = fmt[i];
if (c != '$' || i + 1 >= fmt.size()) {
out.push_back(c);
continue;
}
const char n = fmt[i + 1];
if (n == '$') {
out.push_back('$');
++i;
} else if (n == '&') {
out.append(subject.substr(ms, me - ms));
++i;
} else if (n == '`') {
out.append(subject.substr(prev_end, ms - prev_end));
++i;
} else if (n == '\'') {
out.append(subject.substr(me));
++i;
} else if (n >= '0' && n <= '9') {
// As libstdc++'s match_results::format: up to two digits, read
// greedily; $0 is the whole match; a group the pattern does not
// have expands to nothing.
std::size_t idx = static_cast<std::size_t>(n - '0');
++i;
if (i + 1 < fmt.size() && fmt[i + 1] >= '0' && fmt[i + 1] <= '9') {
idx = idx * 10 + static_cast<std::size_t>(fmt[i + 1] - '0');
++i;
}
std::size_t b = 0, e = 0;
if (idx == 0)
out.append(subject.substr(ms, me - ms));
else if (idx < ngroups && group(idx, b, e))
out.append(subject.substr(b, e - b));
} else {
out.push_back(c);
}
}
}
} // namespace
#if defined(KTE_USE_PCRE2)
// PCRE2 gives up on a match attempt after this many internal steps
// (catastrophic backtracking), reporting it instead of running for ages.
static constexpr std::uint32_t kMatchLimit = 10000000;
struct Regex::Impl {
pcre2_code *code = nullptr;
pcre2_match_data *md = nullptr;
pcre2_match_context *mctx = nullptr;
std::size_t ngroups = 1; // including group 0
mutable bool limit_hit = false;
~Impl()
{
if (md)
pcre2_match_data_free(md);
if (mctx)
pcre2_match_context_free(mctx);
if (code)
pcre2_code_free(code);
}
// pcre2_match with a subject that is never a null pointer.
int match(std::string_view s, std::size_t from, std::uint32_t opts) const
{
static const char empty = '\0';
const char *p = s.data() ? s.data() : &empty;
const int rc = pcre2_match(code, reinterpret_cast<PCRE2_SPTR>(p), s.size(), from, opts, md, mctx);
if (rc == PCRE2_ERROR_MATCHLIMIT || rc == PCRE2_ERROR_DEPTHLIMIT || rc == PCRE2_ERROR_HEAPLIMIT)
limit_hit = true;
return rc;
}
};
Regex::Regex() = default;
Regex::~Regex() = default;
Regex::Regex(Regex &&other) noexcept = default;
Regex &Regex::operator=(Regex &&other) noexcept = default;
bool
Regex::Compile(std::string_view pattern, std::string &err)
{
err.clear();
impl_.reset();
auto impl = std::make_unique<Impl>();
int errcode = 0;
PCRE2_SIZE erroff = 0;
static const char empty = '\0';
// As in ECMAScript (std::regex): '.' matches neither CR nor LF, and '$'
// matches only at the end (PCRE2's default newline is LF alone, so '.'
// matched the CR of a CRLF line and replace-all deleted it).
pcre2_compile_context *cctx = pcre2_compile_context_create(nullptr);
if (cctx)
pcre2_set_newline(cctx, PCRE2_NEWLINE_ANYCRLF);
impl->code = pcre2_compile(reinterpret_cast<PCRE2_SPTR>(pattern.data() ? pattern.data() : &empty),
pattern.size(), PCRE2_DOLLAR_ENDONLY, &errcode, &erroff, cctx);
if (cctx)
pcre2_compile_context_free(cctx);
if (!impl->code) {
PCRE2_UCHAR msg[256];
pcre2_get_error_message(errcode, msg, sizeof(msg));
err = std::string(reinterpret_cast<const char *>(msg)) + " at offset " + std::to_string(erroff);
return false;
}
(void) pcre2_jit_compile(impl->code, PCRE2_JIT_COMPLETE); // interpreted if unavailable
impl->md = pcre2_match_data_create_from_pattern(impl->code, nullptr);
impl->mctx = pcre2_match_context_create(nullptr);
if (!impl->md || !impl->mctx) {
err = "out of memory compiling the pattern";
return false;
}
pcre2_set_match_limit(impl->mctx, kMatchLimit);
std::uint32_t captures = 0;
(void) pcre2_pattern_info(impl->code, PCRE2_INFO_CAPTURECOUNT, &captures);
impl->ngroups = static_cast<std::size_t>(captures) + 1;
impl_ = std::move(impl);
return true;
}
bool
Regex::Search(std::string_view subject, std::size_t from, std::size_t &pos, std::size_t &len) const
{
if (!impl_ || from > subject.size())
return false;
impl_->limit_hit = false;
if (impl_->match(subject, from, 0) <= 0)
return false;
const PCRE2_SIZE *ov = pcre2_get_ovector_pointer(impl_->md);
pos = ov[0];
len = ov[1] - ov[0];
return true;
}
std::string
Regex::ReplaceAll(std::string_view s, std::string_view fmt) const
{
if (!impl_)
return std::string(s);
impl_->limit_hit = false;
std::string out;
std::size_t copied = 0; // subject bytes up to here are in `out`
std::size_t start = 0;
std::uint32_t opts = 0;
while (start <= s.size()) {
const int rc = impl_->match(s, start, opts);
if (rc == PCRE2_ERROR_NOMATCH && opts != 0) {
// No non-empty match right after an empty one: step past a byte.
if (start >= s.size())
break;
++start;
opts = 0;
continue;
}
if (rc <= 0) {
if (impl_->limit_hit)
return std::string(s); // gave up: leave the text as it was
break;
}
const PCRE2_SIZE *ov = pcre2_get_ovector_pointer(impl_->md);
const std::size_t ms = ov[0], me = ov[1];
out.append(s.substr(copied, ms - copied));
append_format(out, fmt, s, copied, ms, me, impl_->ngroups, [&](std::size_t i, std::size_t &b, std::size_t &e) {
if (i >= static_cast<std::size_t>(rc) || ov[2 * i] == PCRE2_UNSET)
return false;
b = ov[2 * i];
e = ov[2 * i + 1];
return true;
});
copied = me;
start = me;
// After an empty match, the next one must not be empty at the same place.
opts = (me == ms) ? (PCRE2_NOTEMPTY_ATSTART | PCRE2_ANCHORED) : 0;
}
out.append(s.substr(copied));
return out;
}
bool
Regex::Valid() const
{
return static_cast<bool>(impl_);
}
bool
Regex::LimitHit() const
{
return impl_ && impl_->limit_hit;
}
void
Regex::RunGuarded(const std::function<void()> &fn)
{
fn();
}
const char *
Regex::EngineName()
{
return "PCRE2";
}
std::size_t
Regex::IncrementalLineLimit()
{
return std::size_t{1} << 20;
}
#else // std::regex
struct Regex::Impl {
std::regex rx;
};
Regex::Regex() = default;
Regex::~Regex() = default;
Regex::Regex(Regex &&other) noexcept = default;
Regex &Regex::operator=(Regex &&other) noexcept = default;
bool
Regex::Compile(std::string_view pattern, std::string &err)
{
err.clear();
impl_.reset();
try {
auto impl = std::make_unique<Impl>();
impl->rx = std::regex(pattern.begin(), pattern.end());
impl_ = std::move(impl);
return true;
} catch (const std::regex_error &e) {
err = e.what();
return false;
}
}
bool
Regex::Search(std::string_view subject, std::size_t from, std::size_t &pos, std::size_t &len) const
{
if (!impl_ || from > subject.size())
return false;
std::cmatch m;
const auto flags = from > 0 ? std::regex_constants::match_prev_avail : std::regex_constants::match_default;
const char *b = subject.data() + from;
const char *e = subject.data() + subject.size();
if (!std::regex_search(b, e, m, impl_->rx, flags))
return false;
pos = from + static_cast<std::size_t>(m.position(0));
len = static_cast<std::size_t>(m.length(0));
return true;
}
std::string
Regex::ReplaceAll(std::string_view s, std::string_view fmt) const
{
if (!impl_)
return std::string(s);
std::string out;
std::regex_replace(std::back_inserter(out), s.begin(), s.end(), impl_->rx, std::string(fmt));
return out;
}
bool
Regex::Valid() const
{
return static_cast<bool>(impl_);
}
bool
Regex::LimitHit() const
{
return false;
}
void
Regex::RunGuarded(const std::function<void()> &fn)
{
RunWithLargeStack(fn);
}
const char *
Regex::EngineName()
{
return "std::regex";
}
std::size_t
Regex::IncrementalLineLimit()
{
return 2000;
}
#endif
} // namespace kte
+79
View File
@@ -0,0 +1,79 @@
/*
* RegexEngine.h - the regex engine behind search, replace and match
* highlighting.
*
* With KTE_USE_PCRE2 (the default when libpcre2-8 is found) patterns are
* compiled by PCRE2 with its JIT: typically one to two orders of magnitude
* faster than std::regex, no recursion on the C stack, and a match limit
* that stops catastrophic backtracking (e.g. "^(a+)+$") instead of hanging
* the editor. Otherwise std::regex (ECMAScript) is used, run on a large
* stack (see RunGuarded). The syntax both accept covers ordinary use;
* PCRE2 additionally has lookbehind, possessive quantifiers and the like.
*
* Replacement strings use ECMAScript syntax with either engine, as
* std::regex_replace (libstdc++) reads it: $& or $0 (the match), $1..$99
* (groups, two digits read greedily; a group the pattern lacks expands to
* nothing), $` (the text since the previous match), $' (the rest of the
* line), $$ (a dollar sign). As in ECMAScript, '.' matches neither CR nor
* LF and '$' matches only at the end of the line's text.
*/
#pragma once
#include <cstddef>
#include <functional>
#include <memory>
#include <string>
#include <string_view>
namespace kte {
class Regex {
public:
Regex();
~Regex();
Regex(Regex &&other) noexcept;
Regex &operator=(Regex &&other) noexcept;
Regex(const Regex &) = delete;
Regex &operator=(const Regex &) = delete;
// Compile; on failure err describes the problem and Valid() is false.
bool Compile(std::string_view pattern, std::string &err);
[[nodiscard]] bool Valid() const;
// The first match in `subject` starting at byte `from` or later; `from`
// may be inside the subject (anchors and lookbehind see what precedes
// it). False if there is none, or if the engine gave up (LimitHit()).
bool Search(std::string_view subject, std::size_t from, std::size_t &pos, std::size_t &len) const;
// Every non-overlapping match replaced by `fmt` (ECMAScript syntax, see
// above); an empty match is not repeated at the same position.
[[nodiscard]] std::string ReplaceAll(std::string_view subject, std::string_view fmt) const;
// Whether the last Search/ReplaceAll stopped at the engine's match limit.
[[nodiscard]] bool LimitHit() const;
// Run fn, which does regex work that may span long text, where the
// engine cannot overflow the stack: std::regex recurses once per matched
// character, so fn runs to completion on a thread with a large stack
// (RegexGuard.h); PCRE2 does not recurse on the C stack, so fn runs
// inline. Exceptions thrown by fn are rethrown.
static void RunGuarded(const std::function<void()> &fn);
// "PCRE2" or "std::regex".
static const char *EngineName();
// The longest line search-as-you-type and match highlighting run the
// regex over (per keystroke/frame); std::regex can be quadratic in line
// length with no way to interrupt it, PCRE2 is bounded by its limit.
static std::size_t IncrementalLineLimit();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace kte
+52
View File
@@ -0,0 +1,52 @@
#include "RegexGuard.h"
#include <exception>
#include <pthread.h>
namespace kte {
namespace {
// 1 GiB supports matches spanning several million characters.
constexpr std::size_t kLargeStackBytes = std::size_t{1} << 30;
struct Job {
const std::function<void()> *fn;
std::exception_ptr error;
};
void *
run_job(void *arg)
{
auto *job = static_cast<Job *>(arg);
try {
(*job->fn)();
} catch (...) {
job->error = std::current_exception();
}
return nullptr;
}
} // namespace
void
RunWithLargeStack(const std::function<void()> &fn)
{
Job job{&fn, nullptr};
pthread_attr_t attr;
bool started = false;
pthread_t tid{};
if (pthread_attr_init(&attr) == 0) {
if (pthread_attr_setstacksize(&attr, kLargeStackBytes) == 0)
started = pthread_create(&tid, &attr, run_job, &job) == 0;
pthread_attr_destroy(&attr);
}
if (!started) {
fn();
return;
}
pthread_join(tid, nullptr);
if (job.error)
std::rethrow_exception(job.error);
}
} // namespace kte
+18
View File
@@ -0,0 +1,18 @@
// RegexGuard.h - keep std::regex from overflowing the stack
//
// libstdc++'s std::regex matcher recurses roughly once per character of a
// match, so a pattern such as ".*" or "a+" on a line of a few tens of
// thousands of characters (minified JS/JSON) overflows a default 8 MiB
// stack and crashes the editor.
#pragma once
#include <functional>
namespace kte {
// Run fn to completion on a thread with a large stack (address space is
// reserved, pages are committed only as used), so regex work over long lines
// cannot overflow. If such a thread cannot be created, fn runs on the calling
// thread. Exceptions thrown by fn are rethrown to the caller.
void RunWithLargeStack(const std::function<void()> &fn);
} // namespace kte
+45
View File
@@ -0,0 +1,45 @@
#include "SearchHighlight.h"
#include <algorithm>
#include "Editor.h"
namespace kte {
SearchHighlight::SearchHighlight(const Editor &ed)
{
active_ = ed.SearchActive() && !ed.SearchQuery().empty();
if (!active_)
return;
query_ = ed.SearchQuery();
regex_ = ed.PromptActive() && (ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind);
if (regex_) {
std::string err;
rx_valid_ = rx_.Compile(query_, err);
}
}
void
SearchHighlight::Ranges(std::string_view line, std::vector<std::pair<std::size_t, std::size_t> > &out) const
{
out.clear();
if (!active_)
return;
if (regex_) {
if (!rx_valid_ || line.size() > Regex::IncrementalLineLimit())
return;
std::size_t from = 0, pos = 0, len = 0;
while (from <= line.size() && rx_.Search(line, from, pos, len)) {
out.emplace_back(pos, pos + len);
from = pos + std::max<std::size_t>(len, 1);
}
return;
}
std::size_t pos = 0;
while ((pos = line.find(query_, pos)) != std::string_view::npos) {
out.emplace_back(pos, pos + query_.size());
pos += query_.size();
}
}
} // namespace kte
+44
View File
@@ -0,0 +1,44 @@
/*
* SearchHighlight.h - the search-match ranges every renderer highlights.
*
* Built once per frame from the editor's search state (the regex, when the
* regex search or regex replace prompt is up, is compiled here once rather
* than per line), then asked for each visible line's matches.
*/
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include "RegexEngine.h"
class Editor;
namespace kte {
class SearchHighlight {
public:
explicit SearchHighlight(const Editor &ed);
// Whether a search is up with a non-empty query.
[[nodiscard]] bool Active() const
{
return active_;
}
// The matches in one line as [start, end) byte ranges, ordered and not
// overlapping. Nothing for an invalid pattern (the status line shows the
// error) or, in regex mode, a line longer than
// Regex::IncrementalLineLimit().
void Ranges(std::string_view line, std::vector<std::pair<std::size_t, std::size_t> > &out) const;
private:
bool active_ = false;
bool regex_ = false;
bool rx_valid_ = false;
std::string query_;
Regex rx_;
};
} // namespace kte
+1027 -221
View File
File diff suppressed because it is too large Load Diff
+149 -9
View File
@@ -10,10 +10,12 @@
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <condition_variable> #include <condition_variable>
#include <deque>
#include <thread> #include <thread>
#include <atomic> #include <atomic>
#include "SwapRecorder.h" #include "SwapRecorder.h"
#include "ErrorRecovery.h"
class Buffer; class Buffer;
@@ -22,9 +24,17 @@ namespace kte {
enum class SwapRecType : std::uint8_t { enum class SwapRecType : std::uint8_t {
INS = 1, INS = 1,
DEL = 2, DEL = 2,
// SPLIT and JOIN are no longer written; replay still reads them.
SPLIT = 3, SPLIT = 3,
JOIN = 4, JOIN = 4,
META = 0xF0, META = 0xF0,
// A checkpoint too large for one record (a record's length field is 24
// bits) is written as BEGIN [encver u8][total u64][crc32 u32], DATA
// chunks (raw bytes), END [encver u8]. Replay applies it only at END,
// with the size and CRC-32 matching; an unfinished one is ignored.
CHKPT_BEGIN = 0xFB,
CHKPT_DATA = 0xFC,
CHKPT_END = 0xFD,
CHKPT = 0xFE, CHKPT = 0xFE,
}; };
@@ -78,6 +88,19 @@ public:
// This is best-effort and asynchronous; call Flush() if you need it written before continuing. // This is best-effort and asynchronous; call Flush() if you need it written before continuing.
void Checkpoint(Buffer *buf = nullptr); void Checkpoint(Buffer *buf = nullptr);
// Main thread, called once per frame: request the recovery checkpoint of
// any journal left with a gap (a lost record) whose last attempt is more
// than the retry interval ago. Otherwise that checkpoint is only
// requested by the next edit, and a journal that lost a record just
// before the user stopped typing would stay behind indefinitely.
void RetryGapCheckpoints();
// Main thread: the oldest pending message for the user (a journal that
// another kte holds, or that fell behind because writes failed), or ""
// when there is none. Such failures otherwise went only to the error
// log, leaving crash recovery silently off or incomplete.
std::string TakeUserNotice();
void SetConfig(const SwapConfig &cfg) void SetConfig(const SwapConfig &cfg)
{ {
@@ -92,6 +115,15 @@ public:
// Detach(buf) or SwapManager destruction. // Detach(buf) or SwapManager destruction.
SwapRecorder *RecorderFor(Buffer *buf); SwapRecorder *RecorderFor(Buffer *buf);
// Re-key an attached buffer's journal/recorder entries after its Buffer object
// has moved to a new address (e.g. std::vector<Buffer> reallocation/erase-shift).
// Callers must ensure no swap records for old_addr are in flight (see Flush())
// before calling this, and must not call it with an address that isn't
// currently attached. Returns the recorder for new_addr (nullptr if old_addr
// wasn't attached); the caller is responsible for calling
// new_buf->SetSwapRecorder() with the result.
SwapRecorder *Rehome(Buffer *old_addr, Buffer *new_addr);
// Notify that the buffer's filename changed (e.g., SaveAs) // Notify that the buffer's filename changed (e.g., SaveAs)
void NotifyFilenameChanged(Buffer &buf); void NotifyFilenameChanged(Buffer &buf);
@@ -100,15 +132,34 @@ public:
// On failure (corrupt/truncated/invalid), the buffer is left in whatever // On failure (corrupt/truncated/invalid), the buffer is left in whatever
// state results from applying records up to the failure point; callers should // state results from applying records up to the failure point; callers should
// treat this as a recovery failure and surface `err`. // treat this as a recovery failure and surface `err`.
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err); //
// A record cut short at end of file (a crash mid-append) ends the replay
// successfully; err then describes the incomplete record. If valid_bytes
// is given, it receives the length of the journal's valid prefix, so a
// caller that keeps using the journal can truncate the torn tail first.
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err,
std::uint64_t *valid_bytes = nullptr);
// Compute the swap path for a file-backed buffer by filename. // Compute the swap path for a file-backed buffer by filename.
// Returns empty string if filename is empty. // Returns empty string if filename is empty.
static std::string ComputeSwapPathForFilename(const std::string &filename); static std::string ComputeSwapPathForFilename(const std::string &filename);
// True if another process currently holds the journal at swap_path (it
// is live, not left by a crash): it must be neither replayed nor removed.
static bool JournalInUse(const std::string &swap_path);
// False if the journal records which version of `file_path` it applies
// to and the file on disk no longer matches (edited or replaced since
// the journal started); replaying it would apply edits to the wrong text.
// Journals without that record (older kte) are assumed to match.
static bool JournalMatchesFile(const std::string &swap_path, const std::string &file_path);
// Test-only hook to keep swap path logic centralized. // Test-only hook to keep swap path logic centralized.
// (Avoid duplicating naming rules in unit tests.) // (Avoid duplicating naming rules in unit tests.)
#ifdef KTE_TESTS #ifdef KTE_TESTS
// Base files larger than this get their CRC on the writer thread.
static void SetSyncCrcLimitForTests(std::uint64_t bytes);
static std::string ComputeSwapPathForTests(const Buffer &buf) static std::string ComputeSwapPathForTests(const Buffer &buf)
{ {
return ComputeSidecarPath(buf); return ComputeSidecarPath(buf);
@@ -131,6 +182,20 @@ public:
// Per-buffer toggle // Per-buffer toggle
void SetSuspended(Buffer &buf, bool on); void SetSuspended(Buffer &buf, bool on);
// Error reporting for background thread
struct SwapError {
std::uint64_t timestamp_ns{0};
std::string message;
std::string buffer_name; // filename or "<unnamed>"
};
// Query error state (thread-safe)
bool HasErrors() const;
std::string GetLastError() const;
std::size_t GetErrorCount() const;
private: private:
class BufferRecorder final : public SwapRecorder { class BufferRecorder final : public SwapRecorder {
public: public:
@@ -149,10 +214,6 @@ private:
void RecordDelete(Buffer &buf, int row, int col, std::size_t len); void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
void RecordSplit(Buffer &buf, int row, int col);
void RecordJoin(Buffer &buf, int row);
void RecordCheckpoint(Buffer &buf, bool urgent_flush); void RecordCheckpoint(Buffer &buf, bool urgent_flush);
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes); void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
@@ -167,16 +228,53 @@ private:
std::uint64_t last_chkpt_ns{0}; std::uint64_t last_chkpt_ns{0};
std::uint64_t edit_bytes_since_chkpt{0}; std::uint64_t edit_bytes_since_chkpt{0};
std::uint64_t approx_size_bytes{0}; std::uint64_t approx_size_bytes{0};
// A record was lost (write failure, breaker open, oversize). Records are
// position-based, so later ones no longer apply to the journal's state:
// drop them until a checkpoint re-establishes the full content.
bool gap{false};
bool gap_notified{false}; // the user was told about the current gap
bool chkpt_queued{false}; // a checkpoint is queued and not yet being written
std::uint32_t header_version{0}; // of the open journal file (0: none yet)
bool lockout_notified{false}; // told about the lock; kept across ResetJournal
std::uint64_t gap_chkpt_request_ns{0};
bool gap_unfixable_reported{false}; // gap on a buffer too large to checkpoint
// Another kte process holds this journal's lock: do not write to it.
bool locked_out{false};
// Identity of the file the journal's records apply to (captured when
// the journal starts) and written into its header, so recovery can
// tell whether the file changed since.
bool has_base{false};
std::uint64_t base_size{0};
std::int64_t base_mtime_ns{0};
bool has_base_crc{false}; // CRC-32 of the base file's content
std::uint32_t base_crc{0};
// A large base file's CRC is computed by the writer thread before it
// creates the journal (see process_one), not on open/save.
std::string base_crc_file;
}; };
struct Pending { struct Pending {
Buffer *buf{nullptr}; Buffer *buf{nullptr};
SwapRecType type{SwapRecType::INS}; SwapRecType type{SwapRecType::INS};
std::vector<std::uint8_t> payload; // framed payload only std::vector<std::uint8_t> payload; // framed payload only
std::string chkpt; // CHKPT: the buffer's content (framed by the writer)
bool urgent_flush{false}; bool urgent_flush{false};
std::uint64_t seq{0}; std::uint64_t seq{0};
}; };
// A record (or, for a large checkpoint, several) as segments to write:
// small framing buffers owned here, and a reference to the checkpoint
// content (not copied).
struct RecordFrames {
std::deque<std::vector<std::uint8_t> > owned;
std::vector<std::pair<const std::uint8_t *, std::size_t> > segs;
std::size_t total = 0;
};
static void frame_pending(const Pending &p, RecordFrames &f);
static bool write_frames(int fd, const RecordFrames &f);
// Helpers // Helpers
static std::string ComputeSidecarPath(const Buffer &buf); static std::string ComputeSidecarPath(const Buffer &buf);
@@ -188,16 +286,39 @@ private:
static std::string SwapDirRoot(); static std::string SwapDirRoot();
static bool write_header(int fd); static bool write_header(int fd, const JournalCtx &ctx);
static bool open_ctx(JournalCtx &ctx, const std::string &path); static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err, bool *locked_out = nullptr);
static void close_ctx(JournalCtx &ctx); static void close_ctx(JournalCtx &ctx);
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record); static bool compact_to_checkpoint(JournalCtx &ctx, const RecordFrames &chkpt_record,
std::string &err);
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0); static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
// CRC-32 of a file's content; false if it cannot be read.
static bool file_crc32(const std::string &path, std::uint32_t &out);
// Identity (size, mtime, content CRC) of the file a journal's records
// apply to. Computed without holding mtx_ (it reads the file), then
// stored into the JournalCtx under it.
struct BaseId {
bool has{false};
std::uint64_t size{0};
std::int64_t mtime_ns{0};
bool has_crc{false};
std::uint32_t crc{0};
std::string crc_file; // set when the CRC is deferred to the writer thread
};
static BaseId compute_base(const std::string &file);
// Writer thread: compute a deferred base CRC (see BaseId::crc_file).
void complete_base_crc(JournalCtx &ctx);
static void apply_base(JournalCtx &ctx, const BaseId &id);
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v); static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
static void put_le64(std::uint8_t dst[8], std::uint64_t v); static void put_le64(std::uint8_t dst[8], std::uint64_t v);
@@ -210,11 +331,19 @@ private:
void process_one(const Pending &p); void process_one(const Pending &p);
// Error reporting helper (called from writer thread)
void report_error(const std::string &message, Buffer *buf = nullptr);
// State // State
SwapConfig cfg_{}; SwapConfig cfg_{};
std::unordered_map<Buffer *, JournalCtx> journals_; std::unordered_map<Buffer *, JournalCtx> journals_;
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_; std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
std::mutex mtx_; mutable std::mutex mtx_;
// Serialises closing journal fds with the writer's periodic fsync, so
// neither can act on an fd the other has closed (or that was reused).
// Taken before mtx_ when both are needed; edits (which take mtx_) never
// wait for an fsync.
mutable std::mutex io_mtx_;
std::condition_variable cv_; std::condition_variable cv_;
std::vector<Pending> queue_; std::vector<Pending> queue_;
std::uint64_t next_seq_{0}; std::uint64_t next_seq_{0};
@@ -222,5 +351,16 @@ private:
std::uint64_t inflight_{0}; std::uint64_t inflight_{0};
std::atomic<bool> running_{false}; std::atomic<bool> running_{false};
std::thread worker_; std::thread worker_;
// Error tracking (protected by mtx_)
std::deque<SwapError> errors_; // bounded to max 100 entries
std::deque<std::string> user_notices_; // see TakeUserNotice(); guarded by mtx_
// Queue a message for the user (mtx_ must be held).
void notify_user_locked_(std::string msg);
std::size_t total_error_count_{0};
// Circuit breaker for swap operations (protected by mtx_)
CircuitBreaker circuit_breaker_;
}; };
} // namespace kte } // namespace kte
+89
View File
@@ -0,0 +1,89 @@
#include "SyscallWrappers.h"
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <cerrno>
#include <cstdlib>
namespace kte {
namespace syscall {
int
Open(const char *path, int flags, mode_t mode)
{
int fd;
do {
fd = ::open(path, flags, mode);
} while (fd == -1 && errno == EINTR);
return fd;
}
int
Close(int fd)
{
// Do not retry on EINTR: on Linux (and most systems) the descriptor is
// released even when close() is interrupted, so a retry could close an
// fd that another thread (e.g. the swap writer) has just opened.
const int ret = ::close(fd);
if (ret == -1 && errno == EINTR)
return 0;
return ret;
}
int
Fsync(int fd)
{
int ret;
do {
ret = ::fsync(fd);
} while (ret == -1 && errno == EINTR);
return ret;
}
int
Fstat(int fd, struct stat *buf)
{
int ret;
do {
ret = ::fstat(fd, buf);
} while (ret == -1 && errno == EINTR);
return ret;
}
int
Ftruncate(int fd, off_t length)
{
int ret;
do {
ret = ::ftruncate(fd, length);
} while (ret == -1 && errno == EINTR);
return ret;
}
int
Fchmod(int fd, mode_t mode)
{
int ret;
do {
ret = ::fchmod(fd, mode);
} while (ret == -1 && errno == EINTR);
return ret;
}
int
Mkstemp(char *template_str)
{
int fd;
do {
fd = ::mkstemp(template_str);
} while (fd == -1 && errno == EINTR);
return fd;
}
} // namespace syscall
} // namespace kte
+51
View File
@@ -0,0 +1,51 @@
// SyscallWrappers.h - EINTR-safe syscall wrappers for kte
#pragma once
#include <string>
#include <cstddef>
#include <sys/stat.h>
namespace kte {
namespace syscall {
// EINTR-safe wrapper for open(2).
// Returns file descriptor on success, -1 on failure (errno set).
// Automatically retries on EINTR.
int Open(const char *path, int flags, mode_t mode = 0);
// Wrapper for close(2).
// Returns 0 on success, -1 on failure (errno set).
// EINTR is treated as success and never retried: the descriptor has already
// been released on Linux, and retrying could close an unrelated, reused fd.
int Close(int fd);
// EINTR-safe wrapper for fsync(2).
// Returns 0 on success, -1 on failure (errno set).
// Automatically retries on EINTR.
int Fsync(int fd);
// EINTR-safe wrapper for fstat(2).
// Returns 0 on success, -1 on failure (errno set).
// Automatically retries on EINTR.
int Fstat(int fd, struct stat *buf);
// EINTR-safe wrapper for fchmod(2).
// Returns 0 on success, -1 on failure (errno set).
// Automatically retries on EINTR.
int Fchmod(int fd, mode_t mode);
// EINTR-safe wrapper for ftruncate(2).
// Returns 0 on success, -1 on failure (errno set).
// Automatically retries on EINTR.
int Ftruncate(int fd, off_t length);
// EINTR-safe wrapper for mkstemp(3).
// Returns file descriptor on success, -1 on failure (errno set).
// Automatically retries on EINTR.
// Note: template_str must be a mutable buffer ending in "XXXXXX".
int Mkstemp(char *template_str);
// Note: rename(2) and unlink(2) are not wrapped because they operate on
// filesystem metadata and typically complete atomically without EINTR.
// If interrupted, they either succeed or fail without partial state.
} // namespace syscall
} // namespace kte
+72
View File
@@ -0,0 +1,72 @@
// TermWidth.h - terminal cell width of a decoded character
//
// Shared by TerminalRenderer (drawing and cursor placement) and the command
// layer (horizontal scrolling, mouse column mapping) so they always agree.
// Tabs are handled by the callers, which know the current column.
#pragma once
#include <cstddef>
#include <cwchar>
#include <string_view>
namespace kte {
// Tab stops every kTabWidth columns, in every frontend and in the command
// layer's horizontal scrolling (they must agree, or the cursor and the
// scroll offset are misplaced on lines with tabs).
constexpr std::size_t kTabWidth = 8;
// C0 controls and DEL are drawn as caret notation ("^M", "^?"): two cells.
// ncurses would otherwise act on them (carriage return, backspace) and
// corrupt the line.
inline bool
IsCaretControl(wchar_t wch)
{
return (wch >= 0 && wch < 0x20) || wch == 0x7f;
}
// C1 controls (U+0080..U+009F, also what a stray byte in that range decodes
// to) are drawn as '?': sent raw, U+009B is a terminal CSI.
inline bool
IsC1Control(wchar_t wch)
{
return wch >= 0x80 && wch <= 0x9f;
}
inline int
CellWidth(wchar_t wch)
{
if (IsCaretControl(wch))
return 2;
if (IsC1Control(wch))
return 1;
const int w = wcwidth(wch);
return w < 0 ? 1 : w;
}
// Decode the character at line[i]: its byte length and display width in
// cells (a tab reaches the next multiple of tabw from column col). Measures
// the way TerminalRenderer draws: an invalid byte or NUL is one byte, measured
// as the byte value.
inline void
MeasureChar(std::string_view line, std::size_t i, std::size_t col, std::size_t &len, std::size_t &width,
std::size_t tabw = kTabWidth)
{
std::mbstate_t state{};
wchar_t wch = 0;
const auto rc = std::mbrtowc(&wch, line.data() + i, line.size() - i, &state);
if (rc == static_cast<std::size_t>(-1) || rc == static_cast<std::size_t>(-2) || rc == 0) {
wch = static_cast<unsigned char>(line[i]);
len = 1;
} else {
len = rc;
}
if (wch == L'\t')
width = tabw - (col % tabw);
else
width = static_cast<std::size_t>(CellWidth(wch));
}
} // namespace kte
+51 -5
View File
@@ -1,3 +1,6 @@
#include <cstdio>
#include <ctime>
#include <string>
#include <ncurses.h> #include <ncurses.h>
#include <termios.h> #include <termios.h>
#include <unistd.h> #include <unistd.h>
@@ -12,6 +15,12 @@ TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc; (void) argc;
(void) argv; (void) argv;
// Keys are read from stdin. Piped input would be executed as editor
// commands (and after EOF the loop spun at full CPU with no way to quit).
if (!isatty(STDIN_FILENO)) {
std::fprintf(stderr, "kte: standard input is not a terminal\n");
return false;
}
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS) // Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
{ {
struct termios tio{}; struct termios tio{};
@@ -43,7 +52,9 @@ TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
// Enable 8-bit meta key sequences (Alt/ESC-prefix handling in terminals) // Enable 8-bit meta key sequences (Alt/ESC-prefix handling in terminals)
meta(stdscr, TRUE); meta(stdscr, TRUE);
// Make ESC key sequences resolve quickly so ESC+<key> works as meta // Make ESC key sequences resolve quickly so ESC+<key> works as meta
#ifdef set_escdelay // set_escdelay is an ncurses extension function (not a macro), so test for
// ncurses itself rather than `#ifdef set_escdelay`, which is always false.
#if defined(NCURSES_VERSION)
set_escdelay(TerminalFrontend::kEscDelayMs); set_escdelay(TerminalFrontend::kEscDelayMs);
#endif #endif
// Make getch() block briefly instead of busy-looping; reduces CPU when idle // Make getch() block briefly instead of busy-looping; reduces CPU when idle
@@ -86,29 +97,64 @@ TerminalFrontend::Step(Editor &ed, bool &running)
// Handle resize and keep editor dimensions synced // Handle resize and keep editor dimensions synced
int r, c; int r, c;
getmaxyx(stdscr, r, c); getmaxyx(stdscr, r, c);
bool changed = first_frame_;
if (r != prev_r_ || c != prev_c_) { if (r != prev_r_ || c != prev_c_) {
resizeterm(r, c); resizeterm(r, c);
clear(); clear();
prev_r_ = r; prev_r_ = r;
prev_c_ = c; prev_c_ = c;
changed = true;
} }
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c)); ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
// Allow deferred opens (including swap recovery prompts) to run. // Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens(); // Its return value only reports an opened buffer; a deferred open that
// starts a prompt or sets a status must be drawn too.
{
const bool prompt_before = ed.PromptActive();
const std::string status_before = ed.Status();
const std::time_t status_time_before = ed.StatusTime();
if (ed.ProcessPendingOpens())
changed = true;
if (ed.PromptActive() != prompt_before || ed.Status() != status_before ||
ed.StatusTime() != status_time_before)
changed = true;
}
// Handle all pending input before drawing: one key per frame made a
// pasted block take a frame (and a full redraw) per character. The first
// read waits up to the configured timeout; the rest do not wait.
MappedInput mi; MappedInput mi;
if (input_.Poll(mi)) { int handled = 0;
if (mi.hasCommand) { while (handled < 4096 && input_.PollKey(mi)) {
changed = true;
if (mi.hasCommand)
Execute(ed, mi.id, mi.arg, mi.count); Execute(ed, mi.id, mi.arg, mi.count);
if (ed.QuitRequested())
break;
if (++handled == 1)
timeout(0);
} }
} if (handled > 0)
timeout(16);
if (ed.QuitRequested()) { if (ed.QuitRequested()) {
running = false; running = false;
} }
// Redraw after input or a resize, and otherwise about once a second
// (status messages, deferred work): redrawing every 16 ms kept a core
// busy on large or long-line files while idle.
const auto now = std::chrono::steady_clock::now();
if (changed || now - last_draw_ >= std::chrono::milliseconds(1000)) {
try {
renderer_.Draw(ed); renderer_.Draw(ed);
} catch (const std::exception &e) {
ed.SetStatus(std::string("Display error: ") + e.what());
}
last_draw_ = now;
first_frame_ = false;
}
} }
+4
View File
@@ -2,6 +2,7 @@
* TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle * TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle
*/ */
#pragma once #pragma once
#include <chrono>
#include <termios.h> #include <termios.h>
#include <signal.h> #include <signal.h>
@@ -32,6 +33,9 @@ private:
TerminalRenderer renderer_{}; TerminalRenderer renderer_{};
int prev_r_ = 0; int prev_r_ = 0;
int prev_c_ = 0; int prev_c_ = 0;
// Redraw only when something may have changed (see Step).
bool first_frame_ = true;
std::chrono::steady_clock::time_point last_draw_{};
// Saved terminal attributes to restore on shutdown // Saved terminal attributes to restore on shutdown
bool have_orig_tio_ = false; bool have_orig_tio_ = false;
struct termios orig_tio_{}; struct termios orig_tio_{};
+168 -41
View File
@@ -1,4 +1,6 @@
#include <cstdio> #include <cstdio>
#include <climits>
#include <cwchar>
#include <ncurses.h> #include <ncurses.h>
#include "TerminalInputHandler.h" #include "TerminalInputHandler.h"
@@ -12,6 +14,22 @@ CTRL(char c)
{ {
return c & 0x1F; 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;
@@ -21,6 +39,7 @@ TerminalInputHandler::~TerminalInputHandler() = default;
static bool static bool
map_key_to_command(const int ch, map_key_to_command(const int ch,
const bool is_keycode,
bool &k_prefix, bool &k_prefix,
bool &esc_meta, bool &esc_meta,
bool &k_ctrl_pending, bool &k_ctrl_pending,
@@ -28,9 +47,22 @@ map_key_to_command(const int ch,
Editor *ed, Editor *ed,
MappedInput &out) MappedInput &out)
{ {
// Handle special keys from ncurses // 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). // These keys exit k-prefix mode if active (user pressed C-k then a special key).
switch (ch) { //
// ESC marks the next key as Meta. Consume that flag on every other key up
// front: keys handled before the Meta lookup below (arrows, mouse, Enter,
// control chords) used to leave it set, turning a later plain key into a
// Meta command (ESC, Left, d => delete word).
const bool meta = esc_meta && ch != 27;
if (ch != 27)
esc_meta = false;
switch (is_keycode ? ch : -1) {
case KEY_ENTER: case KEY_ENTER:
// Some terminals send KEY_ENTER distinct from '\n'/'\r' // Some terminals send KEY_ENTER distinct from '\n'/'\r'
k_prefix = false; k_prefix = false;
@@ -67,13 +99,20 @@ map_key_to_command(const int ch,
if (pressed) { if (pressed) {
mouse_selecting = true; mouse_selecting = true;
Execute(*ed, CommandId::MoveCursorTo, std::string(buf)); Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
if (Buffer *b = ed->CurrentBuffer()) { // We don't set the mark on simple click anymore in ncurses either,
b->SetMark(b->Curx(), b->Cury()); // 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; out.hasCommand = false;
return true; return true;
} }
if (mouse_selecting && moved) { 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)); Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
out.hasCommand = false; out.hasCommand = false;
return true; return true;
@@ -135,9 +174,10 @@ map_key_to_command(const int ch,
out = {true, CommandId::DeleteChar, "", 0}; out = {true, CommandId::DeleteChar, "", 0};
return true; return true;
case KEY_RESIZE: case KEY_RESIZE:
k_prefix = false; // TerminalFrontend::Step picks up the new size before the next draw.
k_ctrl_pending = false; // Refresh is the C-g cancel handler, so issuing it here would clear the
out = {true, CommandId::Refresh, "", 0}; // mark and cancel open prompts on every window resize.
out.hasCommand = false;
return true; return true;
default: default:
break; break;
@@ -153,6 +193,37 @@ map_key_to_command(const int ch,
return true; return true;
} }
// Meta (ESC-prefixed) keys: backspace, Enter and printable keys use the
// ESC keymap. Checked before control chords so ESC ^H (meta-backspace) is
// not taken as C-h, and before Enter so ESC Enter reaches SmartNewline.
// Other control chords after ESC act as themselves.
const bool is_backspace = (is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H');
const bool meta_key = meta && (is_backspace || (!is_keycode && (ch == '\n' || ch == '\r' || ch >= 0x20)));
if (meta_key) {
k_prefix = false;
k_ctrl_pending = false;
int ascii_key = ch;
if (is_backspace) {
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;
}
// Control keys // Control keys
if (ch == CTRL('K')) { if (ch == CTRL('K')) {
// C-k prefix // C-k prefix
@@ -247,34 +318,8 @@ map_key_to_command(const int ch,
out = {true, CommandId::Newline, "", 0}; out = {true, CommandId::Newline, "", 0};
return true; 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 (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 // Backspace in ncurses can be KEY_BACKSPACE or 127
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) { if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
k_prefix = false; k_prefix = false;
k_ctrl_pending = false; k_ctrl_pending = false;
out = {true, CommandId::Backspace, "", 0}; out = {true, CommandId::Backspace, "", 0};
@@ -290,11 +335,20 @@ map_key_to_command(const int ch,
return true; return true;
} }
// Printable ASCII // Printable character: ASCII, or (for a regular decoded wide character,
if (ch >= 0x20 && ch <= 0x7E) { // 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.hasCommand = true;
out.id = CommandId::InsertText; out.id = CommandId::InsertText;
out.arg.assign(1, static_cast<char>(ch)); out.arg = mb;
out.count = 0; out.count = 0;
return true; return true;
} }
@@ -304,15 +358,88 @@ map_key_to_command(const int ch,
} }
// Read one key: an ncurses KEY_* code, or a character decoded from UTF-8
// here rather than by get_wch(). With a zero timeout (the frontend reads the
// rest of a batch without waiting), get_wch() gave up on a character whose
// bytes had not all arrived yet, and ncurses then discarded the remaining
// bytes as an invalid sequence: text pasted over a slow link lost
// characters, and whatever followed them in the same read.
// Returns false when there is no input.
static bool
read_key(int &ch, bool &is_keycode)
{
const int c = getch();
if (c == ERR)
return false;
is_keycode = c > 0xFF;
ch = c;
if (is_keycode || c < 0x80)
return true;
// Lead byte of a multibyte character: how many continuation bytes follow.
int need;
char32_t cp;
if (c >= 0xC2 && c <= 0xDF) {
need = 1;
cp = static_cast<char32_t>(c & 0x1F);
} else if (c >= 0xE0 && c <= 0xEF) {
need = 2;
cp = static_cast<char32_t>(c & 0x0F);
} else if (c >= 0xF0 && c <= 0xF4) {
need = 3;
cp = static_cast<char32_t>(c & 0x07);
} else {
ch = -1; // stray continuation or invalid byte: ignore it
return true;
}
// The rest of the character is on its way; wait for it (restoring the
// caller's timeout), but not long if it never comes: terminals that send
// Alt as a raw 8-bit byte (rxvt meta8) produce lone lead bytes, and
// every such key would otherwise stall input.
const int saved_delay = wgetdelay(stdscr);
timeout(250);
for (int i = 0; i < need; ++i) {
const int b = getch();
if (b == ERR || b > 0xFF || (b & 0xC0) != 0x80) {
if (b != ERR)
ungetch(b); // not part of this character; read it next
ch = -1;
timeout(saved_delay);
return true;
}
cp = (cp << 6) | static_cast<char32_t>(b & 0x3F);
}
timeout(saved_delay);
// Reject overlong forms and surrogates.
if ((need == 2 && cp < 0x800) || (need == 3 && cp < 0x10000) || (cp >= 0xD800 && cp <= 0xDFFF) ||
cp > 0x10FFFF) {
ch = -1;
return true;
}
ch = static_cast<int>(cp);
return true;
}
bool bool
TerminalInputHandler::decode_(MappedInput &out) TerminalInputHandler::decode_(MappedInput &out)
{ {
int ch = getch(); int ch = 0;
if (ch == ERR) { bool is_keycode = false;
if (!read_key(ch, is_keycode))
return false; // no input return false; // no input
if (ch < 0) {
// Invalid byte sequence, dropped. It still ends a pending ESC or
// C-k prefix, as any other key would; left set, it applied to the
// next key instead.
esc_meta_ = false;
k_prefix_ = false;
k_ctrl_pending_ = false;
out.hasCommand = false;
return true;
} }
bool consumed = map_key_to_command( bool consumed = map_key_to_command(
ch, ch, is_keycode,
k_prefix_, esc_meta_, k_prefix_, esc_meta_,
k_ctrl_pending_, k_ctrl_pending_,
mouse_selecting_, mouse_selecting_,
+9
View File
@@ -20,6 +20,15 @@ public:
bool Poll(MappedInput &out) override; bool Poll(MappedInput &out) override;
// Read one key if available. Returns false when no input was read; true
// when a key was consumed (out.hasCommand says whether it produced a
// command: prefixes such as C-k or ESC do not).
bool PollKey(MappedInput &out)
{
out = {};
return decode_(out);
}
private: private:
bool decode_(MappedInput &out); bool decode_(MappedInput &out);
+96 -91
View File
@@ -6,10 +6,11 @@
#include <filesystem> #include <filesystem>
#include <cstdlib> #include <cstdlib>
#include <ncurses.h> #include <ncurses.h>
#include <regex>
#include <string> #include <string>
#include "TerminalRenderer.h" #include "TerminalRenderer.h"
#include "SearchHighlight.h"
#include "TermWidth.h"
#include "Buffer.h" #include "Buffer.h"
#include "Editor.h" #include "Editor.h"
#include "Highlight.h" #include "Highlight.h"
@@ -42,11 +43,13 @@ TerminalRenderer::Draw(Editor &ed)
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
if (buf) { if (buf) {
const auto &lines = buf->Rows(); // Per-row access only: Rows() rebuilds a string for every line of the
// file after each edit, which made every frame O(file size).
const std::size_t nlines = buf->Nrows();
std::size_t rowoffs = buf->Rowoffs(); std::size_t rowoffs = buf->Rowoffs();
std::size_t coloffs = buf->Coloffs(); std::size_t coloffs = buf->Coloffs();
const int tabw = 8; const int tabw = static_cast<int>(kte::kTabWidth);
// Phase 3: prefetch visible viewport highlights (current terminal area) // Phase 3: prefetch visible viewport highlights (current terminal area)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) { if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
int fr = static_cast<int>(rowoffs); int fr = static_cast<int>(rowoffs);
@@ -54,41 +57,18 @@ TerminalRenderer::Draw(Editor &ed)
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version()); buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
} }
// Search matches: the pattern is compiled once per frame.
const kte::SearchHighlight search_hl(ed);
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
for (int r = 0; r < content_rows; ++r) { for (int r = 0; r < content_rows; ++r) {
move(r, 0); move(r, 0);
std::size_t li = rowoffs + static_cast<std::size_t>(r); std::size_t li = rowoffs + static_cast<std::size_t>(r);
std::size_t render_col = 0; std::size_t render_col = 0;
std::size_t src_i = 0; std::size_t src_i = 0;
// Compute matches for this line if search highlighting is active // Per-row copy: GetLineView() would materialize the whole buffer
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty(); // after every edit.
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end) const std::string line = li < nlines ? buf->GetLineString(li) : std::string();
if (search_mode && li < lines.size()) { search_hl.Ranges(line, ranges);
std::string sline = static_cast<std::string>(lines[li]);
// If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges
if (ed.PromptActive() && (
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
try {
std::regex rx(ed.SearchQuery());
for (auto it = std::sregex_iterator(sline.begin(), sline.end(), rx);
it != std::sregex_iterator(); ++it) {
const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.position());
std::size_t ex = sx + static_cast<std::size_t>(m.length());
ranges.emplace_back(sx, ex);
}
} catch (const std::regex_error &) {
// ignore invalid patterns here; status shows error
}
} else {
const std::string &q = ed.SearchQuery();
std::size_t pos = 0;
while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) {
ranges.emplace_back(pos, pos + q.size());
pos += q.size();
}
}
}
auto is_src_in_hl = [&](std::size_t si) -> bool { auto is_src_in_hl = [&](std::size_t si) -> bool {
if (ranges.empty()) if (ranges.empty())
return false; return false;
@@ -140,8 +120,7 @@ TerminalRenderer::Draw(Editor &ed)
return true; return true;
}; };
int written = 0; int written = 0;
if (li < lines.size()) { if (li < nlines) {
std::string line = static_cast<std::string>(lines[li]);
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey; const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
const std::size_t vsel_spot_src = vsel_on_line const std::size_t vsel_spot_src = vsel_on_line
? std::min(buf->Curx(), line.size()) ? std::min(buf->Curx(), line.size())
@@ -168,14 +147,11 @@ TerminalRenderer::Draw(Editor &ed)
wch_len = static_cast<int>(res); wch_len = static_cast<int>(res);
} }
if (wch == L'\t') { if (wch == L'\t') {
constexpr std::size_t tab_width = 8; constexpr std::size_t tab_width = kte::kTabWidth;
const std::size_t next_tab = tab_width - (rc % tab_width); const std::size_t next_tab = tab_width - (rc % tab_width);
rc += next_tab; rc += next_tab;
} else { } else {
int w = wcwidth(wch); rc += static_cast<std::size_t>(kte::CellWidth(wch));
if (w < 0)
w = 1;
rc += static_cast<std::size_t>(w);
} }
si += static_cast<std::size_t>(wch_len); si += static_cast<std::size_t>(wch_len);
} }
@@ -187,42 +163,24 @@ TerminalRenderer::Draw(Editor &ed)
std::vector<kte::HighlightSpan> sane_spans; std::vector<kte::HighlightSpan> sane_spans;
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()-> if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) { HasHighlighter()) {
kte::LineHighlight lh_val = buf->Highlighter()->GetLine( const kte::LineHighlight &lh_val = buf->Highlighter()->GetLine(
*buf, static_cast<int>(li), buf->Version()); *buf, static_cast<int>(li), buf->Version());
// Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties kte::SanitizeSpans(line, lh_val.spans, sane_spans);
const std::size_t line_len = line.size();
sane_spans.reserve(lh_val.spans.size());
for (const auto &sp: lh_val.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s),
std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
sane_spans.push_back(kte::HighlightSpan{
static_cast<int>(s), static_cast<int>(e), sp.kind
});
}
std::sort(sane_spans.begin(), sane_spans.end(),
[](const kte::HighlightSpan &a, const kte::HighlightSpan &b) {
return a.col_start < b.col_start;
});
} }
// Called left to right while drawing: walk the spans with a
// cursor (rescanning from the first span for every byte was
// quadratic on long highlighted lines).
std::size_t span_i = 0;
int last_token_si = -1;
auto token_at = [&](std::size_t src_index) -> kte::TokenKind { auto token_at = [&](std::size_t src_index) -> kte::TokenKind {
if (sane_spans.empty()) const int si = static_cast<int>(src_index);
return kte::TokenKind::Default; if (si < last_token_si)
int si = static_cast<int>(src_index); span_i = 0; // asked about an earlier byte: restart
for (const auto &sp: sane_spans) { last_token_si = si;
if (si < sp.col_start) while (span_i < sane_spans.size() && sane_spans[span_i].col_end <= si)
break; ++span_i;
if (si >= sp.col_start && si < sp.col_end) if (span_i < sane_spans.size() && sane_spans[span_i].col_start <= si)
return sp.kind; return sane_spans[span_i].kind;
}
return kte::TokenKind::Default; return kte::TokenKind::Default;
}; };
auto token_attr = [&](kte::TokenKind k) -> attr_t { auto token_attr = [&](kte::TokenKind k) -> attr_t {
@@ -242,6 +200,45 @@ TerminalRenderer::Draw(Editor &ed)
return 0; return 0;
} }
}; };
// Fast-forward to the scroll offset with the draw loop's own skip
// rules (a tab is skipped only if it ends at or before coloffs,
// any other character while it starts before coloffs), cached
// for the row's current content.
if (coloffs > 0 && src_i == 0) {
if (skip_cache_.size() < static_cast<std::size_t>(content_rows))
skip_cache_.resize(static_cast<std::size_t>(content_rows));
SkipCache &sc = skip_cache_[static_cast<std::size_t>(r)];
if (!(sc.valid && sc.buf_id == buf->Id() && sc.version == buf->Version() && sc.row == li &&
sc.coloffs == coloffs)) {
std::size_t s = 0, c = 0;
while (s < line.size()) {
std::mbstate_t st = std::mbstate_t();
wchar_t w = 0;
std::size_t n = std::mbrtowc(&w, &line[s], line.size() - s, &st);
if (n == (size_t) -1 || n == (size_t) -2) {
w = static_cast<unsigned char>(line[s]);
n = 1;
} else if (n == 0) {
w = L'\0';
n = 1;
}
if (w == L'\t') {
const std::size_t next_tab = tabw - (c % tabw);
if (c + next_tab > coloffs)
break;
c += next_tab;
} else {
if (c >= coloffs)
break;
c += static_cast<std::size_t>(kte::CellWidth(w));
}
s += n;
}
sc = SkipCache{buf->Id(), buf->Version(), li, coloffs, s, c, true};
}
src_i = sc.src;
render_col = sc.col;
}
while (written < cols) { while (written < cols) {
bool from_src = false; bool from_src = false;
wchar_t wch = L' '; wchar_t wch = L' ';
@@ -286,7 +283,7 @@ TerminalRenderer::Draw(Editor &ed)
vsel_on_line && !vsel_spot_is_eol && src_i == vsel_on_line && !vsel_spot_is_eol && src_i ==
vsel_spot_src; vsel_spot_src;
bool in_sel = in_mark || in_vsel; bool in_sel = in_mark || in_vsel;
bool in_hl = search_mode && is_src_in_hl(src_i); bool in_hl = search_hl.Active() && is_src_in_hl(src_i);
bool in_cur = bool in_cur =
has_current && li == cur_my && src_i >= cur_mx has_current && li == cur_my && src_i >= cur_mx
&& &&
@@ -310,10 +307,8 @@ TerminalRenderer::Draw(Editor &ed)
src_i += wch_len; src_i += wch_len;
continue; continue;
} else { } else {
// normal char // normal char (controls are drawn as ^X or '?')
disp_w = wcwidth(wch); disp_w = kte::CellWidth(wch);
if (disp_w < 0)
disp_w = 1; // non-printable or similar
if (render_col < coloffs) { if (render_col < coloffs) {
render_col += disp_w; render_col += disp_w;
@@ -345,7 +340,7 @@ TerminalRenderer::Draw(Editor &ed)
} }
} }
bool in_sel = in_mark || in_vsel; bool in_sel = in_mark || in_vsel;
bool in_hl = search_mode && from_src && is_src_in_hl(src_i); bool in_hl = search_hl.Active() && from_src && is_src_in_hl(src_i);
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx && bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
src_i < cur_mend; src_i < cur_mend;
attr_t a = A_NORMAL; attr_t a = A_NORMAL;
@@ -361,7 +356,12 @@ TerminalRenderer::Draw(Editor &ed)
} }
attrset(a); attrset(a);
if (from_src) { if (from_src && kte::IsCaretControl(wch)) {
addch('^');
addch(wch == 0x7f ? '?' : static_cast<chtype>(wch + 0x40));
} else if (from_src && kte::IsC1Control(wch)) {
addch('?');
} else if (from_src) {
cchar_t cch; cchar_t cch;
wchar_t warr[2] = {wch, L'\0'}; wchar_t warr[2] = {wch, L'\0'};
setcchar(&cch, warr, 0, 0, nullptr); setcchar(&cch, warr, 0, 0, nullptr);
@@ -389,8 +389,11 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t cx = buf->Curx(); std::size_t cx = buf->Curx();
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs()); int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
std::size_t rx_recomputed = 0; std::size_t rx_recomputed = 0;
if (cy < lines.size()) { if (cy < nlines && cursor_cache_.valid && cursor_cache_.buf_id == buf->Id() &&
const std::string line_for_cursor = static_cast<std::string>(lines[cy]); cursor_cache_.version == buf->Version() && cursor_cache_.row == cy && cursor_cache_.cx == cx) {
rx_recomputed = cursor_cache_.rx;
} else if (cy < nlines) {
const std::string line_for_cursor = buf->GetLineString(cy);
std::size_t src_i_cur = 0; std::size_t src_i_cur = 0;
std::size_t render_col_cur = 0; std::size_t render_col_cur = 0;
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) { while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
@@ -400,23 +403,25 @@ TerminalRenderer::Draw(Editor &ed)
&wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur, &wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur,
&state); &state);
// Same decoding and widths as the drawing loop above.
std::size_t len = res;
if (res == (size_t) -1 || res == (size_t) -2) { if (res == (size_t) -1 || res == (size_t) -2) {
render_col_cur += 1; wch = static_cast<unsigned char>(line_for_cursor[src_i_cur]);
src_i_cur += 1; len = 1;
} else if (res == 0) { } else if (res == 0) {
src_i_cur += 1; wch = L'\0';
} else { len = 1;
}
if (wch == L'\t') { if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col_cur % tabw); std::size_t next_tab = tabw - (render_col_cur % tabw);
render_col_cur += next_tab; render_col_cur += next_tab;
} else { } else {
int dw = wcwidth(wch); render_col_cur += static_cast<std::size_t>(kte::CellWidth(wch));
render_col_cur += (dw < 0) ? 1 : dw;
}
src_i_cur += res;
} }
src_i_cur += len;
} }
rx_recomputed = render_col_cur; rx_recomputed = render_col_cur;
cursor_cache_ = CursorCache{buf->Id(), buf->Version(), cy, cx, rx_recomputed, true};
} }
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs()); int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) { if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
@@ -541,7 +546,7 @@ TerminalRenderer::Draw(Editor &ed)
left += " [RO]"; left += " [RO]";
// Append total line count as "<n>L" // Append total line count as "<n>L"
if (b) { if (b) {
unsigned long lcount = static_cast<unsigned long>(b->Rows().size()); unsigned long lcount = static_cast<unsigned long>(b->Nrows());
left += " "; left += " ";
left += std::to_string(lcount); left += std::to_string(lcount);
left += "L"; left += "L";
+29
View File
@@ -2,6 +2,9 @@
* TerminalRenderer - ncurses-based renderer for terminal mode * TerminalRenderer - ncurses-based renderer for terminal mode
*/ */
#pragma once #pragma once
#include <vector>
#include <cstdint>
#include <cstddef>
#include "Renderer.h" #include "Renderer.h"
@@ -12,4 +15,30 @@ public:
~TerminalRenderer() override; ~TerminalRenderer() override;
void Draw(Editor &ed) override; void Draw(Editor &ed) override;
private:
// Where a row's visible part starts (the draw loop's skip up to the
// horizontal scroll offset), cached per screen row. Scanning a very long
// line from column 0 on every frame made each redraw O(line length).
struct SkipCache {
std::uint64_t buf_id = 0; // Buffer::Id(); addresses get reused
std::uint64_t version = 0;
std::size_t row = 0;
std::size_t coloffs = 0;
std::size_t src = 0; // byte offset where drawing resumes
std::size_t col = 0; // render column at that offset
bool valid = false;
};
std::vector<SkipCache> skip_cache_;
// Render column of the cursor, cached the same way.
struct CursorCache {
std::uint64_t buf_id = 0; // Buffer::Id(); addresses get reused
std::uint64_t version = 0;
std::size_t row = 0;
std::size_t cx = 0;
std::size_t rx = 0;
bool valid = false;
} cursor_cache_;
}; };
+16
View File
@@ -0,0 +1,16 @@
/*
* TextSpan.h - a reference to bytes held in a PieceTable's storage.
*
* PieceTable storage is append-only (edits add pieces; nothing rewrites
* stored bytes), so a span stays valid for the life of the table's storage.
* Undo records large texts as spans instead of copies of the bytes.
*/
#pragma once
#include <cstddef>
struct TextSpan {
bool add{false}; // false: original storage, true: add storage
std::size_t start{0};
std::size_t len{0};
};
+35
View File
@@ -1,6 +1,9 @@
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <vector>
#include "TextSpan.h"
enum class UndoType : std::uint8_t { enum class UndoType : std::uint8_t {
@@ -9,6 +12,13 @@ enum class UndoType : std::uint8_t {
Paste, Paste,
Newline, Newline,
DeleteRow, DeleteRow,
InsertRow,
// Inverse of Newline: forward = join_lines(row) [removes the newline at the
// end of `row`], backward = split_line(row, col) [recreates the original
// two lines]. Used by backspace-at-col-0 and delete-at-eol, whose forward
// action is a join, not a split — Newline's apply() semantics are the
// wrong direction for those.
JoinLines,
}; };
struct UndoNode { struct UndoNode {
@@ -17,7 +27,32 @@ struct UndoNode {
int col{}; int col{};
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
std::string text; std::string text;
// A large Insert/Paste/Delete text is held as references to the buffer's
// storage instead (text is then empty and span_len is its length): undo
// of a replace-all over a 100 MB file otherwise kept 200 MB of copies.
std::vector<TextSpan> spans;
std::size_t span_len = 0;
UndoNode *parent = nullptr; // previous state; null means pre-first-edit UndoNode *parent = nullptr; // previous state; null means pre-first-edit
UndoNode *child = nullptr; // next in current timeline UndoNode *child = nullptr; // next in current timeline
UndoNode *next = nullptr; // redo branch UndoNode *next = nullptr; // redo branch
[[nodiscard]] bool HasSpans() const
{
return !spans.empty();
}
// Length of the node's text, however it is held.
[[nodiscard]] std::size_t TextSize() const
{
return HasSpans() ? span_len : text.size();
}
// Approximate heap and object bytes held by this node.
[[nodiscard]] std::size_t CostBytes() const
{
return sizeof(UndoNode) + text.capacity() + spans.capacity() * sizeof(TextSpan);
}
}; };
-63
View File
@@ -1,63 +0,0 @@
#pragma once
#include <stack>
#include <vector>
#include <memory>
#include "UndoNode.h"
// Pool allocator for UndoNode to eliminate frequent malloc/free.
// Uses fixed-size blocks to keep node addresses stable.
class UndoNodePool {
public:
explicit UndoNodePool(std::size_t block_size = 64)
: block_size_(block_size) {}
UndoNode *acquire()
{
if (available_.empty())
allocate_block();
auto *node = available_.top();
available_.pop();
// Node comes zeroed; ensure links are reset
node->text.clear();
node->parent = nullptr;
node->child = nullptr;
node->next = nullptr;
node->row = node->col = 0;
node->type = UndoType{};
return node;
}
void release(UndoNode *node)
{
if (!node)
return;
// Clear heavy fields to free memory held by strings
node->text.clear();
node->parent = nullptr;
node->child = nullptr;
node->next = nullptr;
node->row = node->col = 0;
node->type = UndoType{};
available_.push(node);
}
private:
void allocate_block()
{
// allocate a new block; keep ownership so memory stays valid
std::unique_ptr<UndoNode[]> block(new UndoNode[block_size_]);
UndoNode *base = block.get();
blocks_.push_back(std::move(block));
for (std::size_t i = 0; i < block_size_; ++i) {
// ensure the node is reset; rely on default constructor/zero init
available_.push(&base[i]);
}
}
std::size_t block_size_;
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
std::stack<UndoNode *> available_;
};
+311 -34
View File
@@ -1,7 +1,9 @@
#include "UndoSystem.h" #include "UndoSystem.h"
#include "Buffer.h" #include "Buffer.h"
#include <algorithm>
#include <cassert> #include <cassert>
#include <cstdio> #include <cstdio>
#include <vector>
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree) UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
@@ -15,6 +17,9 @@ UndoSystem::BeginGroup()
commit(); commit();
if (active_group_id_ == 0) if (active_group_id_ == 0)
active_group_id_ = next_group_id_++; active_group_id_ = next_group_id_++;
// Groups nest (e.g. SmartNewline wraps Newline, which groups its own
// visual-line splits); only the outermost EndGroup closes the group.
++group_depth_;
return active_group_id_; return active_group_id_;
} }
@@ -23,7 +28,12 @@ void
UndoSystem::EndGroup() UndoSystem::EndGroup()
{ {
commit(); commit();
if (group_depth_ > 0)
--group_depth_;
if (group_depth_ == 0) {
active_group_id_ = 0; active_group_id_ = 0;
maybe_prune();
}
} }
@@ -36,7 +46,8 @@ UndoSystem::Begin(UndoType type)
const int col = static_cast<int>(buf_->Curx()); const int col = static_cast<int>(buf_->Curx());
// Some operations should always be standalone undo steps. // Some operations should always be standalone undo steps.
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow); const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
UndoType::InsertRow || type == UndoType::JoinLines);
if (always_standalone) { if (always_standalone) {
commit(); commit();
} }
@@ -75,6 +86,8 @@ UndoSystem::Begin(UndoType type)
} }
case UndoType::Newline: case UndoType::Newline:
case UndoType::DeleteRow: case UndoType::DeleteRow:
case UndoType::InsertRow:
case UndoType::JoinLines:
break; break;
} }
} }
@@ -140,6 +153,9 @@ UndoSystem::commit()
return; return;
} }
try_convert_to_spans(tree_.pending);
bytes_ += tree_.pending->CostBytes();
if (!tree_.root) { if (!tree_.root) {
tree_.root = tree_.pending; tree_.root = tree_.pending;
tree_.pending->parent = nullptr; tree_.pending->parent = nullptr;
@@ -167,6 +183,8 @@ UndoSystem::commit()
tree_.pending = nullptr; tree_.pending = nullptr;
pending_mode_ = PendingAppendMode::Append; pending_mode_ = PendingAppendMode::Append;
update_dirty_flag(); update_dirty_flag();
if (group_depth_ == 0)
maybe_prune();
} }
@@ -238,6 +256,24 @@ UndoSystem::mark_saved()
{ {
commit(); commit();
tree_.saved = tree_.current; tree_.saved = tree_.current;
base_unsaved_ = false;
update_dirty_flag();
}
void
UndoSystem::AbortGroups()
{
commit();
group_depth_ = 0;
active_group_id_ = 0;
}
void
UndoSystem::mark_base_unsaved()
{
base_unsaved_ = true;
update_dirty_flag(); update_dirty_flag();
} }
@@ -257,19 +293,61 @@ void
UndoSystem::clear() UndoSystem::clear()
{ {
discard_pending(); discard_pending();
free_node(tree_.root); // The root may have siblings (edits made after undoing to the start).
FreeUndoForest(tree_.root);
tree_.root = nullptr; tree_.root = nullptr;
tree_.current = nullptr; tree_.current = nullptr;
tree_.saved = nullptr; tree_.saved = nullptr;
base_unsaved_ = false;
bytes_ = 0;
active_group_id_ = 0; active_group_id_ = 0;
group_depth_ = 0;
next_group_id_ = 1; next_group_id_ = 1;
update_dirty_flag(); update_dirty_flag();
} }
// Position just after the node's text inserted at (node->col, node->row).
// The text may span lines: the column is then the length after its last
// newline, not col + size (which left the cursor past the end of the line,
// so later edits and their undo records went to the wrong place).
static void
end_of_text(const Buffer &buf, const UndoNode *node, std::size_t &x, std::size_t &y)
{
y = static_cast<std::size_t>(node->row);
x = static_cast<std::size_t>(node->col);
auto scan = [&](const char *d, std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
if (d[i] == '\n') {
++y;
x = 0;
} else {
++x;
}
}
};
if (node->HasSpans())
buf.VisitSpans(node->spans, scan);
else
scan(node->text.data(), node->text.size());
}
// Insert the node's text at its position (from spans without copying).
static void
insert_node_text(Buffer &buf, const UndoNode *node)
{
if (node->HasSpans())
buf.insert_spans(node->row, node->col, node->spans);
else
buf.insert_text(node->row, node->col, node->text);
}
void void
UndoSystem::apply(const UndoNode *node, int direction) UndoSystem::apply(const UndoNode *node, int direction)
{ {
std::size_t ex = 0, ey = 0;
if (!node) if (!node)
return; return;
// Cursor positioning: keep the point at a sensible location after undo/redo. // Cursor positioning: keep the point at a sensible location after undo/redo.
@@ -278,22 +356,22 @@ UndoSystem::apply(const UndoNode *node, int direction)
case UndoType::Insert: case UndoType::Insert:
case UndoType::Paste: case UndoType::Paste:
if (direction > 0) { if (direction > 0) {
buf_->insert_text(node->row, node->col, node->text); insert_node_text(*buf_, node);
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()), end_of_text(*buf_, node, ex, ey);
static_cast<std::size_t>(node->row)); buf_->SetCursor(ex, ey);
} else { } else {
buf_->delete_text(node->row, node->col, node->text.size()); buf_->delete_text(node->row, node->col, node->TextSize());
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row)); buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::Delete: case UndoType::Delete:
if (direction > 0) { if (direction > 0) {
buf_->delete_text(node->row, node->col, node->text.size()); buf_->delete_text(node->row, node->col, node->TextSize());
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row)); buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} else { } else {
buf_->insert_text(node->row, node->col, node->text); insert_node_text(*buf_, node);
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()), end_of_text(*buf_, node, ex, ey);
static_cast<std::size_t>(node->row)); buf_->SetCursor(ex, ey);
} }
break; break;
case UndoType::Newline: case UndoType::Newline:
@@ -314,6 +392,25 @@ UndoSystem::apply(const UndoNode *node, int direction)
buf_->SetCursor(0, static_cast<std::size_t>(node->row)); buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::InsertRow:
if (direction > 0) {
buf_->insert_row(node->row, node->text);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} else {
buf_->delete_row(node->row);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
}
break;
case UndoType::JoinLines:
// Mirror image of Newline: forward removes a newline, backward restores it.
if (direction > 0) {
buf_->join_lines(node->row);
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} else {
buf_->split_line(node->row, node->col);
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
}
break;
} }
} }
@@ -321,20 +418,13 @@ UndoSystem::apply(const UndoNode *node, int direction)
void void
UndoSystem::free_node(UndoNode *node) UndoSystem::free_node(UndoNode *node)
{ {
// Free this node and its child subtree, but not its siblings.
if (!node) if (!node)
return; return;
// Free child subtree(s) and sibling branches UndoNode *children = node->child;
if (node->child) {
// Free entire redo list starting at child, including each subtree
UndoNode *branch = node->child;
while (branch) {
UndoNode *next = branch->next;
free_node(branch);
branch = next;
}
node->child = nullptr; node->child = nullptr;
}
delete node; delete node;
FreeUndoForest(children);
} }
@@ -342,26 +432,27 @@ void
UndoSystem::free_branch(UndoNode *node) UndoSystem::free_branch(UndoNode *node)
{ {
// Free a branch list (node and its next siblings) including their subtrees // Free a branch list (node and its next siblings) including their subtrees
while (node) { FreeUndoForest(node);
UndoNode *next = node->next;
free_node(node);
node = next;
}
} }
// Iterative search for `target` below `cur`; sets out_parent to its parent.
static bool static bool
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent) dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
{ {
if (!cur) if (!cur)
return false; return false;
for (UndoNode *child = cur->child; child != nullptr; child = child->next) { std::vector<UndoNode *> stack{cur};
while (!stack.empty()) {
UndoNode *node = stack.back();
stack.pop_back();
for (UndoNode *child = node->child; child != nullptr; child = child->next) {
if (child == target) { if (child == target) {
out_parent = cur; out_parent = node;
return true; return true;
} }
if (dfs_find_parent(child, target, out_parent)) stack.push_back(child);
return true; }
} }
return false; return false;
} }
@@ -380,11 +471,190 @@ UndoSystem::find_parent(UndoNode *from, UndoNode *target)
} }
// Texts at least this long are held as storage spans when possible.
static constexpr std::size_t kSpanMinBytes = PieceTable::kCaptureDeletedMin;
void
UndoSystem::try_convert_to_spans(UndoNode *node)
{
if (!buf_ || !node || node->text.size() < kSpanMinBytes)
return;
std::vector<TextSpan> spans;
switch (node->type) {
case UndoType::Insert:
case UndoType::Paste:
// The inserted text is in the buffer at the node's position.
spans = buf_->SpansAt(node->row, node->col, node->text.size());
break;
case UndoType::Delete:
// The deleted text is still in storage; the deletion remembered where.
spans = buf_->TakeDeletedSpans(node->row, node->col, node->text.size());
break;
default:
return;
}
// Only when the spans hold exactly the recorded text; otherwise (the
// text is not where the node says) keep the copy.
if (spans.empty() || !buf_->SpansEqual(spans, node->text))
return;
node->span_len = node->text.size();
node->spans = std::move(spans);
node->spans.shrink_to_fit();
std::string().swap(node->text);
}
void
UndoSystem::maybe_prune()
{
std::size_t budget = budget_;
if (budget == 0) {
const std::size_t content = buf_ ? buf_->ContentBytes() : 0;
budget = std::max<std::size_t>(std::size_t{64} << 20, content * 2);
}
if (bytes_ <= budget)
return;
// Prune well below the budget so this does not run on every commit.
prune_to(budget - budget / 4);
}
void
UndoSystem::forget_saved_state()
{
tree_.saved = nullptr;
base_unsaved_ = true;
}
// Total bytes held by a node, its children and its siblings.
static std::size_t
forest_bytes(const UndoNode *first)
{
std::size_t total = 0;
std::vector<const UndoNode *> stack;
if (first)
stack.push_back(first);
while (!stack.empty()) {
const UndoNode *n = stack.back();
stack.pop_back();
total += n->CostBytes();
if (n->child)
stack.push_back(n->child);
if (n->next)
stack.push_back(n->next);
}
return total;
}
static bool
forest_contains(const UndoNode *first, const UndoNode *target)
{
if (!target)
return false;
std::vector<const UndoNode *> stack;
if (first)
stack.push_back(first);
while (!stack.empty()) {
const UndoNode *n = stack.back();
stack.pop_back();
if (n == target)
return true;
if (n->child)
stack.push_back(n->child);
if (n->next)
stack.push_back(n->next);
}
return false;
}
void
UndoSystem::prune_to(const std::size_t target)
{
// The line kept: root..current, then current's active redo line.
std::vector<UndoNode *> line;
for (UndoNode *n = tree_.current; n; n = n->parent)
line.push_back(n);
std::reverse(line.begin(), line.end());
{
UndoNode *n = tree_.current ? tree_.current->child : tree_.root;
for (; n; n = n->child)
line.push_back(n);
}
if (line.empty())
return;
// Detach and free the siblings of `keep` in the list starting at *head.
auto drop_siblings = [&](UndoNode **head, UndoNode *keep) {
UndoNode *n = *head;
*head = keep;
while (n) {
UndoNode *nx = n->next;
if (n != keep) {
n->next = nullptr;
if (forest_contains(n, tree_.saved))
forget_saved_state();
FreeUndoForest(n);
}
n = nx;
}
keep->next = nullptr;
};
// 1. Branches off the kept line: reachable only by choosing a redo branch.
drop_siblings(&tree_.root, line.front());
for (std::size_t i = 0; i + 1 < line.size(); ++i)
drop_siblings(&line[i]->child, line[i + 1]);
bytes_ = forest_bytes(tree_.root);
// 2. The oldest edits, while they are older than the current state. An
// undo group goes whole: undoing what remained of it would stop at a
// state inside the group, which the user never saw.
std::size_t i = 0;
bool mid_group = false;
while ((bytes_ > target || mid_group) && i < line.size() && line[i] != tree_.current && tree_.current) {
UndoNode *old = line[i];
UndoNode *nx = old->child;
if (!nx)
break;
if (old->group_id != 0 && old->group_id == tree_.current->group_id)
break; // the current state's own group stays whole
// Dropping `old` loses the state before it. `old`'s own state
// becomes the first state in history.
if (tree_.saved == nullptr)
forget_saved_state();
else if (tree_.saved == old)
tree_.saved = nullptr;
mid_group = old->group_id != 0 && nx->group_id == old->group_id;
bytes_ -= old->CostBytes();
old->child = nullptr;
delete old;
nx->parent = nullptr;
tree_.root = nx;
++i;
}
// 3. The redo line beyond the current state, newest first.
if (bytes_ > target && tree_.current && tree_.current->child) {
UndoNode *future = tree_.current->child;
if (forest_contains(future, tree_.saved))
forget_saved_state();
tree_.current->child = nullptr;
bytes_ -= forest_bytes(future);
FreeUndoForest(future);
}
update_dirty_flag();
}
void void
UndoSystem::update_dirty_flag() UndoSystem::update_dirty_flag()
{ {
// dirty if current != saved // dirty if current != saved
bool dirty = (tree_.current != tree_.saved); bool dirty = base_unsaved_ || (tree_.current != tree_.saved);
buf_->SetDirty(dirty); buf_->SetDirty(dirty);
} }
@@ -411,6 +681,10 @@ UndoSystem::type_str(UndoType t)
return "Newline"; return "Newline";
case UndoType::DeleteRow: case UndoType::DeleteRow:
return "DeleteRow"; return "DeleteRow";
case UndoType::InsertRow:
return "InsertRow";
case UndoType::JoinLines:
return "JoinLines";
} }
return "?"; return "?";
} }
@@ -421,11 +695,14 @@ UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
{ {
if (!root || !target) if (!root || !target)
return false; return false;
if (root == target) std::vector<UndoNode *> stack{root};
return true; while (!stack.empty()) {
for (UndoNode *child = root->child; child != nullptr; child = child->next) { UndoNode *node = stack.back();
if (is_descendant(child, target)) stack.pop_back();
if (node == target)
return true; return true;
for (UndoNode *child = node->child; child != nullptr; child = child->next)
stack.push_back(child);
} }
return false; return false;
} }
+81
View File
@@ -1,3 +1,44 @@
/*
* UndoSystem.h - undo/redo system with tree-based branching
*
* UndoSystem manages the undo/redo history for a Buffer. It provides:
*
* - Tree-based undo: Multiple redo branches at each node (not just linear history)
* - Atomic grouping: Multiple operations can be undone/redone as a single step
* - Dirty tracking: Marks when buffer matches last saved state
* - Efficient storage: Nodes stored in UndoTree, operations applied to Buffer
*
* Key concepts:
*
* 1. Undo tree structure:
* - Each edit creates a node in the tree
* - Undo moves up the tree (toward root)
* - Redo moves down the tree (toward leaves)
* - Multiple redo branches preserved (not lost on new edits after undo)
*
* 2. Operation lifecycle:
* - Begin(type): Start recording an operation (insert/delete)
* - Append(text): Add content to the pending operation
* - commit(): Finalize and add to undo tree
* - discard_pending(): Cancel without recording
*
* 3. Atomic grouping:
* - BeginGroup()/EndGroup(): Bracket multiple operations
* - All operations in a group share the same group_id
* - Undo/redo treats the entire group as one step
*
* 4. Integration with Buffer:
* - UndoSystem holds a reference to its owning Buffer
* - apply() executes undo/redo by calling Buffer's editing methods
* - Buffer's dirty flag updated automatically
*
* Usage pattern:
* undo_system.Begin(UndoType::Insert);
* undo_system.Append("text");
* undo_system.commit(); // Now undoable
*
* See also: UndoTree.h (storage), UndoNode.h (node structure)
*/
#pragma once #pragma once
#include <string_view> #include <string_view>
#include <cstddef> #include <cstddef>
@@ -35,12 +76,35 @@ public:
void mark_saved(); void mark_saved();
// No undo state matches the file on disk any more (e.g. after crash
// recovery replayed edits onto it): the buffer stays dirty in every state
// until the next mark_saved() or clear().
void mark_base_unsaved();
// Close any open group (after an interrupted command) and seal pending.
void AbortGroups();
void discard_pending(); void discard_pending();
void clear(); void clear();
void UpdateBufferReference(Buffer &new_buf); void UpdateBufferReference(Buffer &new_buf);
// History memory budget in bytes (node texts, spans and nodes). When
// committed history exceeds it, branches off the current path and then
// the oldest edits are dropped. 0 (the default) means max(64 MiB, twice
// the buffer's size).
void SetByteBudget(std::size_t bytes)
{
budget_ = bytes;
}
[[nodiscard]] std::size_t HistoryBytes() const
{
return bytes_;
}
#if defined(KTE_TESTS) #if defined(KTE_TESTS)
// Test-only introspection hook. // Test-only introspection hook.
const UndoTree &TreeForTests() const const UndoTree &TreeForTests() const
@@ -70,9 +134,26 @@ private:
void update_dirty_flag(); void update_dirty_flag();
// Hold a large committed text as storage spans (see UndoNode::spans).
void try_convert_to_spans(UndoNode *node);
// Enforce the byte budget (outside groups).
void maybe_prune();
void prune_to(std::size_t target);
// The saved state's node is being dropped: no state in history matches
// the file any more.
void forget_saved_state();
std::size_t bytes_ = 0; // approximate bytes held by committed history
std::size_t budget_ = 0;
PendingAppendMode pending_mode_ = PendingAppendMode::Append; PendingAppendMode pending_mode_ = PendingAppendMode::Append;
std::uint64_t active_group_id_ = 0; std::uint64_t active_group_id_ = 0;
int group_depth_ = 0; // nesting depth of BeginGroup/EndGroup
bool base_unsaved_ = false; // see mark_base_unsaved()
std::uint64_t next_group_id_ = 1; std::uint64_t next_group_id_ = 1;
Buffer *buf_; Buffer *buf_;
+30 -2
View File
@@ -1,3 +1,31 @@
// Placeholder translation unit for UndoTree struct definition. // Undo logic is implemented in UndoSystem; this file only owns node lifetime.
// Undo logic is implemented in UndoSystem.
#include "UndoTree.h" #include "UndoTree.h"
#include <vector>
void
FreeUndoForest(UndoNode *first)
{
// Nodes form a first-child/next-sibling tree whose depth equals the
// length of the edit history, so walk it with an explicit stack.
std::vector<UndoNode *> stack;
if (first)
stack.push_back(first);
while (!stack.empty()) {
UndoNode *node = stack.back();
stack.pop_back();
if (node->child)
stack.push_back(node->child);
if (node->next)
stack.push_back(node->next);
delete node;
}
}
UndoTree::~UndoTree()
{
FreeUndoForest(root);
delete pending;
}
+12
View File
@@ -7,4 +7,16 @@ struct UndoTree {
UndoNode *current = nullptr; // current state of buffer UndoNode *current = nullptr; // current state of buffer
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag) UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
UndoNode *pending = nullptr; // in-progress batch (detached) UndoNode *pending = nullptr; // in-progress batch (detached)
// Frees the entire node graph (root's subtree/branches plus any detached
// pending node). `current`/`saved` are aliases into that same graph, not
// separately owned. Without this, closing/reloading/resetting a buffer
// (which replaces or destroys its UndoTree) leaks every UndoNode ever
// created for it.
~UndoTree();
}; };
// Frees `first`, its whole child subtree, and every node on its `next`
// (sibling) chain. Iterative, so history depth cannot overflow the stack.
void FreeUndoForest(UndoNode *first);
+31 -4
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.15) cmake_minimum_required(VERSION 3.15)
# Fix up a macOS .app bundle by copying non-Qt dylibs into # Fix up a macOS .app bundle by copying non-system dylibs into
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path. # Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
# #
# Usage: # Usage:
@@ -29,9 +29,8 @@ set(DIRS
) )
# Note: We pass empty plugin list so fixup_bundle scans the executable and all # Note: We pass empty plugin list so fixup_bundle scans the executable and all
# libs it references recursively. Qt frameworks already live in the bundle after # libs it references recursively, copying every non-system dylib (e.g. Homebrew
# macdeployqt; this step is primarily for non-Qt dylibs (glib, icu, pcre2, zstd, # SDL2, freetype, pcre2) into Contents/Frameworks; SDL3 is handled below.
# dbus, etc.).
# fixup_bundle often fails if copied libraries are read-only. # fixup_bundle often fails if copied libraries are read-only.
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version. # We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently. # Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
@@ -44,6 +43,34 @@ execute_process(COMMAND chmod -R u+w "${APP_DIR}/Contents/Frameworks")
fixup_bundle("${APP_DIR}" "" "${DIRS}") fixup_bundle("${APP_DIR}" "" "${DIRS}")
# Homebrew's sdl2 is sdl2-compat, which dlopen()s SDL3 at startup instead of
# linking it, so fixup_bundle never sees it. Without SDL3 in the bundle kge
# stops at launch with "Failed loading SDL3 library". sdl2-compat looks for
# @loader_path/libSDL3.dylib first; put SDL3 there.
set(SDL2_IN_BUNDLE "${APP_DIR}/Contents/Frameworks/libSDL2-2.0.0.dylib")
if (EXISTS "${SDL2_IN_BUNDLE}")
file(STRINGS "${SDL2_IN_BUNDLE}" SDL2_COMPAT_REFS REGEX "libSDL3\\.dylib")
if (SDL2_COMPAT_REFS)
find_file(SDL3_DYLIB NAMES libSDL3.0.dylib libSDL3.dylib
PATHS "/opt/homebrew/opt/sdl3/lib" "/opt/homebrew/lib" "/usr/local/opt/sdl3/lib" "/usr/local/lib"
NO_DEFAULT_PATH)
if (NOT SDL3_DYLIB)
message(FATAL_ERROR "libSDL2 in the bundle is sdl2-compat, which needs SDL3 at run time, "
"but libSDL3 was not found (brew install sdl3)")
endif ()
file(REAL_PATH "${SDL3_DYLIB}" SDL3_REAL)
set(SDL3_IN_BUNDLE "${APP_DIR}/Contents/Frameworks/libSDL3.dylib")
message(STATUS "Bundling SDL3 for sdl2-compat: ${SDL3_REAL}")
file(COPY_FILE "${SDL3_REAL}" "${SDL3_IN_BUNDLE}")
execute_process(COMMAND chmod u+w "${SDL3_IN_BUNDLE}")
execute_process(COMMAND /usr/bin/install_name_tool -id "@loader_path/libSDL3.dylib" "${SDL3_IN_BUNDLE}"
RESULT_VARIABLE SDL3_ID_RESULT)
if (NOT SDL3_ID_RESULT EQUAL 0)
message(FATAL_ERROR "install_name_tool -id failed on ${SDL3_IN_BUNDLE}")
endif ()
endif ()
endif ()
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle # On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc) # invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
# for it to be allowed to run. # for it to be allowed to run.
+46 -23
View File
@@ -1,18 +1,17 @@
{ {
pkgs ? import <nixpkgs> {}, lib,
lib ? pkgs.lib,
stdenv, stdenv,
cmake, cmake,
pkg-config,
ncurses, ncurses,
pcre2,
SDL2, SDL2,
libGL, libGL,
xorg, xorg,
kdePackages,
qt6Packages ? kdePackages.qt6Packages,
installShellFiles, installShellFiles,
copyDesktopItems,
makeDesktopItem,
graphical ? false, graphical ? false,
graphical-qt ? false,
...
}: }:
let let
cmakeContent = builtins.readFile ./CMakeLists.txt; cmakeContent = builtins.readFile ./CMakeLists.txt;
@@ -23,31 +22,56 @@ let
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine); version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
in in
stdenv.mkDerivation { stdenv.mkDerivation {
pname = "kte"; pname = if graphical then "kge" else "kte";
inherit version; inherit version;
src = lib.cleanSource ./.; src = lib.cleanSource ./.;
nativeBuildInputs = [ nativeBuildInputs = [
cmake cmake
ncurses pkg-config
installShellFiles installShellFiles
] ] ++ lib.optionals graphical [
++ lib.optionals graphical [ copyDesktopItems
];
buildInputs = [
ncurses
pcre2 # regex engine (optional; std::regex without it)
] ++ lib.optionals graphical [
SDL2 SDL2
libGL libGL
xorg.libX11 xorg.libX11
]
++ lib.optionals graphical-qt [
kdePackages.qt6ct
qt6Packages.qtbase
qt6Packages.wrapQtAppsHook
]; ];
cmakeFlags = [ cmakeFlags = [
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}" "-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
"-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}"
"-DCMAKE_BUILD_TYPE=Debug" "-DCMAKE_BUILD_TYPE=Debug"
"-DKTE_STATIC_LINK=OFF"
];
desktopItems = lib.optionals graphical [
(makeDesktopItem {
name = "kge";
desktopName = "kge";
genericName = "Text Editor";
comment = "kyle's graphical text editor";
exec = "kge %F";
icon = "kge";
terminal = false;
categories = [ "Utility" "TextEditor" "Development" ];
mimeTypes = [
"text/plain"
"text/x-c"
"text/x-c++"
"text/x-python"
"text/x-go"
"text/x-rust"
"application/json"
"text/markdown"
"text/x-shellscript"
];
})
]; ];
installPhase = '' installPhase = ''
@@ -58,14 +82,7 @@ stdenv.mkDerivation {
installManPage ../docs/kte.1 installManPage ../docs/kte.1
${lib.optionalString graphical '' ${lib.optionalString graphical ''
mkdir -p $out/bin
${if graphical-qt then ''
cp kge $out/bin/kge-qt
'' else ''
cp kge $out/bin/kge cp kge $out/bin/kge
''}
installManPage ../docs/kge.1 installManPage ../docs/kge.1
mkdir -p $out/share/icons/hicolor/256x256/apps mkdir -p $out/share/icons/hicolor/256x256/apps
@@ -74,4 +91,10 @@ stdenv.mkDerivation {
runHook postInstall runHook postInstall
''; '';
meta = {
description = "kyle's text editor" + lib.optionalString graphical " (graphical)";
platforms = lib.platforms.unix;
mainProgram = if graphical then "kge" else "kte";
};
} }
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Helper script to test Linux builds using Docker/Podman
# This script mounts the current source tree into a Linux container,
# builds kte in terminal-only mode, and runs the test suite.
set -e
# Detect whether to use docker or podman
if command -v docker &> /dev/null; then
CONTAINER_CMD="docker"
elif command -v podman &> /dev/null; then
CONTAINER_CMD="podman"
else
echo "Error: Neither docker nor podman found in PATH"
exit 1
fi
IMAGE_NAME="kte-linux"
# Check if image exists, if not, build it
if ! $CONTAINER_CMD image inspect "$IMAGE_NAME" &> /dev/null; then
echo "Building $IMAGE_NAME image..."
$CONTAINER_CMD build -t "$IMAGE_NAME" .
fi
# Run the container with the current directory mounted
echo "Running Linux build and tests..."
$CONTAINER_CMD run --rm -v "$(pwd):/kte" "$IMAGE_NAME"
+245
View File
@@ -0,0 +1,245 @@
# kte Benchmarking and Testing Guide
This document describes the benchmarking infrastructure and testing
improvements added to ensure high performance and correctness of core
operations.
## Overview
The kte test suite now includes comprehensive benchmarks and migration
coverage tests to:
- Measure performance of core operations (PieceTable, Buffer, syntax
highlighting)
- Ensure no performance regressions from refactorings
- Validate correctness of API migrations (Buffer::Rows() →
GetLineString/GetLineView)
- Provide performance baselines for future optimizations
## Running Tests
### All Tests (including benchmarks)
```bash
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
```
### Test Organization
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
search, etc.
- **15 benchmark tests**: Performance measurements for critical
operations
- **30 migration coverage tests**: Edge cases and correctness validation
Total: **98 tests**
## Benchmark Results
### Buffer Iteration Patterns (5,000 lines)
| Pattern | Time | Speedup vs Rows() |
|-----------------------------------------|---------|-------------------|
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
**Key Insight**: `GetLineView()` provides zero-copy access and is
dramatically faster than materializing the entire rows cache.
### PieceTable Operations (10,000 lines)
| Operation | Time |
|-----------------------------|---------|
| Sequential inserts (10K) | 2.1 ms |
| Random inserts (5K) | 32.9 ms |
| `GetLine()` sequential | 4.7 ms |
| `GetLineRange()` sequential | 1.3 ms |
### Buffer Operations
| Operation | Time |
|--------------------------------------|---------|
| `Nrows()` (1M calls) | 13.0 ms |
| `GetLineString()` (10K lines) | 4.8 ms |
| `GetLineView()` (10K lines) | 1.6 ms |
| `Rows()` materialization (10K lines) | 6.2 ms |
### Syntax Highlighting
| Operation | Time | Notes |
|------------------------------------|---------|----------------|
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
| HighlighterEngine cache population | 19.9 ms | |
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
### Large File Performance
| Operation | Time |
|---------------------------------|---------|
| Insert 50K lines | 0.53 ms |
| Iterate 50K lines (GetLineView) | 2.7 ms |
| Random access (10K accesses) | 1.8 ms |
## API Differences: GetLineString vs GetLineView
Understanding the difference between these APIs is critical:
### `GetLineString(row)`
- Returns: `std::string` (copy)
- Content: Line text **without** trailing newline
- Use case: When you need to modify the string or store it
- Example: `"hello"` for line `"hello\n"`
### `GetLineView(row)`
- Returns: `std::string_view` (zero-copy)
- Content: Raw line range **including** trailing newline
- Use case: Read-only access, maximum performance
- Example: `"hello\n"` for line `"hello\n"`
- **Warning**: View becomes invalid after buffer modifications
### `Rows()`
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
- Content: Lines **without** trailing newlines
- Use case: Legacy code, being phased out
- Performance: Slower due to materialization overhead
## Migration Coverage Tests
The `test_migration_coverage.cc` file provides 30 tests covering:
### Edge Cases
- Empty buffers
- Single lines (with/without newlines)
- Very long lines (10,000 characters)
- Many empty lines (1,000 newlines)
### Consistency
- `GetLineString()` vs `GetLineView()` vs `Rows()`
- Consistency after edits (insert, delete, split, join)
### Boundary Conditions
- First line access
- Last line access
- Line range boundaries
### Special Characters
- Tabs, carriage returns, null bytes
- Unicode (UTF-8 multibyte characters)
### Stress Tests
- Large files (10,000 lines)
- Many small operations (100+ inserts)
- Alternating insert/delete patterns
### Regression Tests
- Shebang detection pattern (Editor.cc)
- Empty buffer check pattern (Editor.cc)
- Syntax highlighter pattern (all highlighters)
- Swap snapshot pattern (Swap.cc)
## Performance Recommendations
Based on benchmark results:
1. **Prefer `GetLineView()` for read-only access**
- 11x faster than `Rows()` for iteration
- Zero-copy, minimal overhead
- Use immediately (view invalidates on edit)
2. **Use `GetLineString()` when you need a copy**
- Still 1.7x faster than `Rows()`
- Safe to store and modify
- Strips trailing newlines automatically
3. **Avoid `Rows()` in hot paths**
- Materializes entire line cache
- Slower for large files
- Being phased out (legacy API)
4. **Cache `Nrows()` in tight loops**
- Very fast (13ms for 1M calls)
- But still worth caching in inner loops
5. **Leverage HighlighterEngine caching**
- 38x speedup on cache hits
- Automatically invalidates on edits
- Prefetch viewport for smooth scrolling
## Adding New Benchmarks
To add a new benchmark:
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
2. Use `BenchmarkTimer` to measure critical sections:
```cpp
{
BenchmarkTimer timer("Operation description");
// ... code to benchmark ...
}
```
3. Print section headers with `std::cout` for clarity
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
Example:
```cpp
TEST(Benchmark_MyOperation) {
std::cout << "\n=== My Operation Benchmark ===\n";
// Setup
Buffer buf;
std::string data = generate_test_data();
buf.insert_text(0, 0, data);
std::size_t result = 0;
{
BenchmarkTimer timer("My operation on 10K lines");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
result += my_operation(buf, i);
}
}
EXPECT_TRUE(result > 0);
}
```
## Continuous Performance Monitoring
Run benchmarks regularly to detect regressions:
```bash
# Run tests and save output
./cmake-build-debug/kte_tests > benchmark_results.txt
# Compare with baseline
diff benchmark_baseline.txt benchmark_results.txt
```
Look for:
- Significant time increases (>20%) in any benchmark
- New operations that are slower than expected
- Cache effectiveness degradation
## Conclusion
The benchmark suite provides:
- **Performance validation**: Ensures migrations don't regress
performance
- **Optimization guidance**: Identifies fastest APIs for each use case
- **Regression detection**: Catches performance issues early
- **Documentation**: Demonstrates correct API usage patterns
All 98 tests pass with 0 failures, confirming both correctness and
performance of the migrated codebase.
File diff suppressed because it is too large Load Diff
+678
View File
@@ -0,0 +1,678 @@
# Bug and Performance Audit
**Project:** kte (Kyle's Text Editor)
**Date:** 2026-09-23
**Auditor:** Claude Code (automated review, four parallel reviewers plus
spot verification)
**Baseline:** `d8d2ae8` (debug build clean, `kte_tests` 0 failures)
---
## Method
The tree was split into four review areas: core text model (PieceTable,
Buffer, OptimizedSearch), undo and swap (UndoSystem/Tree/Node, Swap,
ErrorRecovery, SyscallWrappers), commands (Command.cc, KKeymap), and
frontends/syntax (Terminal, ImGui, Qt, Editor, `syntax/`). Every finding
below was traced to a concrete scenario. Findings marked **[repro]** were
reproduced with a small test program; the rest were confirmed by reading
the code.
Finding IDs are stable and are referenced by fix commits:
- **M** — memory safety (crash, out-of-bounds, use-after-free, data race)
- **D** — data integrity (undo, swap journal, text corruption)
- **B** — incorrect behaviour
- **P** — performance
---
## M — Memory safety
### M1. PieceTable assignment keeps a stale line index **[repro]**
`PieceTable.cc:47-98`. Copy and move assignment copy `pieces_`, `add_`,
and `total_size_`, but leave the destination's `line_index_` and
`line_index_dirty_` untouched. `Editor::CloseBuffer` erases from
`std::vector<Buffer>`, which move-assigns every later buffer into the
previous slot.
Scenario: open A (`"line1\nline2\nline3\n"`), open B (`"x"`), render
both, close A. Slot 0 now holds B's text with A's index: `LineCount()`
returns 4 and `GetLineView(1)` builds a view over bytes 6–12 of a 1-byte
buffer.
Fix: mark the line index dirty (and clear it) in both assignment
operators.
### M2. Background highlighter thread races buffer mutation
`syntax/HighlighterEngine.cc:184-249`. `PrefetchViewport` (called from
the terminal and ImGui renderers) queues rows for a worker thread which
calls `buf.GetLineString`, walking `pieces_` and reading `add_`. The main
thread's `PieceTable::Insert`/`Delete` mutate both without any shared
lock; `add_.append` may reallocate. The worker also stores a raw
`Buffer*` (`pending_.buf`) which dangles when `AddBuffer`'s `push_back`
reallocates or `CloseBuffer`'s `erase` shifts buffers.
Fix: remove the background warm-up, or feed it an immutable snapshot.
### M3. Per-window SwapManager leaves dangling recorders (ImGui multi-window)
`ImGuiFrontend.cc:381-385`, `Editor.cc:205-213`. Each window's `Editor`
owns its own `SwapManager` but shares the buffer vector. A buffer created
from a secondary window keeps a `swap_rec_` pointing into that window's
manager; closing the window destroys it, and the next edit in the main
window calls into freed memory. `Rehome` from the wrong manager also
returns `nullptr`, silently disabling journaling for the primary's
buffers.
Fix: share one `SwapManager` across windows, and detach every buffer an
`Editor` attached when that `Editor` is destroyed.
### M4. Recursive undo-tree free overflows the stack **[repro]**
`UndoTree.cc:12` (`free_node_graph`), also `UndoSystem.cc:354`
(`is_descendant`, `dfs_find_parent`). Recursion depth equals history
length. About 300k nodes segfaults a debug build on an 8 MB stack;
replace-all over ~150k matches (two nodes each) followed by close or
reload is enough.
Fix: free iteratively with an explicit stack.
### M5. Jump-to-mark places the cursor past the end of the buffer
`Command.cc:3720`. Marks are not adjusted on edits and
`cmd_jump_to_mark` does not clamp. After deleting lines below a mark,
C-k j puts the cursor on a nonexistent row; `cmd_move_left` and
`cmd_move_up` (`:3866`, `:3988`) then index `rows[y]` out of bounds.
Fix: clamp in `cmd_jump_to_mark` and in `compute_mark_region`.
---
## D — Data integrity
### D1. Smart-newline undo deletes the wrong text **[repro]**
`Command.cc:3139-3146`. The indent is inserted and the cursor moved past
it before `u->Begin(UndoType::Insert)`; `UndoSystem::Begin` records the
cursor position, so the node is placed `indent.size()` columns too far
right. `" foobar"` + SmartNewline + two undos yields
`" foo next"` (the following line's text is spliced in and `bar`
is lost).
Fix: `Begin` at `new_x`, then insert, then move the cursor; group the
newline and the indent.
### D2. Cursor column left past end-of-line corrupts undo
`cmd_scroll_up`/`cmd_scroll_down` (`Command.cc:4178`, `:4215`, bound to
the mouse wheel), replace-all's cursor restore (`:2580`), and
`cmd_jump_to_mark` call `SetCursor` without clamping x. `delete_text`
does not clamp; it treats the excess as crossing the newline.
Backspace then joins lines with an empty (dropped) undo node, and
typing followed by undo deletes a newline instead of the typed
character.
Fix: clamp x to the line length in `Buffer::SetCursor`.
### D3. `delete_row` on a final line without trailing newline leaves a stray `\n`
`Buffer.cc:803`. Affects `delete_region` (C-w, M-d, M-Backspace), regex
replace (`Command.cc:2996`), reflow (`:4884`), and the kill ring.
`"foo\nbar"` + M-d at end of `foo` yields `"foo\n"`.
### D4. Phantom empty row after a trailing newline
`PieceTable::LineCount()` counts the empty row after a final `\n`.
C-k C-d on that row records a `DeleteRow("")` whose undo inserts a
line (`Command.cc:3516`); regex replace of `^` matches it and appends
an extra line (`:2985`).
### D5. Missing or wrong undo grouping
- Plain replace-all (`Command.cc:2537-2577`) has no group: N
replacements need 2N undos, and the final Insert stays pending so the
next typed characters coalesce into it.
- Visual-line newline (`:3043-3075`) records no undo at all.
### D6. Reflow on a blank line merges the adjacent paragraphs
`Command.cc:4592`. `"aaa\n\nbbb"` with the cursor on the blank line and
ESC q gives `"aaa bbb"`.
### D7. Swap journal is fragile exactly where it matters
- `Swap.cc:1344-1373`: a torn final record (the power-loss case) makes
the whole file unreadable instead of replaying the valid prefix.
- `Swap.cc:1134-1157`, `:1187`, `:1224`: after any dropped record
(breaker open, >16 MB payload, partial write) later records still
replay against a different base state; a partial write leaves bytes
that later records are appended behind.
- `Swap.cc:1040` vs `:1187`: checkpoints allow 4 GB but the record
length is 24 bits; buffers over 16 MB are never checkpointed and the
journal grows without bound.
- `Swap.cc:644`, `:708`: a failed header write leaves an fd with no
header; answering N to "delete corrupt swap?" appends new edits to the
corrupt file.
### D8. Circuit breaker failure window never expires
`ErrorRecovery.cc:77`. `last_failure_time_` is updated before
`IsWindowExpired()` reads it, so elapsed time is always zero; five
failures hours apart still open the breaker.
### D9. Stale syntax highlighting after undo-to-clean or reload
`Buffer.h:391-400`. `version_` is bumped only in `SetDirty(true)`. Undo
back to the saved node calls `SetDirty(false)`, and `OpenFromFile`
(reload) never bumps it, so `HighlighterEngine` keeps serving spans for
the old text.
---
## B — Behaviour
### B1. `set_escdelay` is compiled out
`TerminalFrontend.cc:46`. `set_escdelay` is a function in ncurses, not a
macro, so `#ifdef set_escdelay` is always false and ESC waits the
1000 ms default.
### B2. Terminal resize runs C-g
`TerminalInputHandler.cc:167-171` maps `KEY_RESIZE` to
`CommandId::Refresh`, the cancel handler: it clears the mark, cancels
prompts (including the swap-recovery prompt, so the file is never
opened), and resets the universal argument.
### B3. ESC-as-Meta flag is not cleared on most paths
`TerminalInputHandler.cc:56-279`. ESC followed by an arrow, Enter, Tab,
or a control chord leaves `esc_meta` set; the next plain `d` runs M-d.
Same in `ImGuiInputHandler.cc:43-73`.
### B4. C-h shadows Backspace in the terminal
The generic control lookup (`TerminalInputHandler.cc:264`) maps ^H to
SearchReplace before the Backspace branch (`:307`) runs.
### B5. Byte-based editing splits UTF-8
`Command.cc:3259`, `:3352`, `:3861-3947`. Backspace, delete, and
horizontal motion step by byte: Backspace on `é` leaves a lone `0xC3`,
and C-b/C-f land inside code points.
### B6. Horizontal scroll counts bytes, renderer counts cells
`compute_render_x` (`Command.cc:52-60`) counts bytes while
`TerminalRenderer.cc:269-322` skips display cells: a long CJK line
scrolls to blank with the cursor off-screen.
### B7. GUI draws non-ASCII text as replacement glyphs
Highlighters emit one span per non-ASCII byte and `ImGuiRenderer.cc`
(~510) / `QtFrontend.cc` (~333) decode each span separately.
### B8. ImGui drops the character after Ctrl+V
`ImGuiInputHandler.cc:362`, `:382`, `:401` set a suppress-next-text flag
for chords that produce no text event.
### B9. Control characters mis-measured in the terminal
`TerminalRenderer.cc:314`, `:364`, `:413` count C0 controls as width 1;
ncurses draws them as `^X`.
### B10. Qt tab width disagrees with the command layer
`QtFrontend.cc:138` uses 4; `Command.cc` uses 8.
### B11. Smart newline edits the old buffer while a prompt is open
`Command.cc:3131`. With a prompt active, `cmd_newline` accepts it and
the saved indent is inserted through a possibly stale `Buffer*`.
### B12. Line count stale after merged appends **[repro]**
`PieceTable.cc:220-260`. `addPieceBack`/`addPieceFront` merge paths
skip `InvalidateLineIndex()`. Latent today.
### B13. Syntax highlighter edge cases
Go raw strings and Rust multi-line strings do not carry state across
lines; shell `$#` is highlighted as a comment; a C++ `#define` line
opening `/*` does not carry the comment.
---
## P — Performance
### P1. Whole-file `Rows()` rebuild per frame
Renderers call `buf->Rows()` every frame (`TerminalRenderer.cc:45`,
`ImGuiRenderer.cc:69`, `QtFrontend.cc:477`), and
`ensure_cursor_visible` (`Command.cc:91`) after every command. After an
edit this rebuilds a `std::string` per line, each via two `GetRange`
calls.
### P2. Whole-buffer materialization for one line
`Buffer::GetLineView` (`Buffer.cc:633`) calls `content_.Data()`, which
copies the entire file; the line index is rebuilt byte-by-byte after
every edit.
### P3. Re-highlight from row 0 on every keystroke
`SetDirty(true)` calls `InvalidateFrom(0)`; stateful highlighters then
re-lex from the top of the file to the viewport.
### P4. Quadratic region commands
Replace-all, `delete_region`, indent/unindent, reflow, and repeated
backspace re-fetch `Rows()` after each mutation inside their loops.
### P5. Opening a file checkpoints into a journal that is then deleted
`Swap.cc:493` snapshots, writes, and fsyncs the whole file into
`unnamed-N.swp` before removing it.
### P6. Boyer–Moore bad-character shift degenerates to 1
`OptimizedSearch.cc:63`, `:96`: when the mismatched byte is absent from
the pattern the shift is 1 instead of `j`. Test-only today.
---
## Recommended order
1. M1 (one-line fix)
2. M2 (remove or snapshot the warm-up thread)
3. D2 + M5 (clamp in `SetCursor` and jump-to-mark)
4. D1 (smart-newline undo order)
5. D7 (accept a torn final swap record)
6. P1/P2 (`Nrows()` + `GetLineString` in hot paths)
---
## Fix status
Updated as fixes land on `claude/funny-fermat-gj2vm5`.
| ID | Status |
|----|--------|
| M1 | fixed: assignment operators invalidate the line index |
| M2 | fixed: background warm-up thread removed; visible rows highlighted synchronously |
| M3 | fixed: editors sharing buffers share the owner's SwapManager |
| M4 | fixed: iterative free/search; `clear()` also leaked root siblings, now frees all |
| M5 | fixed: jump-to-mark clamps; move up/down clamp the row (move-down also underflowed) |
| D1 | fixed: `Begin` before the cursor moves; newline + indent grouped |
| D2 | fixed for scroll up/down and replace-all restore |
| D3 | fixed: kill-region, regex replace, reflow and kill-line edit byte ranges in place |
| D4 | fixed: regex replace skips the row after a final newline (a zero-width pattern used to hang the editor); kill-line there is a no-op |
| D5 | fixed: replace-all one undo group; visual-line newline recorded as one group |
| D6 | fixed: reflow on a blank line is a no-op |
| D7 | fixed: torn tail replays the valid prefix; lost records mark a gap resynced by checkpoint; partial writes truncated; header failure closes; large inserts split; oversize checkpoints skipped; declined corrupt swap moved to `.corrupt` |
| D8 | fixed: window checked before recording the failure |
| D9 | fixed: every text mutation bumps the version (including undo to the saved state); reload and replace also clear undo/highlighting |
| B1 | fixed: guard on `NCURSES_VERSION` |
| B2 | fixed: `KEY_RESIZE` issues no command |
| B3 | fixed: ESC-meta flag consumed on every key; ESC ^H and ESC Enter reach their bindings |
| B4 | not changed: C-h is a deliberate binding (search & replace); terminals whose terminfo `kbs` is ^H are translated to KEY_BACKSPACE by ncurses |
| B5 | fixed: backspace/delete/left/right step over whole UTF-8 characters; up/down snap to a boundary |
| B6 | fixed: horizontal scroll and mouse column use display cells (mbrtowc/wcwidth) |
| B7 | fixed: spans are widened to whole UTF-8 characters before the GUIs draw them (`SanitizeSpans`) |
| B8 | fixed: a text-event suppression lasts only until the next key press |
| B9 | fixed: C0 controls and DEL drawn and measured as two-cell caret notation (`TermWidth.h`) |
| B10 | fixed: one tab width (`kte::kTabWidth`); the Qt frontend has since been removed |
| B13 | open (highlighter edge cases) |
| B11 | fixed: SmartNewline with a prompt open delegates to Newline |
| B12 | fixed: merged appends invalidate the line index |
| P1 | fixed: `ensure_cursor_visible` and the terminal/ImGui renderers no longer call `Rows()` |
| P2 | fixed: line index updated in place on insert/delete (14.7 ms -> 0.28 ms per keystroke on a 38 MB buffer) |
| P3 | fixed: highlighting invalidated from the edited row (46 ms -> 0.14 ms per keystroke near the end of a 100k-line C++ file) |
| P4 | fixed: replace-all, delete-region, indent/unindent and visual-line edits each apply as one edit |
| P5 | fixed: no throwaway checkpoint on filename change |
| P6 | moot: `OptimizedSearch` was test-only and has been removed |
Regression tests: `tests/test_audit_regressions.cc`. Each test fails on the
unfixed code (M4 by crashing).
---
## Follow-up review rounds
After the first batch of fixes, further review rounds (an adversarial
review of each batch of changes, plus fresh reviews of areas the first
audit covered lightly) found further defects. All of the following are
fixed on this branch, each with a regression test that fails on the code
before the fix (unless noted).
**Crash / hang**
- Opening a directory (bad_alloc exit), a FIFO (open blocked forever), or
a device node (later replaced by a regular file on save).
- Filesystem exceptions (ENAMETOOLONG, EACCES, deleted cwd) and any other
exception escaping a command terminated the editor.
- Regex search/replace, and renderer match highlighting, overflowed the
stack on long lines (std::regex recursion); a zero-width regex replace
hung the editor (D4).
- Piped (non-tty) stdin was executed as commands, then spun at full CPU.
**Data loss / corruption**
- Recovery deleted the swap it was about to replay when another named
buffer was current (pre-existing).
- Recovered sessions appended behind a torn final record.
- Reload left the old journal (discarded edits came back on recovery);
reload discarded unsaved edits without confirmation and "reloaded" a
deleted file as empty.
- Two buffers (duplicate open, save-as onto an open file) or two
sessions shared one journal; closing/saving one deleted the other's
live journal. Journals are now flock()ed; a session never removes a
journal it does not own; unnamed journals include the pid; distinct
paths no longer map to one swap name.
- Journals now record the base file's size, mtime and CRC-32 and are
not replayed onto a file that changed afterwards.
- Quit checked only the current buffer; Enter at "Recover?" / "Save
changes?" took the destructive branch; the close prompt's save
overwrote on-disk changes; save-as overwrote other files silently.
- Kill-line with a count, stale marks in kill-region, commands running
behind an open prompt (read-only bypass, wrong buffer closed), paste
into a prompt, nested undo groups, UTF-8 splitting in word motions.
- Save: fsync retried after failure (could report success for lost
data); new files got 0600; symlinks replaced; hard links split;
owner and setuid/setgid bits lost; umask race with the writer thread.
- Writer-thread fsync raced with closes (fd reuse; kept a closed
journal's lock alive); crc32 table init race; Close() retried EINTR.
**Performance**
- Motion/delete commands rebuilt every line after an edit (80-90 ms per
keystroke on 500k lines; C-u 3000 C-d took 273 s, now 0.25 s).
- Line index invalidated by consolidation; incremental index added.
- Idle redraw every 16 ms rescanned long lines (one core busy); paste
handled one key per frame; RowsView retained unbounded copies.
**Fourth round (stress testing)**
- Undo/redo of multi-line text left the cursor past the end of the
line; later edits and their undo records went astray and
delete-word-prev read out of bounds (ASan).
- A recovered buffer looked clean after one edit and its undo; closing
it discarded the recovered text and journal. Save-and-quit did not
record the saved state; stale visual-line selections pointed past the
end of the buffer; open undo groups survived an exception.
- A reload that ran out of memory emptied the buffer, and the next save
truncated the file. Exceptions from deferred opens ended the editor.
A leftover hard-link copy blocked every later save; a file in a
read-only directory could not be saved.
- The journal writer's periodic fsync held the lock every keystroke
needs (typing froze on slow disks); every open and save re-read the
whole file under that lock; two ThreadSanitizer races.
- Replace-all, yank and repeat counts issued one edit per match, line
or repetition (quadratic: minutes for 229k matches, now 0.14 s).
- With LANG unset, multibyte input was dropped. Renderer caches and the
reload confirmation were keyed on buffer addresses, which are reused
after a close; a reload confirmation survived other commands; a swap
recovery prompt from a deferred open was not drawn until a key.
**Fifth round (review of the fourth round's changes)**
- The save fallbacks added in round four (write in place when no temp
file or hard-link copy can be made) also fired on ENOSPC/EDQUOT/EIO,
truncating the file and then failing: the file on disk was left
corrupt. They now apply only when the directory refuses a new name
(EACCES, EPERM, ENAMETOOLONG); otherwise the save fails and the file
is untouched.
- Whole-buffer replace recorded the entire old and new text for undo on
every run (about 150 MB more per replace-all on a 50 MB file); only
the span between the common prefix and suffix is now recorded.
- Regex search silently never found matches on lines over 20,000 bytes;
the skip now applies only to search-as-you-type (at 2,000 bytes, where
the worst case is well under a second), says so in the status line,
and moving to the next/previous match searches every line.
- After a lost journal record, the recovery checkpoint was requested
only by later edits, at most once a second; if the first attempt
failed and the user then stopped typing, the journal stayed behind
indefinitely. The editor loop now retries it each second while idle
(this was also why SwapReplay_LostRecord_ResyncsWithCheckpoint was
flaky under load).
- Undoing C-k C-k (and redoing an inserted row) journaled the row and
its newline as two records; a checkpoint triggered by the first
already contained the newline, so recovery silently produced an
extra blank line.
- Visual-line yank with the cursor moved out of the selection (C-k a,
Enter) left it past the end of a line or the buffer, and text typed
there could not be undone. Every command now leaves the cursor
inside the buffer (checked in the command dispatcher).
- ENABLE_ASAN instrumented only C sources, i.e. none of the project;
it now applies to C++ as well. (Earlier "ASan clean" results in this
audit came from that uninstrumented build; the full suite has since
passed under a correctly instrumented ASan build with leak checks,
and the stress fuzzers ran with ASan and UBSan.)
**Sixth round (real terminal, file I/O, recovery end to end)**
- UTF-8 typed or pasted over a slow link lost characters: with the
input batch read at zero timeout, ncurses gave up on a character split
across reads and discarded the rest as invalid. kte now assembles
UTF-8 itself and waits for continuation bytes. (This regression came
from this audit's own input batching.)
- Visual-line (multi-line) insert, delete, backspace and newline used
the cursor's byte column on every selected line, splitting multibyte
characters on the others; the column is now carried in characters.
- Journal failures reached only the error log: a session locked out of
the journal by another kte (which only happens when the other opened
the file before this one's first edit), or whose journal writes
failed, silently had no or partial crash recovery. The status line now
says so (once per failure).
- Basenames over about 233 bytes produced a swap name over NAME_MAX, so
such files were never journaled. (The first fix capped the name at
exactly 255 bytes, which broke compaction's ".tmp" sibling and let
the journal grow without bound; it now leaves room for it.)
**Seventh round (highlighters, region and paragraph commands, buffers)**
- Indent-region on a selection ending in the empty row after a final
newline added a tab-only last line (empty lines are now skipped).
- Reflow emitted a prefix-only line before a first word longer than the
width (a blank line before a long URL, a bullet split from its text),
adding another on every reflow; joined paragraphs separated by a
whitespace-only line; and moved the CRs of CRLF lines into the middle
of lines.
- A counted backspace/delete that joined lines took several undos.
- The C++ highlighter scanned to the end of the line for a raw-string
'(' at every R", quadratic on long lines.
- Fuzzing every highlighter (incremental vs full), 20,000 random command
sequences, and 6,300 random buffer-list sessions found nothing else.
**Eighth round (reflow review; large-file performance)**
- Reflow's CRLF handling applied only when every line had a CR, so the
last paragraph of a CRLF file without a final newline still got CRs
mid-line; wrapping could start a line with "-" or "1.", which the next
reflow read as a list.
- Large files (26-105 MB): the stateful highlighter cached spans for
every row above the viewport (854 MB for a 78 MB C++ file after
jumping to the end; now 197 MB); search built and kept a string per
line on every keystroke after an edit (now one scan of the
materialized text: 42 -> 28 ms per keystroke on 26 MB); indent,
unindent and visual-line insert/delete/backspace/newline/yank made one
piece-table edit per row (1.1 s per keystroke with 5,000 rows
selected; now 4 ms, as one edit).
**Ninth round (review of the eighth round's changes)**
- No serious findings. Highlighting (cached vs fresh), search (against
the old per-line code, 3,200 random buffers) and the row-range edits
(differential against the previous commit, ~88k random steps: text
identical after every step; undo-all and journal replay exact) all
agreed. Redo-all after heavy branching can end on an older branch of
the undo tree (a state that did exist), as before.
**Follow-up: undo memory**
- Undo texts of 4 KiB or more are held as references to the piece
table's (append-only) storage instead of copies, verified byte for
byte when converted; undo/redo re-splice the stored bytes instead of
copying them into storage again. A replace-all session on a 26 MB file
ends at 181 MB instead of 332 MB; undo/redo cycles of a 6.6 MB kill
stay at 75 MB instead of growing by 6.6 MB each.
- History has a byte budget (default max(64 MiB, twice the buffer)):
over it, branches off the current path, then the oldest edits (whole
undo groups), then the redo line are dropped. If the saved state goes,
the buffer stays modified until the next save.
- replace_all_bytes clears undo history (it was recorded against, and
may reference, the previous content).
**Follow-up: search**
- Search finds the next match from the search origin (then from the
current match) and stops there; it no longer collects every match in
the buffer on every keystroke and next/previous. The i/N count stops
at 1,000 matches (and, for regex, after 4 MiB). On 26 MB: plain
keystroke 30 ms -> 0.1 ms, next 24 -> 3 ms, regex keystroke 434 ->
1.6 ms. Previous-match scans backwards in 1 MiB forward-searched
blocks (string_view::rfind: 511 ms -> 6 ms on 105 MB).
- Semantics change: the first match is the first one at or after the
cursor (it was the first in the buffer), and Left/Right also step
through matches in the regex replace prompt; Up/Down in a regex prompt
use the regex.
**Follow-up: journals for large buffers**
- Buffers over 16 MiB are checkpointed as CHKPT_BEGIN (size, CRC-32),
data chunks and CHKPT_END; replay applies the checkpoint only when it
is complete and its CRC matches, and treats an unfinished one as a
torn tail. Such buffers could not be checkpointed at all: a lost
record left the journal permanently behind and it was never
compacted. The journal header is now version 2 (version 1 journals
are still read; older kte builds will not read version 2).
- Checkpoint thresholds scale with the buffer: after edits amounting to
half its size, on the timer only after a sixteenth, compaction at
twice its size, gap retries spaced by size. A checkpoint that would
compact is written straight into the compacted journal.
- The recovery prompt keeps the content it replayed for the preview;
answering y installs it instead of replaying the journal again.
- The CRC of a base file over 64 MiB is computed by the journal writer
thread (it was skipped), so a touched but unchanged large file no
longer makes its journal unusable for recovery.
**Follow-up: regex engine**
- kte::Regex (RegexEngine.h) wraps PCRE2 with its JIT when libpcre2-8
is found (KTE_USE_PCRE2, default ON), std::regex otherwise. Search,
regex replace-all and the terminal and ImGui renderers use it (the Qt
frontend's highlighting still calls std::regex). PCRE2 has a match
limit, so catastrophic backtracking ("^(a+)+$") stops with a status
note instead of hanging, and does not recurse on the C stack, so its
per-keystroke line limit is 1 MiB instead of 2,000 bytes. Replacement
strings keep std::regex_replace semantics with either engine.
- 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 per keystroke.
- 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 it.
- Review of this work found, and these are fixed: with PCRE2, '.'
matched the CR of CRLF lines (regex replace-all deleted it) — PCRE2
now uses the ANYCRLF newline convention and $ matches only at the end;
$0, out-of-range $n and $nn in replacements now follow libstdc++;
plain search visits overlapping matches in both directions (the count
could read "2/1"); the recovery prompt replays again if the journal
changed while it was open; a version 1 journal continued after
recovery gets a chunked checkpoint only by being rewritten as
version 2.
**Known limitations (not fixed)**
- Without PCRE2 (std::regex builds), catastrophic regex backtracking
can still take very long; std::regex has no limit.
- Without PCRE2 on libc++ (macOS), '^' also matches where a search
resumes inside a line (libc++ ignores match_prev_avail, and
match_not_bol with it, for '^'), and '\b' never matches at the end of
a line. Search_NextPrevVisitReferenceMatches fails in that
configuration; the default macOS build uses PCRE2.
- Typing in a multi-megabyte single line costs O(line length) per
keystroke (cursor column computed by scanning the line).
- Highlighter edge cases B13 are unchanged. (B7, B8 and B10 were fixed
in the debt pass below, once the GUIs could be built and run.)
- Journals are created at the first edit, so a second kte that opens
the file before the first one edits it is not warned at open; the
session that is locked out is told at its first edit instead.
- A regex that matches rarely or not at all scans the whole buffer
(0.1 s on 105 MB with PCRE2, 1.6 s with std::regex).
---
## Debt and performance pass (2026-09-24)
After the merge of the fixes above. Both GUI frontends (ImGui and Qt) were
built in this environment and checked by screenshot under Xvfb; the ImGui
renderer changes are pixel-identical to before on a sample with tabs,
UTF-8, syntax colouring and search highlights.
**Performance**
- Typing near the top of a large file: each edit shifted every later line
start. The shift is now pending and applied on read; piece lookup
binary-searches prefix sums instead of walking the piece list. Typing on
line 10 of a 1M-line file, with a frame's worth of line reads per
keystroke: 0.52 -> 0.017 ms.
- A loaded file is adopted as the piece table's original storage: the
first keystroke no longer copies the whole file (140 ms at 200 MB), and
the read buffer is not copied again. Save writes the pieces instead of
materializing a second full copy that then stayed resident. An unedited
(single-piece) buffer is viewed in place; `GetLineView` no longer copies
the whole buffer after an edit when the line lies within one piece.
- Line reads decide "ends with a newline" from the line index instead of
fetching the last byte; the index is rebuilt with `memchr`.
- ImGui measured every span and the cursor from column 0 of the line
(quadratic on long lines); the terminal renderer rescanned spans per
byte; Qt compiled a `std::regex` per visible line per frame. All now
linear, with search-match ranges computed by one shared
`SearchHighlight`.
- The highlighter engine returns cached spans by reference and no longer
`dynamic_cast`s per call.
**Bugs found on the way**
- Qt: operators were drawn in the dark background colour (invisible);
tabs were 4 columns against the command layer's 8; text after a
multi-byte character was placed one column per byte; the editor's
column count was computed three different ways from painting and the
cell width was rounded, so on long lines the cursor drifted off the
text. Unhighlighted text between spans is now drawn.
- B7, B8 (above).
**Debt removed**
- `Buffer::Rows()` and the `Line` wrapper (tests use `GetLineString` or a
test-only `LinesForTests()`); `OptimizedSearch`, `UndoNodePool.h`, the
unused swap SPLIT/JOIN writers and several unused Editor/Buffer/PieceTable
members; mutexes that guarded only reads in single-threaded classes
(the claim that PieceTable reads were thread-safe was false).
- Consolidated: filetype detection and highlighter installation
(`Buffer::ApplyDetectedFiletype`, `InstallFiletypeHighlighter`), whole
buffer copies (`Buffer::Bytes`), character measurement
(`kte::MeasureChar`, `DisplayColumns`), span sanitizing
(`kte::SanitizeSpans`), word motion scans, the search reset, and the
regex engine's stack policy (`kte::Regex::RunGuarded`).
**Left as is**
- The C-k / ESC-meta / universal-argument key state machine is still
written once per frontend; unifying it is a behavioural change best done
with its own tests.
- The highlighter keeps one line state per row (about 40 bytes each) and
highlighters copy each line; both are modest next to the file itself.
- A long run of backspaces grows its undo record by prepending (quadratic
only in the length of one typed run).
---
## Follow-up: macOS build and Qt removal (2026-09-24)
The gate had only run on Linux (as root). On macOS it failed three
tests and the app bundle could not start.
- kge stopped at launch with "Failed loading SDL3 library": Homebrew's
sdl2 is sdl2-compat, which dlopen()s SDL3, and the bundle fixup copies
only linked libraries. SDL3 is now bundled when sdl2-compat is.
- Saving lost setuid/setgid when not running as root: writing the data
clears them, and the mode was applied before the write. Ownership and
mode now go on after the data; in-place writes restore the mode.
- Two tests used libc++'s std::regex as the reference, which misses
'\b' at the end of the subject and ignores match_prev_avail for '^'.
- After C-k, pressing Shift (for C-k C, C-k ^ or a shifted symbol) or
pressing Ctrl again (C-k C-d) ended the prefix as an unknown command
in kge; modifier keys alone are now ignored there (checked with
synthetic SDL key events against kge's objects).
- The Qt frontend was removed: unused, out of make-app-release since
March, not built by the gate, and its bundle could not start (no
platform plugin). Every renderer and input change had to be made in
it separately.
@@ -0,0 +1,549 @@
# Error Propagation Standardization Report
**Project:** kte (Kyle's Text Editor)
**Date:** 2026-02-17
**Auditor:** Error Propagation Standardization Review
**Language:** C++20
---
## Executive Summary
This report documents the standardization of error propagation patterns
across the kte codebase. Following the implementation of centralized
error handling (ErrorHandler), this audit identifies inconsistencies in
error propagation and provides concrete remediation recommendations.
**Key Findings:**
- **Dominant Pattern**: `bool + std::string &err` is used consistently
in Buffer and SwapManager for I/O operations
- **Inconsistencies**: PieceTable has no error reporting mechanism; some
internal helpers lack error propagation
- **Standard Chosen**: `bool + std::string &err` pattern (C++20 project,
std::expected not available)
- **Documentation**: Comprehensive error handling conventions added to
DEVELOPER_GUIDE.md
**Overall Assessment**: The codebase has a **solid foundation** with the
`bool + err` pattern used consistently in critical I/O paths. Primary
gaps are in PieceTable memory allocation error handling and some
internal helper functions.
---
## 1. CURRENT STATE ANALYSIS
### 1.1 Error Propagation Patterns Found
#### Pattern 1: `bool + std::string &err` (Dominant)
**Usage**: File I/O, swap operations, resource allocation
**Examples**:
- `Buffer::OpenFromFile(const std::string &path, std::string &err)` (
Buffer.h:72)
- `Buffer::Save(std::string &err)` (Buffer.h:74)
- `Buffer::SaveAs(const std::string &path, std::string &err)` (Buffer.h:
75)
- `Editor::OpenFile(const std::string &path, std::string &err)` (
Editor.h:536)
-
`SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)` (
Swap.h:104)
-
`SwapManager::open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` (
Swap.h:208)
-
`SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err)` (
Swap.h:212-213)
**Assessment**: ✅ **Excellent** - Consistent, well-implemented,
integrated with ErrorHandler
#### Pattern 2: `void` (State Changes)
**Usage**: Setters, cursor movement, flag toggles, internal state
modifications
**Examples**:
- `Buffer::SetCursor(std::size_t x, std::size_t y)` (Buffer.h:348)
- `Buffer::SetDirty(bool d)` (Buffer.h:368)
- `Buffer::SetMark(std::size_t x, std::size_t y)` (Buffer.h:387)
- `Buffer::insert_text(int row, int col, std::string_view text)` (
Buffer.h:545)
- `Buffer::delete_text(int row, int col, std::size_t len)` (Buffer.h:
547)
- `Editor::SetStatus(const std::string &msg)` (Editor.h:various)
**Assessment**: ✅ **Appropriate** - These operations are infallible
state changes
#### Pattern 3: `bool` without error parameter (Control Flow)
**Usage**: Validation checks, control flow decisions
**Examples**:
- `Editor::ProcessPendingOpens()` (Editor.h:544)
- `Editor::ResolveRecoveryPrompt(bool yes)` (Editor.h:558)
- `Editor::SwitchTo(std::size_t index)` (Editor.h:563)
- `Editor::CloseBuffer(std::size_t index)` (Editor.h:565)
**Assessment**: ✅ **Appropriate** - Success/failure is sufficient for
control flow
#### Pattern 4: No Error Reporting (PieceTable)
**Usage**: Memory allocation, text manipulation
**Examples**:
- `void PieceTable::Reserve(std::size_t newCapacity)` (PieceTable.h:71)
- `void PieceTable::Append(const char *s, std::size_t len)` (
PieceTable.h:75)
-
`void PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)` (
PieceTable.h:118)
- `char *PieceTable::Data()` (PieceTable.h:89-93) - returns nullptr on
allocation failure
**Assessment**: ⚠️ **Gap** - Memory allocation failures are not reported
---
## 2. STANDARDIZATION DECISION
### 2.1 Chosen Pattern: `bool + std::string &err`
**Rationale**:
1. **C++20 Project**: `std::expected` (C++23) is not available
2. **Existing Adoption**: Already used consistently in Buffer,
SwapManager, Editor for I/O operations
3. **Clear Semantics**: `bool` return indicates success/failure, `err`
provides details
4. **ErrorHandler Integration**: Works seamlessly with centralized error
logging
5. **Zero Overhead**: No exceptions, no dynamic allocation for error
paths
6. **Testability**: Easy to verify error messages in unit tests
**Alternative Considered**: `std::expected<T, std::string>` (C++23)
- **Rejected**: Requires C++23, would require major refactoring, not
available in current toolchain
### 2.2 Pattern Selection Guidelines
| Operation Type | Pattern | Example |
|---------------------|---------------------------|-----------------------------------------------------------------------------------|
| File I/O | `bool + std::string &err` | `Buffer::Save(std::string &err)` |
| Syscalls | `bool + std::string &err` | `open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` |
| Resource Allocation | `bool + std::string &err` | Future: `PieceTable::Reserve(std::size_t cap, std::string &err)` |
| Parsing/Validation | `bool + std::string &err` | `SwapManager::ReplayFile(Buffer &buf, const std::string &path, std::string &err)` |
| State Changes | `void` | `Buffer::SetCursor(std::size_t x, std::size_t y)` |
| Control Flow | `bool` (no err) | `Editor::SwitchTo(std::size_t index)` |
---
## 3. INCONSISTENCIES AND GAPS
### 3.1 PieceTable Memory Allocation (Severity: 6/10)
**Finding**: PieceTable methods that allocate memory (`Reserve`,
`Append`, `Insert`, `Data`) do not report allocation failures.
**Impact**:
- Memory allocation failures are silent
- `Data()` returns `nullptr` on failure, but callers may not check
- Large file operations could fail without user notification
**Evidence**:
```cpp
// PieceTable.h:71
void Reserve(std::size_t newCapacity); // No error reporting
// PieceTable.h:89-93
char *Data(); // Returns nullptr on allocation failure
```
**Remediation Priority**: **Medium** - Memory allocation failures are
rare on modern systems, but should be handled for robustness
**Recommended Fix**:
**Option 1: Add error parameter to fallible operations** (Preferred)
```cpp
// PieceTable.h
bool Reserve(std::size_t newCapacity, std::string &err);
bool Append(const char *s, std::size_t len, std::string &err);
bool Insert(std::size_t byte_offset, const char *text, std::size_t len, std::string &err);
// Returns nullptr on failure; check with HasMaterializationError()
char *Data();
bool HasMaterializationError() const;
std::string GetMaterializationError() const;
```
**Option 2: Use exceptions for allocation failures** (Not recommended)
PieceTable could throw `std::bad_alloc` on allocation failures, but this
conflicts with the project's error handling philosophy and would require
exception handling throughout the codebase.
**Option 3: Status quo with improved documentation** (Minimal change)
Document that `Data()` can return `nullptr` and callers must check. Add
assertions in debug builds.
```cpp
// PieceTable.h
// Returns pointer to materialized buffer, or nullptr if materialization fails.
// Callers MUST check for nullptr before dereferencing.
char *Data();
```
**Recommendation**: **Option 3** for now (document + assertions), *
*Option 1** if memory allocation errors become a concern in production.
### 3.2 Internal Helper Functions (Severity: 4/10)
**Finding**: Some internal helper functions in Swap.cc and Buffer.cc use
`bool` returns without error parameters.
**Examples**:
```cpp
// Swap.cc:562
static bool ensure_parent_dir(const std::string &path); // No error details
// Swap.cc:579
static bool write_header(int fd); // No error details
// Buffer.cc:101
static bool write_all_fd(int fd, const char *data, std::size_t len, std::string &err); // ✅ Good
```
**Impact**: Limited - These are internal helpers called by functions
that do report errors
**Remediation Priority**: **Low** - Callers already provide error
context
**Recommended Fix**: Add error parameters to internal helpers for
consistency
```cpp
// Swap.cc
static bool ensure_parent_dir(const std::string &path, std::string &err);
static bool write_header(int fd, std::string &err);
```
**Status**: **Deferred** - Low priority, callers already provide
adequate error context
### 3.3 Editor Control Flow Methods (Severity: 2/10)
**Finding**: Editor methods like `SwitchTo()`, `CloseBuffer()` return
`bool` without error details.
**Assessment**: ✅ **Appropriate** - These are control flow decisions
where success/failure is sufficient
**Remediation**: **None needed** - Current pattern is correct for this
use case
---
## 4. ERRORHANDLER INTEGRATION STATUS
### 4.1 Components with ErrorHandler Integration
✅ **Buffer** (Buffer.cc)
- `OpenFromFile()` - Reports file open, seek, read errors
- `Save()` - Reports write errors
- `SaveAs()` - Reports write errors
✅ **SwapManager** (Swap.cc)
- `report_error()` - All swap file errors reported
- Background thread errors captured and logged
- Errno captured for all syscalls
✅ **main** (main.cc)
- Top-level exception handler reports Critical errors
- Both `std::exception` and unknown exceptions captured
### 4.2 Components Without ErrorHandler Integration
⚠️ **PieceTable** (PieceTable.cc)
- No error reporting mechanism
- Memory allocation failures are silent
⚠️ **Editor** (Editor.cc)
- File operations delegate to Buffer (✅ covered)
- Control flow methods don't need error reporting (✅ appropriate)
⚠️ **Command** (Command.cc)
- Commands use `Editor::SetStatus()` for user-facing messages
- No ErrorHandler integration for command failures
- **Assessment**: Commands are user-initiated actions; status messages
are appropriate
---
## 5. DOCUMENTATION STATUS
### 5.1 Error Handling Conventions (DEVELOPER_GUIDE.md)
✅ **Added comprehensive section** covering:
- Three standard error propagation patterns
- Pattern selection guidelines with decision tree
- ErrorHandler integration requirements
- Code examples for file I/O, syscalls, background threads, top-level
handlers
- Anti-patterns and best practices
- Error log location and format
- Migration guide for updating existing code
**Location**: `docs/DEVELOPER_GUIDE.md` section 7
### 5.2 API Documentation
⚠️ **Gap**: Individual function documentation in headers could be
improved
**Recommendation**: Add brief comments to public APIs documenting error
behavior
```cpp
// Buffer.h
// Opens a file and loads its content into the buffer.
// Returns false on failure; err contains detailed error message.
// Errors are logged to ErrorHandler.
bool OpenFromFile(const std::string &path, std::string &err);
```
---
## 6. REMEDIATION RECOMMENDATIONS
### 6.1 High Priority (Severity 7-10)
**None identified** - Critical error handling gaps were addressed in
previous sessions:
- ✅ Top-level exception handler added (Severity 9/10)
- ✅ Background thread error reporting added (Severity 9/10)
- ✅ File I/O error checking added (Severity 8/10)
- ✅ Errno capture added to swap operations (Severity 7/10)
- ✅ Centralized error handling implemented (Severity 7/10)
### 6.2 Medium Priority (Severity 4-6)
#### 6.2.1 PieceTable Memory Allocation Error Handling (Severity: 6/10)
**Action**: Document that `Data()` can return `nullptr` and add debug
assertions
**Implementation**:
```cpp
// PieceTable.h
// Returns pointer to materialized buffer, or nullptr if materialization fails
// due to memory allocation error. Callers MUST check for nullptr.
char *Data();
// PieceTable.cc
char *PieceTable::Data() {
materialize();
assert(materialized_ != nullptr && "PieceTable materialization failed");
return materialized_;
}
```
**Effort**: Low (documentation + assertions)
**Risk**: Low (no API changes)
**Timeline**: Next maintenance cycle
#### 6.2.2 Add Error Parameters to Internal Helpers (Severity: 4/10)
**Action**: Add `std::string &err` parameters to `ensure_parent_dir()`
and `write_header()`
**Implementation**:
```cpp
// Swap.cc
static bool ensure_parent_dir(const std::string &path, std::string &err) {
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 (const std::exception &e) {
err = std::string("Failed to create directory: ") + e.what();
return false;
} catch (...) {
err = "Failed to create directory: unknown error";
return false;
}
}
```
**Effort**: Low (update 2 functions + call sites)
**Risk**: Low (internal helpers only)
**Timeline**: Next maintenance cycle
### 6.3 Low Priority (Severity 1-3)
#### 6.3.1 Add Function-Level Error Documentation (Severity: 3/10)
**Action**: Add brief comments to public APIs documenting error behavior
**Effort**: Medium (many functions to document)
**Risk**: None (documentation only)
**Timeline**: Ongoing as code is touched
#### 6.3.2 Add ErrorHandler Integration to Commands (Severity: 2/10)
**Action**: Consider logging command failures to ErrorHandler for
diagnostics
**Assessment**: **Not recommended** - Commands are user-initiated
actions; status messages are more appropriate than error logs
---
## 7. TESTING RECOMMENDATIONS
### 7.1 Error Handling Test Coverage
**Current State**:
- ✅ Swap file error handling tested (test_swap_edge_cases.cc)
- ✅ Buffer I/O error handling tested (test_buffer_io.cc)
- ⚠️ PieceTable allocation failure testing missing
**Recommendations**:
1. **Add PieceTable allocation failure tests** (if Option 1 from 3.1 is
implemented)
2. **Add ErrorHandler query tests** - Verify error logging and retrieval
3. **Add errno capture tests** - Verify errno is captured correctly in
syscall failures
### 7.2 Test Examples
```cpp
// test_error_handler.cc
TEST(ErrorHandler, LogsErrorsWithContext) {
ErrorHandler::Instance().Error("TestComponent", "Test error", "test.txt");
EXPECT_TRUE(ErrorHandler::Instance().HasErrors());
EXPECT_EQ(ErrorHandler::Instance().GetErrorCount(), 1);
std::string last = ErrorHandler::Instance().GetLastError();
EXPECT_TRUE(last.find("Test error") != std::string::npos);
EXPECT_TRUE(last.find("test.txt") != std::string::npos);
}
// test_piece_table.cc (if Option 1 implemented)
TEST(PieceTable, ReportsAllocationFailure) {
PieceTable pt;
std::string err;
// Attempt to allocate huge buffer
bool ok = pt.Reserve(SIZE_MAX, err);
EXPECT_FALSE(ok);
EXPECT_FALSE(err.empty());
}
```
---
## 8. MIGRATION CHECKLIST
For developers updating existing code to follow error handling
conventions:
- [ ] Identify all error-prone operations (file I/O, syscalls,
allocations)
- [ ] Add `std::string &err` parameter if not present
- [ ] Clear `err` at function start: `err.clear();`
- [ ] Capture `errno` immediately after syscall failures:
`int saved_errno = errno;`
- [ ] Build detailed error messages with context (paths, operation
details)
- [ ] Call `ErrorHandler::Instance().Error()` at all error sites
- [ ] Return `false` on failure, `true` on success
- [ ] Update all call sites to handle the error parameter
- [ ] Write unit tests that verify error handling
- [ ] Update function documentation to describe error behavior
---
## 9. SUMMARY AND NEXT STEPS
### 9.1 Achievements
✅ **Standardized on `bool + std::string &err` pattern** for error-prone
operations
✅ **Documented comprehensive error handling conventions** in
DEVELOPER_GUIDE.md
✅ **Identified and prioritized remaining gaps** (PieceTable, internal
helpers)
✅ **Integrated ErrorHandler** into Buffer, SwapManager, and main
✅ **Established clear pattern selection guidelines** for future
development
### 9.2 Remaining Work
**Medium Priority**:
1. Document PieceTable `Data()` nullptr behavior and add assertions
2. Add error parameters to internal helper functions
**Low Priority**:
3. Add function-level error documentation to public APIs
4. Add ErrorHandler query tests
### 9.3 Conclusion
The kte codebase has achieved **strong error handling consistency** with
the `bool + std::string &err` pattern used uniformly across critical I/O
paths. The centralized ErrorHandler provides comprehensive logging and
UI integration. Remaining gaps are minor and primarily affect edge
cases (memory allocation failures) that are rare in practice.
**Overall Grade**: **B+ (8.5/10)**
**Strengths**:
- Consistent error propagation in Buffer and SwapManager
- Comprehensive ErrorHandler integration
- Excellent documentation in DEVELOPER_GUIDE.md
- Errno capture for all syscalls
- Top-level exception handling
**Areas for Improvement**:
- PieceTable memory allocation error handling
- Internal helper function error propagation
- Function-level API documentation
The error handling infrastructure is **production-ready** and provides a
solid foundation for reliable operation and debugging.
-124
View File
@@ -1,124 +0,0 @@
Based on the project structure and the presence of files like
`imgui.ini`, `GUIFrontend.h`, and `TerminalFrontend.h`, here is an
analysis of the difficulty and challenges involved in adding a GTK or Qt
version of the GUI.
### **Executive Summary: Difficulty Level - Moderate**
The project is well-architected for this task. It already supports
multiple frontends (Terminal vs. GUI), meaning the "Core Logic" (
Buffers, Syntax, Commands) is successfully decoupled from the "View" (
Rendering/Input). However, the specific move from an **Immediate Mode**
GUI (likely Dear ImGui, implied by `imgui.ini` and standard naming
patterns) to a **Retained Mode** GUI (Qt/GTK) introduces specific
architectural frictions regarding the event loop and state management.
---
### **1. Architectural Analysis**
The existence of abstract interfaces—likely `Frontend`, `Renderer`, and
`InputHandler`—is the biggest asset here.
* **Current State:**
* **Abstract Layer:** `Frontend.h`, `Renderer.h`, `InputHandler.h`
likely define the contract.
* **Implementations:**
* `Terminal*` files implement the TUI (likely ncurses or VT100).
* `GUI*` files (currently ImGui) implement the graphical
version.
* **The Path Forward:**
* You would create `QtFrontend`, `QtRenderer`, `QtInputHandler` (or
GTK equivalents).
* Because the core logic (`Editor.cc`, `Buffer.cc`) calls these
interfaces, you theoretically don't need to touch the core text
manipulation code.
### **2. Key Challenges**
#### **A. The Event Loop Inversion (Main Challenge)**
* **Current (ImGui):** Typically, the application owns the loop:
`while (running) { HandleInput(); Update(); Render(); }`. The
application explicitly tells the GUI to draw every frame.
* **Target (Qt/GTK):** The framework owns the loop: `app.exec()` or
`gtk_main()`. The framework calls *you* when events happen.
* **Difficulty:** You will need to refactor `main.cc` or the entry point
to hand over control to the Qt/GTK application object. The Editor's "
tick" function might need to be connected to a timer or an idle event
in the new framework to ensure logic updates happen.
#### **B. Rendering Paradigm: Canvas vs. Widgets**
* **The "Easy" Way (Custom Canvas):**
* Implement the `QtRenderer` by subclassing `QWidget` and overriding
`paintEvent`.
* Use `QPainter` (or Cairo in GTK) to draw text, cursors, and
selections exactly where the `Renderer` interface says to.
* **Pros:** Keeps the code similar to the current ImGui/Terminal
renderers.
* **Cons:** You lose native accessibility and some native "feel" (
scrolling physics, native text context menus).
* **The "Hard" Way (Native Widgets):**
* Trying to map an internal `Buffer` directly to a `QTextEdit` or
`GtkTextView`.
* **Difficulty:** This is usually very hard because the Editor core
likely manages its own cursor, selection, and syntax highlighting.
Syncing that internal state with a complex native widget often
leads to conflicts.
* **Recommendation:** Stick to the "Custom Canvas" approach (drawing
text manually on a surface) to preserve the custom editor
behavior (vim-like modes, specific syntax highlighting).
#### **C. Input Handling**
* **Challenge:** Mapping Qt/GTK key events to the internal `Keymap`.
* **Detail:** ImGui and Terminal libraries often provide raw scancodes
or simple chars. Qt/GTK provide complex Event objects. You will need a
translation layer in `QtInputHandler::keyPressEvent` that converts
`Qt::Key_Escape` -> `KKey::Escape` (or your internal equivalent).
### **3. Portability of Assets**
#### **Themes (Colors)**
* **Feasibility:** High.
* **Approach:** `GUITheme.h` likely contains structs with RGB/Hex
values. Qt supports stylesheets (QSS) and GTK uses CSS. You can write
a converter that reads your current theme configuration and generates
a CSS string to apply to your window, or simply use the RGB values
directly in your custom `QPainter`/Cairo drawing logic.
#### **Fonts**
* **Feasibility:** Moderate.
* **Approach:**
* **ImGui:** Usually loads a TTF into a texture atlas.
* **Qt/GTK:** Uses the system font engine (Freetype/Pango).
* **Challenge:** You won't use the texture atlas anymore. You will
simply request a font family and size (e.g.,
`QFont("JetBrains Mono", 12)`). You may need to ensure your custom
renderer calculates character width/height metrics correctly using
`QFontMetrics` (Qt) or `PangoLayout` (GTK) to align the grid
correctly.
### **4. Summary Recommendation**
If you proceed, **Qt** is generally considered easier to integrate with
C++ projects than GTK (which is C-based, though `gtkmm` exists).
1. **Create a `QtFrontend`** class inheriting from `Frontend`.
2. **Create a `QtWindow`** class inheriting from `QWidget`.
3. **Implement `QtRenderer`** that holds a pointer to the `QtWindow`.
When the core calls `DrawText()`, `QtRenderer` should queue that
command or draw directly to the widget's paint buffer.
4. **Refactor `main.cc`** to instantiate `QApplication` instead of the
current manual loop.
---
Note (2025-12): The Qt frontend defers all key processing to the
existing command subsystem and keymaps, mirroring the ImGui path. There
are no Qt-only keybindings; `QtInputHandler` translates Qt key events
into the shared keymap flow (C-k prefix, Ctrl chords, ESC/Meta,
universal-argument digits, printable insertion).
+9 -4
View File
@@ -23,29 +23,34 @@ Current themes (alphabetically):
- **gruvbox** — Retro groove color scheme (light/dark variants) - **gruvbox** — Retro groove color scheme (light/dark variants)
- **kanagawa-paper** — Inspired by traditional Japanese art - **kanagawa-paper** — Inspired by traditional Japanese art
- **lcars** — Star Trek LCARS interface style - **lcars** — Star Trek LCARS interface style
- **leuchtturm** — Modern, clean theme (light/dark variants)
- **nord** — Arctic, north-bluish color palette - **nord** — Arctic, north-bluish color palette
- **old-book** — Sepia-toned vintage book aesthetic (light/dark - **old-book** — Sepia-toned vintage book aesthetic (light/dark
variants) variants)
- **orbital** — Space-themed dark palette - **orbital** — Space-themed dark palette
- **plan9** — Minimalist Plan 9 from Bell Labs inspired - **plan9** — Minimalist Plan 9 from Bell Labs inspired
- **solarized** — Ethan Schoonover's Solarized (light/dark variants) - **solarized** — Ethan Schoonover's Solarized (light/dark variants)
- **tufte** — Edward Tufte-inspired minimalist theme (light/dark variants)
- **weyland-yutani** — Alien franchise corporate aesthetic - **weyland-yutani** — Alien franchise corporate aesthetic
- **zenburn** — Low-contrast, easy-on-the-eyes theme - **zenburn** — Low-contrast, easy-on-the-eyes theme
Configuration Configuration
------------- -------------
Themes are configured via `$HOME/.config/kte/kge.ini`: Themes are configured via `$HOME/.config/kte/kge.toml`:
```ini ```toml
theme = nord [appearance]
background = dark theme = "nord"
background = "dark"
``` ```
- `theme` — The theme name (e.g., "nord", "gruvbox", "solarized") - `theme` — The theme name (e.g., "nord", "gruvbox", "solarized")
- `background` — Either "dark" or "light" (for themes supporting both - `background` — Either "dark" or "light" (for themes supporting both
variants) variants)
Legacy `kge.ini` format is also supported (see CONFIG.md).
Themes can also be switched at runtime using the `:theme <name>` Themes can also be switched at runtime using the `:theme <name>`
command. command.
File diff suppressed because it is too large Load Diff
+21 -5
View File
@@ -3,8 +3,7 @@
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = outputs = { self, nixpkgs, ... }:
inputs@{ self, nixpkgs, ... }:
let let
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed; eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
pkgsFor = system: import nixpkgs { inherit system; }; pkgsFor = system: import nixpkgs { inherit system; };
@@ -13,9 +12,26 @@
packages = eachSystem (system: rec { packages = eachSystem (system: rec {
default = kte; default = kte;
full = kge; full = kge;
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; graphical-qt = false; }; kte = (pkgsFor system).callPackage ./default.nix { graphical = false; };
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; }; kge = (pkgsFor system).callPackage ./default.nix { graphical = true; };
qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; };
}); });
devShells = eachSystem (system:
let pkgs = pkgsFor system;
in {
default = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.kge ];
packages = with pkgs; [ gdb valgrind ];
};
terminal = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.kte ];
};
}
);
overlays.default = final: prev: {
kte = self.packages.${final.system}.kte;
kge = self.packages.${final.system}.kge;
};
}; };
} }
+1768
View File
File diff suppressed because it is too large Load Diff
+1203
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -3,6 +3,8 @@
#include "BerkeleyMono.h" #include "BerkeleyMono.h"
#include "BrassMono.h" #include "BrassMono.h"
#include "BrassMonoCode.h" #include "BrassMonoCode.h"
#include "CrimsonPro.h"
#include "ETBook.h"
#include "FiraCode.h" #include "FiraCode.h"
#include "Go.h" #include "Go.h"
#include "IBMPlexMono.h" #include "IBMPlexMono.h"
@@ -13,6 +15,7 @@
#include "IosevkaExtended.h" #include "IosevkaExtended.h"
#include "ShareTech.h" #include "ShareTech.h"
#include "SpaceMono.h" #include "SpaceMono.h"
#include "Spectral.h"
#include "Syne.h" #include "Syne.h"
#include "Triplicate.h" #include "Triplicate.h"
#include "Unispace.h" #include "Unispace.h"
+15
View File
@@ -45,6 +45,16 @@ InstallDefaultFonts()
BrassMonoCode::DefaultFontBoldCompressedData, BrassMonoCode::DefaultFontBoldCompressedData,
BrassMonoCode::DefaultFontBoldCompressedSize BrassMonoCode::DefaultFontBoldCompressedSize
)); ));
FontRegistry::Instance().Register(std::make_unique<Font>(
"crimsonpro",
CrimsonPro::DefaultFontRegularCompressedData,
CrimsonPro::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"etbook",
ETBook::DefaultFontRegularCompressedData,
ETBook::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>( FontRegistry::Instance().Register(std::make_unique<Font>(
"fira", "fira",
FiraCode::DefaultFontRegularCompressedData, FiraCode::DefaultFontRegularCompressedData,
@@ -95,6 +105,11 @@ InstallDefaultFonts()
SpaceMono::DefaultFontRegularCompressedData, SpaceMono::DefaultFontRegularCompressedData,
SpaceMono::DefaultFontRegularCompressedSize SpaceMono::DefaultFontRegularCompressedSize
)); ));
FontRegistry::Instance().Register(std::make_unique<Font>(
"spectral",
Spectral::DefaultFontRegularCompressedData,
Spectral::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>( FontRegistry::Instance().Register(std::make_unique<Font>(
"syne", "syne",
Syne::DefaultFontRegularCompressedData, Syne::DefaultFontRegularCompressedData,
+15
View File
@@ -1,10 +1,12 @@
#pragma once #pragma once
#include <algorithm>
#include <cassert> #include <cassert>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector>
#include "Font.h" #include "Font.h"
@@ -87,6 +89,19 @@ public:
} }
// Return all registered font names (sorted)
std::vector<std::string> FontNames() const
{
std::lock_guard lock(mutex_);
std::vector<std::string> names;
names.reserve(fonts_.size());
for (const auto &[name, _] : fonts_)
names.push_back(name);
std::sort(names.begin(), names.end());
return names;
}
// Current font name/size as last successfully loaded via LoadFont() // Current font name/size as last successfully loaded via LoadFont()
std::string CurrentFontName() const std::string CurrentFontName() const
{ {
+17146 -8581
View File
File diff suppressed because it is too large Load Diff
+3227
View File
File diff suppressed because it is too large Load Diff
+14698 -7357
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Name=kge
GenericName=Text Editor
Comment=kyle's graphical text editor
Exec=/usr/local/bin/kge %F
TryExec=/usr/local/bin/kge
Icon=kge
Terminal=false
StartupNotify=true
StartupWMClass=kge
Categories=Utility;TextEditor;
MimeType=text/plain;text/x-c;text/x-c++;text/x-python;text/x-go;text/x-rust;application/json;text/markdown;text/x-shellscript;
Keywords=text;editor;code;
+24
View File
@@ -0,0 +1,24 @@
# kge configuration
# Place at ~/.config/kte/kge.toml
[window]
fullscreen = false
columns = 80
rows = 42
[font]
# Default font and size
name = "default"
size = 18.0
# Font used in code mode (monospace)
code = "default"
# Font used in writing mode (proportional) — for .txt, .md, .rst, .org, .tex, etc.
writing = "crimsonpro"
[appearance]
theme = "nord"
# "dark" or "light" for themes with variants
background = "dark"
[editor]
syntax = true
+75 -8
View File
@@ -1,4 +1,5 @@
#include <clocale> #include <clocale>
#include <langinfo.h>
#include <cctype> #include <cctype>
#include <cerrno> #include <cerrno>
#include <cstdio> #include <cstdio>
@@ -12,6 +13,7 @@
#include <random> #include <random>
#include <thread> #include <thread>
#include <signal.h> #include <signal.h>
#include <filesystem>
#include <string> #include <string>
#include <unistd.h> #include <unistd.h>
#include <sys/stat.h> #include <sys/stat.h>
@@ -20,14 +22,11 @@
#include "Editor.h" #include "Editor.h"
#include "Frontend.h" #include "Frontend.h"
#include "TerminalFrontend.h" #include "TerminalFrontend.h"
#include "ErrorHandler.h"
#if defined(KTE_BUILD_GUI) #if defined(KTE_BUILD_GUI)
#if defined(KTE_USE_QT)
#include "QtFrontend.h"
#else
#include "ImGuiFrontend.h" #include "ImGuiFrontend.h"
#endif #endif
#endif
#ifndef KTE_VERSION_STR #ifndef KTE_VERSION_STR
@@ -111,10 +110,36 @@ RunStressHighlighter(unsigned seconds)
} }
// kte edits UTF-8 text. With LANG unset (common in containers and ssh
// sessions) the C locale cannot decode or encode non-ASCII characters, and
// typed or pasted non-ASCII text was dropped (with the rest of the paste).
// Use a UTF-8 character-type locale when the environment does not give one.
static void
ensure_utf8_ctype()
{
auto is_utf8 = [] {
std::string cs = nl_langinfo(CODESET);
for (auto &c: cs)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return cs == "utf-8" || cs == "utf8";
};
if (is_utf8())
return;
for (const char *name: {"C.UTF-8", "C.utf8", "en_US.UTF-8", "en_US.utf8"}) {
if (std::setlocale(LC_CTYPE, name) && is_utf8())
return;
}
}
int int
main(int argc, char *argv[]) main(int argc, char *argv[])
{ {
std::setlocale(LC_ALL, ""); std::setlocale(LC_ALL, "");
ensure_utf8_ctype();
// Ensure the error handler (and its log file) is initialised early.
kte::ErrorHandler::Instance();
Editor editor; Editor editor;
@@ -181,10 +206,13 @@ main(int argc, char *argv[])
return RunStressHighlighter(stress_seconds); return RunStressHighlighter(stress_seconds);
} }
// Top-level exception handler to prevent data loss and ensure cleanup
try {
// Determine frontend // Determine frontend
#if !defined(KTE_BUILD_GUI) #if !defined(KTE_BUILD_GUI)
if (req_gui) { if (req_gui) {
std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed." << std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed."
<<
std::endl; std::endl;
return 2; return 2;
} }
@@ -196,6 +224,8 @@ main(int argc, char *argv[])
use_gui = false; use_gui = false;
} else { } else {
// Default depends on build target: kge defaults to GUI, kte to terminal // Default depends on build target: kge defaults to GUI, kte to terminal
#if defined(KTE_DEFAULT_GUI) #if defined(KTE_DEFAULT_GUI)
use_gui = true; use_gui = true;
@@ -246,7 +276,18 @@ main(int argc, char *argv[])
// Fall through: not a +number, treat as filename starting with '+' // Fall through: not a +number, treat as filename starting with '+'
} }
const std::string path = arg; // Resolve to absolute path now, before any
// chdir (macOS GUI changes CWD to HOME before
// deferred opens are processed).
std::string path = arg;
try {
std::filesystem::path p(path);
if (p.is_relative()) {
path = std::filesystem::absolute(p).string();
}
} catch (...) {
// Fall through with original path
}
editor.RequestOpenFile(path, pending_line); editor.RequestOpenFile(path, pending_line);
pending_line = 0; // consumed (if set) pending_line = 0; // consumed (if set)
} }
@@ -271,6 +312,20 @@ main(int argc, char *argv[])
fe = std::make_unique<TerminalFrontend>(); fe = std::make_unique<TerminalFrontend>();
} }
// Guarantee Shutdown() runs on every exit path from here on (normal
// return, early return, or exception unwinding e.g. from Init/Execute/
// Step) so a crash never leaves the terminal in raw/cbreak mode.
struct FrontendShutdownGuard {
Frontend *fe;
~FrontendShutdownGuard()
{
if (fe)
fe->Shutdown();
}
} shutdown_guard{fe.get()};
#if defined(KTE_BUILD_GUI) && defined(__APPLE__) #if defined(KTE_BUILD_GUI) && defined(__APPLE__)
if (use_gui) { if (use_gui) {
/* likely using the .app, so need to cd */ /* likely using the .app, so need to cd */
@@ -299,7 +354,19 @@ main(int argc, char *argv[])
fe->Step(editor, running); fe->Step(editor, running);
} }
fe->Shutdown();
return 0; return 0;
} catch (const std::exception &e) {
std::string msg = std::string("Unhandled exception: ") + e.what();
kte::ErrorHandler::Instance().Critical("main", msg);
std::cerr << "\n*** FATAL ERROR ***\n"
<< "kte encountered an unhandled exception: " << e.what() << "\n"
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
return 1;
} catch (...) {
kte::ErrorHandler::Instance().Critical("main", "Unknown exception");
std::cerr << "\n*** FATAL ERROR ***\n"
<< "kte encountered an unknown exception.\n"
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
return 1;
}
} }
-18
View File
@@ -14,21 +14,3 @@ zip -r kge.app.zip kge.app
sha256sum kge.app.zip sha256sum kge.app.zip
open . open .
cd .. cd ..
mkdir -p cmake-build-release-qt
cmake -S . -B cmake-build-release-qt -DBUILD_GUI=ON -DKTE_USE_QT=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
cd cmake-build-release-qt
make clean
rm -fr kge.app* kge-qt.app*
make
mv -f kge.app kge-qt.app
# Use the same Qt's macdeployqt as used for building; ensure it overwrites in-bundle paths
macdeployqt kge-qt.app -always-overwrite -verbose=3
# Run CMake BundleUtilities fixup to internalize non-Qt dylibs and rewrite install names
cmake -DAPP_BUNDLE="$(pwd)/kge-qt.app" -P "${PWD%/*}/cmake/fix_bundle.cmake"
zip -r kge-qt.app.zip kge-qt.app
sha256sum kge-qt.app.zip
open .
cd ..
+1
View File
@@ -22,5 +22,6 @@ fi
git tag "${KTE_VERSION}" git tag "${KTE_VERSION}"
git push && git push --tags git push && git push --tags
git push github && git push github --tags
( ./make-app-release ) ( ./make-app-release )
+7 -5
View File
@@ -60,11 +60,10 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
const LineState &prev, const LineState &prev,
std::vector<HighlightSpan> &out) const std::vector<HighlightSpan> &out) const
{ {
const auto &rows = buf.Rows();
StatefulHighlighter::LineState state = prev; StatefulHighlighter::LineState state = prev;
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return state; return state;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
if (s.empty()) if (s.empty())
return state; return state;
@@ -164,11 +163,14 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
if (c == 'R' && i + 1 < n && s[i + 1] == '"') { if (c == 'R' && i + 1 < n && s[i + 1] == '"') {
int k = i + 2; int k = i + 2;
std::string delim; std::string delim;
while (k < n && s[k] != '(') { // A raw-string delimiter is at most 16 characters (and has no
// spaces, backslashes or parentheses); scanning the rest of the
// line for '(' at every R" made long lines quadratic.
while (k < n && s[k] != '(' && delim.size() <= 16) {
delim.push_back(s[k]); delim.push_back(s[k]);
++k; ++k;
} }
if (k < n && s[k] == '(') { if (k < n && s[k] == '(' && delim.size() <= 16) {
int body_start = k + 1; int body_start = k + 1;
std::string needle = ")" + delim + "\""; std::string needle = ")" + delim + "\"";
auto pos = s.find(needle, static_cast<std::size_t>(body_start)); auto pos = s.find(needle, static_cast<std::size_t>(body_start));
+2 -3
View File
@@ -40,10 +40,9 @@ ErlangHighlighter::ErlangHighlighter()
void void
ErlangHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const ErlangHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
const auto &rows = buf.Rows(); if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
return; return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
int i = 0; int i = 0;
+2 -3
View File
@@ -40,10 +40,9 @@ ForthHighlighter::ForthHighlighter()
void void
ForthHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const ForthHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
const auto &rows = buf.Rows(); if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
return; return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
int i = 0; int i = 0;
+37 -5
View File
@@ -46,15 +46,45 @@ GoHighlighter::GoHighlighter()
void void
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
const auto &rows = buf.Rows(); StatefulHighlighter::LineState prev;
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) (void) HighlightLineStateful(buf, row, prev, out);
return; }
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
StatefulHighlighter::LineState
GoHighlighter::HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const
{
StatefulHighlighter::LineState state = prev;
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return state;
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
int i = 0; int i = 0;
int bol = 0; int bol = 0;
while (bol < n && (s[bol] == ' ' || s[bol] == '\t')) while (bol < n && (s[bol] == ' ' || s[bol] == '\t'))
++bol; ++bol;
// Continue a multi-line block comment from the previous line.
if (state.in_block_comment) {
int j = i;
while (i + 1 < n) {
if (s[i] == '*' && s[i + 1] == '/') {
i += 2;
push(out, j, i, TokenKind::Comment);
state.in_block_comment = false;
break;
}
++i;
}
if (state.in_block_comment) {
push(out, j, n, TokenKind::Comment);
return state;
}
}
// line comment // line comment
while (i < n) { while (i < n) {
char c = s[i]; char c = s[i];
@@ -83,7 +113,8 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
break; state.in_block_comment = true;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -153,5 +184,6 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+6 -1
View File
@@ -5,12 +5,17 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class GoHighlighter final : public LanguageHighlighter { class GoHighlighter final : public StatefulHighlighter {
public: public:
GoHighlighter(); GoHighlighter();
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override; void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
LineState HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const override;
private: private:
std::unordered_set<std::string> kws_; std::unordered_set<std::string> kws_;
std::unordered_set<std::string> types_; std::unordered_set<std::string> types_;
+78 -166
View File
@@ -1,194 +1,122 @@
#include "HighlighterEngine.h" #include "HighlighterEngine.h"
#include "../Buffer.h" #include "../Buffer.h"
#include "LanguageHighlighter.h" #include "LanguageHighlighter.h"
#include <thread>
#include <algorithm>
namespace kte { namespace kte {
HighlighterEngine::HighlighterEngine() = default; HighlighterEngine::HighlighterEngine() = default;
HighlighterEngine::~HighlighterEngine() HighlighterEngine::~HighlighterEngine() = default;
{
// stop background worker
if (worker_running_.load()) {
{
std::lock_guard<std::mutex> lock(mtx_);
worker_running_.store(false);
has_request_ = true; // wake it up to exit
}
cv_.notify_one();
if (worker_.joinable())
worker_.join();
}
}
void void
HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl) HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl)
{ {
std::lock_guard<std::mutex> lock(mtx_);
hl_ = std::move(hl); hl_ = std::move(hl);
cache_.clear(); clear_caches_locked();
state_cache_.clear();
state_last_contig_.clear();
} }
LineHighlight void
HighlighterEngine::clear_caches_locked() const
{
cache_.clear();
states_.clear();
}
void
HighlighterEngine::invalidate_from_locked(int row) const
{
if (row < 0)
row = 0;
cache_.erase(cache_.lower_bound(row), cache_.end());
if (states_.size() > static_cast<std::size_t>(row))
states_.resize(static_cast<std::size_t>(row));
}
const LineHighlight &
HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const
{ {
std::unique_lock<std::mutex> lock(mtx_); if (!have_version_ || buf_version != version_) {
auto it = cache_.find(row); // Content changed without OnEdit: nothing cached can be trusted.
if (it != cache_.end() && it->second.version == buf_version) { clear_caches_locked();
return it->second; // return by value (copy) version_ = buf_version;
have_version_ = true;
} }
if (auto it = cache_.find(row); it != cache_.end())
return it->second;
// We'll compute into a local result to avoid exposing references to cache
LineHighlight result; LineHighlight result;
result.version = buf_version; result.version = buf_version;
result.spans.clear(); if (hl_ && row >= 0 && !hl_->Stateful()) {
hl_->HighlightLine(buf, row, result.spans);
} else if (hl_ && row >= 0) {
const auto *stateful = static_cast<const StatefulHighlighter *>(hl_.get());
// Continue from the last row with a known end state (or the row
// just above the target, if that is known), caching every row's
// spans and state along the way so neighbouring rows are cache hits.
const int known = static_cast<int>(states_.size()) - 1;
const int start = std::min(known, row - 1);
StatefulHighlighter::LineState state = (start >= 0) ? states_[static_cast<std::size_t>(start)]
: StatefulHighlighter::LineState{};
const int nrows = static_cast<int>(buf.Nrows());
for (int r = start + 1; r <= row && r < nrows; ++r) {
LineHighlight lh;
lh.version = buf_version;
state = stateful->HighlightLineStateful(buf, r, state, lh.spans);
if (static_cast<std::size_t>(r) == states_.size())
states_.push_back(state);
else
states_[static_cast<std::size_t>(r)] = state;
if (r == row)
result = std::move(lh);
else if (row - r <= kCacheNear)
cache_.insert_or_assign(r, std::move(lh));
// Rows further above only needed their end state (kept in
// states_): caching their spans too held about 10x the file size
// in memory after jumping to the end of a large file.
}
}
// Trim first: the entry returned must survive until the next call.
trim_cache_locked(row);
return cache_.insert_or_assign(row, std::move(result)).first->second;
}
if (!hl_) {
// Cache empty result and return it
cache_[row] = result;
return result;
}
// Copy shared_ptr-like raw pointer for use outside critical sections void
LanguageHighlighter *hl_ptr = hl_.get(); HighlighterEngine::trim_cache_locked(const int row) const
bool is_stateful = dynamic_cast<StatefulHighlighter *>(hl_ptr) != nullptr; {
if (cache_.size() <= kCacheMax)
if (!is_stateful) { return;
// Stateless fast path: we can release the lock while computing to reduce contention cache_.erase(cache_.begin(), cache_.lower_bound(row - kCacheNear));
lock.unlock(); cache_.erase(cache_.upper_bound(row + kCacheNear), cache_.end());
hl_ptr->HighlightLine(buf, row, result.spans);
// Update cache and return
std::lock_guard<std::mutex> gl(mtx_);
cache_[row] = result;
return result;
}
// Stateful path: we need to walk from a known previous state. Keep lock while consulting caches,
// but release during heavy computation.
auto *stateful = static_cast<StatefulHighlighter *>(hl_ptr);
StatefulHighlighter::LineState prev_state;
int start_row = -1;
if (!state_cache_.empty()) {
// linear search over map (unordered), track best candidate
int best = -1;
for (const auto &kv: state_cache_) {
int r = kv.first;
// Only use cached state if it's for the current version and row still exists
if (r <= row - 1 && kv.second.version == buf_version) {
// Validate that the cached row index is still valid in the buffer
if (r >= 0 && static_cast<std::size_t>(r) < buf.Rows().size()) {
if (r > best)
best = r;
}
}
}
if (best >= 0) {
start_row = best;
prev_state = state_cache_.at(best).state;
}
}
// We'll compute states and the target line's spans without holding the lock for most of the work.
// Create a local copy of prev_state and iterate rows; we will update caches under lock.
lock.unlock();
StatefulHighlighter::LineState cur_state = prev_state;
for (int r = start_row + 1; r <= row; ++r) {
std::vector<HighlightSpan> tmp;
std::vector<HighlightSpan> &out = (r == row) ? result.spans : tmp;
auto next_state = stateful->HighlightLineStateful(buf, r, cur_state, out);
// Update state cache for r
std::lock_guard<std::mutex> gl(mtx_);
StateEntry se;
se.version = buf_version;
se.state = next_state;
state_cache_[r] = se;
cur_state = next_state;
}
// Store in cache and return by value
lock.lock();
cache_[row] = result;
return result;
} }
void void
HighlighterEngine::InvalidateFrom(int row) HighlighterEngine::InvalidateFrom(int row)
{ {
std::lock_guard<std::mutex> lock(mtx_); invalidate_from_locked(row);
if (cache_.empty())
return;
// Simple implementation: erase all rows >= row
for (auto it = cache_.begin(); it != cache_.end();) {
if (it->first >= row)
it = cache_.erase(it);
else
++it;
}
if (!state_cache_.empty()) {
for (auto it = state_cache_.begin(); it != state_cache_.end();) {
if (it->first >= row)
it = state_cache_.erase(it);
else
++it;
}
}
} }
void void
HighlighterEngine::ensure_worker_started() const HighlighterEngine::OnEdit(int row, std::uint64_t buf_version)
{ {
if (worker_running_.load()) if (have_version_)
return; invalidate_from_locked(row);
worker_running_.store(true); version_ = buf_version;
worker_ = std::thread([this]() { have_version_ = true;
this->worker_loop();
});
}
void
HighlighterEngine::worker_loop() const
{
std::unique_lock<std::mutex> lock(mtx_);
while (worker_running_.load()) {
cv_.wait(lock, [this]() {
return has_request_ || !worker_running_.load();
});
if (!worker_running_.load())
break;
WarmRequest req = pending_;
has_request_ = false;
// 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 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) {
// 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);
}
}
lock.lock();
}
} }
void void
HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version, HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version,
int warm_margin) const int /*warm_margin*/) const
{ {
if (row_count <= 0) if (row_count <= 0)
return; return;
@@ -204,21 +132,5 @@ HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_co
for (int r = start; r <= end; ++r) { for (int r = start; r <= end; ++r) {
(void) GetLine(buf, r, buf_version); (void) GetLine(buf, r, buf_version);
} }
// Enqueue background warm-around
int warm_start = std::max(0, start - warm_margin);
int warm_end = std::min(max_rows - 1, end + warm_margin);
{
std::lock_guard<std::mutex> lock(mtx_);
pending_.buf = &buf;
pending_.version = buf_version;
pending_.start_row = warm_start;
pending_.end_row = warm_end;
pending_.skip_first = start;
pending_.skip_last = end;
has_request_ = true;
}
ensure_worker_started();
cv_.notify_one();
} }
} // namespace kte } // namespace kte
+34 -46
View File
@@ -1,14 +1,11 @@
// HighlighterEngine.h - caching layer for per-line highlights // HighlighterEngine.h - caching layer for per-line highlights (main thread
// only, like the Buffer it reads)
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <map>
#include <memory> #include <memory>
#include <unordered_map>
#include <vector> #include <vector>
#include <mutex>
#include <condition_variable>
#include <atomic>
#include <thread>
#include "../Highlight.h" #include "../Highlight.h"
#include "LanguageHighlighter.h" #include "LanguageHighlighter.h"
@@ -24,14 +21,20 @@ public:
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl); void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
// Retrieve highlights for a given line and buffer version. // Highlights for a line at a buffer version, computed if not cached.
// Returns a copy to avoid lifetime issues across threads/renderers. // The reference is valid until the next call on this engine (a later
// If cache is stale, recompute using the current highlighter. // lookup may evict the entry); copy it to keep it longer.
LineHighlight GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const; const LineHighlight &GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
// Invalidate cached lines from row (inclusive) // Invalidate cached lines from row (inclusive)
void InvalidateFrom(int row); void InvalidateFrom(int row);
// The buffer changed at `row` and is now at `buf_version`. Highlighting
// of a row depends only on the rows above it, so rows before `row` stay
// cached. A version change that arrives without OnEdit (a path that
// changed content without reporting it) still clears the whole cache.
void OnEdit(int row, std::uint64_t buf_version);
bool HasHighlighter() const bool HasHighlighter() const
{ {
@@ -39,52 +42,37 @@ public:
} }
// Phase 3: viewport-first prefetch and background warming // Compute highlights for the visible range so drawing hits the cache.
// Compute only the visible range now, and enqueue a background warm-around task. // There is deliberately no background warming: a worker thread would read
// warm_margin: how many extra lines above/below to warm in the background. // the Buffer while the main thread edits it (and could outlive a moved
// Buffer), and the PieceTable is not safe for concurrent read and write.
// warm_margin is accepted for source compatibility and ignored.
void PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version, void PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version,
int warm_margin = 200) const; int warm_margin = 200) const;
private: private:
std::unique_ptr<LanguageHighlighter> hl_; std::unique_ptr<LanguageHighlighter> hl_;
// Simple cache by row index (mutable to allow caching in const GetLine) // Cached spans by row, valid for version_ (ordered so InvalidateFrom can
mutable std::unordered_map<int, LineHighlight> cache_; // drop a tail cheaply).
mutable std::map<int, LineHighlight> cache_;
// For stateful highlighters, remember per-line state (state after finishing that row) // For stateful highlighters: states_[r] is the state after row r, for
struct StateEntry { // every row 0..states_.size()-1 (always a contiguous prefix).
std::uint64_t version{0}; mutable std::vector<StatefulHighlighter::LineState> states_;
// Using the interface type; forward-declare via header
StatefulHighlighter::LineState state;
};
mutable std::unordered_map<int, StateEntry> state_cache_; // Buffer version the caches describe.
mutable std::uint64_t version_{0};
mutable bool have_version_{false};
// Track best known contiguous state row for a given version to avoid O(n) scans void clear_caches_locked() const;
mutable std::unordered_map<std::uint64_t, int> state_last_contig_;
// Thread-safety for caches and background worker state void invalidate_from_locked(int row) const;
mutable std::mutex mtx_;
// Background warmer // Span cache bounds: rows within kCacheNear of a requested row are
struct WarmRequest { // cached; past kCacheMax entries, those far from the current row go.
const Buffer *buf{nullptr}; static constexpr int kCacheNear = 1024;
std::uint64_t version{0}; static constexpr std::size_t kCacheMax = 16384;
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_; void trim_cache_locked(int row) const;
mutable std::thread worker_;
mutable std::atomic<bool> worker_running_{false};
mutable bool has_request_{false};
mutable WarmRequest pending_{};
void ensure_worker_started() const;
void worker_loop() const;
}; };
} // namespace kte } // namespace kte
+2 -3
View File
@@ -13,10 +13,9 @@ is_digit(char c)
void void
JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
const auto &rows = buf.Rows(); if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
return; return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
auto push = [&](int a, int b, TokenKind k) { auto push = [&](int a, int b, TokenKind k) {
if (b > a) if (b > a)
+2 -3
View File
@@ -25,10 +25,9 @@ LispHighlighter::LispHighlighter()
void void
LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
const auto &rows = buf.Rows(); if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
return; return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
int i = 0; int i = 0;
int bol = 0; int bol = 0;
+2 -3
View File
@@ -24,10 +24,9 @@ MarkdownHighlighter::HighlightLineStateful(const Buffer &buf, int row, const Lin
std::vector<HighlightSpan> &out) const std::vector<HighlightSpan> &out) const
{ {
StatefulHighlighter::LineState state = prev; StatefulHighlighter::LineState state = prev;
const auto &rows = buf.Rows(); if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
return state; return state;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
// Reuse in_block_comment flag as "in fenced code" state. // Reuse in_block_comment flag as "in fenced code" state.
+2 -3
View File
@@ -5,10 +5,9 @@ namespace kte {
void void
NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
const auto &rows = buf.Rows(); if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
return; return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
if (n <= 0) if (n <= 0)
return; return;

Some files were not shown because too many files have changed in this diff Show More