Compare commits

..
1 Commits
Author SHA1 Message Date
kyleandClaude Opus 4.8 e2b255901e 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:03 -07:00
98 changed files with 5464 additions and 10601 deletions
+58 -2
View File
@@ -1,2 +1,58 @@
Read AGENTS.md at the repository root before anything else. It is the only # Project Guidelines
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
@@ -1,106 +0,0 @@
# 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).
+240 -412
View File
@@ -99,26 +99,19 @@ 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 ByteChunks &chunks, std::string &err) write_all_fd(int fd, const char *data, std::size_t len, 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; err = std::string("Write failed: ") + std::strerror(errno);
err = std::string("Write failed: ") + std::strerror(errno); return false;
return false;
}
off += static_cast<std::size_t>(n);
} }
off += static_cast<std::size_t>(n);
} }
return true; return true;
} }
@@ -143,133 +136,9 @@ best_effort_fsync_dir(const std::string &path)
} }
// 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
write_in_place(const std::string &path, const ByteChunks &data, std::string &err) atomic_write_file(const std::string &path, const char *data, std::size_t len, 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();
@@ -292,40 +161,31 @@ atomic_write_file(const std::string &path_in, const ByteChunks &data, std::strin
}; };
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) { 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(saved_errno) + err; err = std::string("Failed to create temp file for save: ") + std::strerror(errno) + err;
} }
return false; return false;
} }
std::string tmp_path(buf.data()); std::string tmp_path(buf.data());
bool ok = write_all_fd(fd, data, err); // If the destination exists, carry over its permissions.
// Ownership and mode go on after the data: without privilege, both struct stat dst_st{};
// chown and write clear setuid/setgid, so the mode is applied last. if (::stat(path.c_str(), &dst_st) == 0) {
if (ok && dst_exists) { (void) kte::syscall::Fchmod(fd, dst_st.st_mode);
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);
} else if (ok) {
// mkstemp creates 0600; a new file gets the usual 0666 & ~umask.
(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 bool ok = write_all_fd(fd, data, len, err);
// the rename below would then replace a good file with a bad one. if (ok) {
if (ok && kte::syscall::Fsync(fd) != 0) { // Retry fsync on transient errors
err = std::string("fsync failed: ") + std::strerror(errno); auto fsync_fn = [&]() -> bool {
ok = false; return kte::syscall::Fsync(fd) == 0;
};
std::string fsync_err;
if (!kte::RetryOnTransientError(fsync_fn, kte::RetryPolicy::Aggressive(), fsync_err)) {
err = std::string("fsync failed: ") + std::strerror(errno) + fsync_err;
ok = false;
}
} }
(void) kte::syscall::Close(fd); (void) kte::syscall::Close(fd);
@@ -357,12 +217,15 @@ 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_;
@@ -372,16 +235,30 @@ Buffer::Buffer(const Buffer &other)
edit_mode_ = other.edit_mode_; edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_; 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_) {
InstallFiletypeHighlighter(); // Allocate engine and install an appropriate highlighter
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
}
} }
@@ -392,13 +269,15 @@ 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_;
@@ -407,8 +286,7 @@ Buffer::operator=(const Buffer &other)
edit_mode_ = other.edit_mode_; edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_; 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>();
@@ -416,8 +294,19 @@ 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_) {
InstallFiletypeHighlighter(); highlighter_ = std::make_unique<kte::HighlighterEngine>();
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;
} }
@@ -426,20 +315,18 @@ 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_))
{ {
@@ -447,17 +334,11 @@ Buffer::Buffer(Buffer &&other) noexcept
edit_mode_ = other.edit_mode_; edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_; 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_);
on_disk_identity_ = other.on_disk_identity_; rows_cache_dirty_ = other.rows_cache_dirty_;
// 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);
@@ -472,40 +353,32 @@ Buffer::operator=(Buffer &&other) noexcept
if (this == &other) if (this == &other)
return *this; return *this;
curx_ = other.curx_; curx_ = other.curx_;
cury_ = other.cury_; cury_ = other.cury_;
rowoffs_ = other.rowoffs_; rx_ = other.rx_;
coloffs_ = other.coloffs_; nrows_ = other.nrows_;
filename_ = std::move(other.filename_); rowoffs_ = other.rowoffs_;
is_file_backed_ = other.is_file_backed_; coloffs_ = other.coloffs_;
is_virtual_ = other.is_virtual_; rows_ = std::move(other.rows_);
id_ = other.id_; filename_ = std::move(other.filename_);
dirty_ = other.dirty_; is_file_backed_ = other.is_file_backed_;
read_only_ = other.read_only_; dirty_ = other.dirty_;
mark_set_ = other.mark_set_; read_only_ = other.read_only_;
mark_curx_ = other.mark_curx_; mark_set_ = other.mark_set_;
mark_cury_ = other.mark_cury_; mark_curx_ = other.mark_curx_;
visual_line_active_ = other.visual_line_active_; mark_cury_ = other.mark_cury_;
visual_line_anchor_y_ = other.visual_line_anchor_y_; undo_tree_ = std::move(other.undo_tree_);
visual_line_active_y_ = other.visual_line_active_y_; undo_sys_ = std::move(other.undo_sys_);
undo_tree_ = std::move(other.undo_tree_);
undo_sys_ = std::move(other.undo_sys_);
// Move edit mode + syntax/highlighting state // Move edit mode + syntax/highlighting state
edit_mode_ = other.edit_mode_; edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_; 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_);
on_disk_identity_ = other.on_disk_identity_; rows_cache_dirty_ = other.rows_cache_dirty_;
// 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);
@@ -544,95 +417,77 @@ 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.
std::error_code exists_ec; if (!std::filesystem::exists(norm)) {
const bool exists = std::filesystem::exists(norm, exists_ec); rows_.clear();
if (exists_ec) { nrows_ = 0;
// 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_ = 0; curx_ = cury_ = rx_ = 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();
// History recorded against the previous content no longer applies. rows_cache_dirty_ = true;
if (undo_sys_)
undo_sys_->clear();
MarkContentChanged();
return true; return true;
} }
// Open non-blocking so a named pipe cannot hang the editor, then accept std::ifstream in(norm, std::ios::in | std::ios::binary);
// regular files only: a directory reads as garbage (or throws), a FIFO if (!in) {
// blocks, and saving over a device node would replace it with a file. err = "Failed to open file: " + norm;
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); kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false; return false;
} }
// Read to EOF in chunks (the file may change size while we read). // Read entire file into PieceTable as-is
std::string data; std::string data;
try { in.seekg(0, std::ios::end);
data.reserve(static_cast<std::size_t>(st.st_size)); if (!in) {
char chunk[1 << 16]; err = "Failed to seek to end of file: " + norm;
for (;;) { kte::ErrorHandler::Instance().Error("Buffer", err, norm);
const ssize_t n = ::read(fd, chunk, sizeof(chunk)); return false;
if (n > 0) { }
data.append(chunk, static_cast<std::size_t>(n)); auto sz = in.tellg();
} else if (n == 0) { if (sz < 0) {
break; err = "Failed to get file size: " + norm;
} else if (errno != EINTR) { kte::ErrorHandler::Instance().Error("Buffer", err, norm);
err = "Failed to read file: " + norm + ": " + std::strerror(errno); return false;
(void) kte::syscall::Close(fd); }
kte::ErrorHandler::Instance().Error("Buffer", err, norm); if (sz > 0) {
return false; data.resize(static_cast<std::size_t>(sz));
} in.seekg(0, std::ios::beg);
if (!in) {
err = "Failed to seek to beginning of file: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
in.read(data.data(), static_cast<std::streamsize>(data.size()));
if (!in && !in.eof()) {
err = "Failed to read file: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
// Validate we read the expected number of bytes
const std::streamsize bytes_read = in.gcount();
if (bytes_read != static_cast<std::streamsize>(data.size())) {
err = "Partial read of file (expected " + std::to_string(data.size()) +
" bytes, got " + std::to_string(bytes_read) + "): " + norm;
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();
@@ -643,10 +498,9 @@ 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_ = 0; curx_ = cury_ = rx_ = 0;
rowoffs_ = coloffs_ = 0; rowoffs_ = coloffs_ = 0;
mark_set_ = false; mark_set_ = false;
mark_curx_ = mark_cury_ = 0; mark_curx_ = mark_cury_ = 0;
@@ -662,11 +516,13 @@ 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;
} }
ByteChunks chunks; const std::size_t sz = content_.Size();
content_.ForEachChunk([&](const char *d, std::size_t n) { const char *data = sz ? content_.Data() : nullptr;
chunks.emplace_back(d, n); if (sz && !data) {
}); err = "Internal error: buffer materialization failed";
if (!atomic_write_file(filename_, chunks, err)) { return false;
}
if (!atomic_write_file(filename_, data ? data : "", sz, err)) {
kte::ErrorHandler::Instance().Error("Buffer", err, filename_); kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
return false; return false;
} }
@@ -700,18 +556,19 @@ Buffer::SaveAs(const std::string &path, std::string &err)
out_path = path; out_path = path;
} }
ByteChunks chunks; const std::size_t sz = content_.Size();
content_.ForEachChunk([&](const char *d, std::size_t n) { const char *data = sz ? content_.Data() : nullptr;
chunks.emplace_back(d, n); if (sz && !data) {
}); err = "Internal error: buffer materialization failed";
if (!atomic_write_file(out_path, chunks, err)) { return false;
}
if (!atomic_write_file(out_path, data ? data : "", sz, err)) {
kte::ErrorHandler::Instance().Error("Buffer", err, out_path); kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
return false; 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;
@@ -743,54 +600,43 @@ 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());
edited_at_(row); rows_cache_dirty_ = true;
if (swap_rec_) if (swap_rec_)
swap_rec_->OnInsert(row, col, text); swap_rec_->OnInsert(row, col, text);
} }
} }
void
Buffer::ApplyDetectedFiletype()
{
EnsureHighlighter();
const std::string first = Nrows() > 0 ? GetLineString(0) : std::string();
const std::string ft = kte::HighlighterRegistry::DetectForPath(filename_, first);
SetFiletype(ft);
SetSyntaxEnabled(true);
InstallFiletypeHighlighter();
}
void
Buffer::InstallFiletypeHighlighter()
{
EnsureHighlighter();
auto hl = filetype_.empty() ? nullptr : kte::HighlighterRegistry::CreateFor(filetype_);
if (!hl)
hl = std::make_unique<kte::NullHighlighter>();
highlighter_->SetHighlighter(std::move(hl));
}
// ===== Adapter helpers for PieceTable-backed Buffer ===== // ===== Adapter helpers for PieceTable-backed Buffer =====
std::string
Buffer::Bytes() const
{
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 std::string_view
Buffer::GetLineView(std::size_t row) const Buffer::GetLineView(std::size_t row) const
{ {
const auto [start, end] = content_.GetLineRange(row); // [start,end) in bytes // Get byte range for the logical line and return a view into materialized data
return end > start ? content_.View(start, end - start) : std::string_view(); auto range = content_.GetLineRange(row); // [start,end) in bytes
const char *base = content_.Data(); // materializes if needed
if (!base)
return std::string_view();
const std::size_t start = range.first;
const std::size_t len = (range.second > range.first) ? (range.second - range.first) : 0;
return std::string_view(base + start, len);
}
void
Buffer::ensure_rows_cache() const
{
std::lock_guard<std::mutex> lock(buffer_mutex_);
if (!rows_cache_dirty_)
return;
rows_.clear();
const std::size_t lc = content_.LineCount();
rows_.reserve(lc);
for (std::size_t i = 0; i < lc; ++i) {
rows_.emplace_back(content_.GetLine(i));
}
// Keep nrows_ in sync for any legacy code that still reads it
const_cast<Buffer *>(this)->nrows_ = rows_.size();
rows_cache_dirty_ = false;
} }
@@ -805,17 +651,13 @@ Buffer::content_LineCount_() const
std::string std::string
Buffer::BytesForTests() const Buffer::BytesForTests() const
{ {
return Bytes(); const std::size_t sz = content_.Size();
} if (sz == 0)
return std::string();
const char *data = content_.Data();
std::vector<std::string> if (!data)
Buffer::LinesForTests() const return std::string();
{ 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
@@ -830,18 +672,51 @@ 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));
const std::size_t total = content_.Size(); std::size_t r = static_cast<std::size_t>(row);
const std::size_t actual = (total > start) ? std::min(len, total - start) : 0; std::size_t c = static_cast<std::size_t>(col);
if (actual == 0) std::size_t remaining = len;
return; const std::size_t lc = content_.LineCount();
content_.Delete(start, actual);
edited_at_(row); while (remaining > 0 && r < lc) {
if (swap_rec_) const std::string line = content_.GetLine(r); // logical line (without trailing '\n')
swap_rec_->OnDelete(row, col, actual); 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 actual = (total > start) ? (total - start) : 0;
if (actual == 0)
return;
content_.Delete(start, actual);
rows_cache_dirty_ = true;
if (swap_rec_)
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);
}
} }
@@ -857,7 +732,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);
edited_at_(row); rows_cache_dirty_ = true;
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));
} }
@@ -876,7 +751,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);
edited_at_(row); rows_cache_dirty_ = true;
if (swap_rec_) if (swap_rec_)
swap_rec_->OnDelete(row, col, 1); swap_rec_->OnDelete(row, col, 1);
} }
@@ -892,16 +767,12 @@ 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);
edited_at_(row); rows_cache_dirty_ = true;
if (swap_rec_) { if (swap_rec_) {
// One record: the first of two could trigger a journal checkpoint, // Avoid allocation: emit the row text insertion (if any) and the newline insertion.
// whose snapshot already holds the newline the second then added if (!text.empty())
// again on replay. swap_rec_->OnInsert(row, 0, text);
std::string line; swap_rec_->OnInsert(row, static_cast<int>(text.size()), std::string_view("\n", 1));
line.reserve(text.size() + 1);
line.append(text);
line.push_back('\n');
swap_rec_->OnInsert(row, 0, line);
} }
} }
@@ -923,7 +794,7 @@ Buffer::delete_row(int row)
if (actual == 0) if (actual == 0)
return; return;
content_.Delete(start, actual); content_.Delete(start, actual);
edited_at_(row); rows_cache_dirty_ = true;
if (swap_rec_) if (swap_rec_)
swap_rec_->OnDelete(row, 0, actual); swap_rec_->OnDelete(row, 0, actual);
} }
@@ -935,50 +806,7 @@ 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());
if (undo_sys_) rows_cache_dirty_ = true;
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);
} }
+206 -164
View File
@@ -5,7 +5,7 @@
* or scratch document and manages: * or scratch document and manages:
* *
* - Content storage: Uses PieceTable for efficient text operations * - Content storage: Uses PieceTable for efficient text operations
* - Cursor state: Current position (curx_, cury_) * - Cursor state: Current position (curx_, cury_), rendered column (rx_)
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display * - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
* - File backing: Optional association with a file on disk * - File backing: Optional association with a file on disk
* - Undo/Redo: Integrated UndoSystem for operation history * - Undo/Redo: Integrated UndoSystem for operation history
@@ -17,18 +17,23 @@
* *
* 1. Cursor coordinates: * 1. Cursor coordinates:
* - (curx_, cury_): Logical character position in the document * - (curx_, cury_): Logical character position in the document
* - rx_: Rendered column accounting for tab expansion
* *
* 2. File backing: * 2. File backing:
* - Buffers can be file-backed (associated with a path) or scratch (unnamed) * - Buffers can be file-backed (associated with a path) or scratch (unnamed)
* - File identity tracking detects external modifications * - File identity tracking detects external modifications
* *
* 3. Content access: * 3. Legacy Line wrapper:
* - Nrows(), GetLineString(): per-line access * - Buffer::Line provides a string-like interface for legacy command code
* - GetLineView(): line access via string_view (zero-copy within a piece) * - New code should prefer direct PieceTable operations
* - See DEVELOPER_GUIDE.md for migration guidance
*
* 4. Content access:
* - Rows(): Materialized line cache (legacy, being phased out)
* - GetLineView(): Zero-copy line access via string_view (preferred)
* - Direct PieceTable access for new editing operations * - Direct PieceTable access for new editing operations
*/ */
#pragma once #pragma once
#include <atomic>
#include <algorithm> #include <algorithm>
#include <cstddef> #include <cstddef>
@@ -44,6 +49,7 @@
#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. // Edit mode determines which font class is used for a buffer.
enum class EditMode { Code, Writing }; enum class EditMode { Code, Writing };
@@ -112,6 +118,12 @@ 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_();
@@ -130,7 +142,169 @@ public:
} }
// A line's text without its trailing newline. // Line wrapper used by legacy command paths.
// 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);
@@ -143,25 +317,10 @@ public:
} }
// View of a line's raw bytes, including its trailing '\n' if any. No copy // Zero-copy view of a line. Points into the materialized backing store; becomes
// when the line lies within one piece (an unedited stretch); a line that // invalid after subsequent edits. Use immediately.
// 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
{ {
@@ -175,7 +334,6 @@ public:
{ {
filename_ = name; filename_ = name;
is_file_backed_ = false; is_file_backed_ = false;
is_virtual_ = true;
} }
@@ -185,23 +343,6 @@ 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_;
@@ -234,6 +375,12 @@ 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;
@@ -243,17 +390,13 @@ 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_) {
// Whole-buffer content change (load, reload, replace): bump the version highlighter_->InvalidateFrom(0);
// and drop all cached highlighting. }
void MarkContentChanged() }
{
edited_at_(0);
} }
@@ -326,22 +469,15 @@ 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
{ {
const std::size_t y = visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_;
: visual_line_active_y_;
return clamp_row_(y);
} }
[[nodiscard]] std::size_t VisualLineEndY() const [[nodiscard]] std::size_t VisualLineEndY() const
{ {
const std::size_t y = visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_y_;
: visual_line_anchor_y_;
return clamp_row_(y);
} }
@@ -364,15 +500,6 @@ 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
{ {
@@ -421,25 +548,6 @@ 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;
@@ -471,15 +579,6 @@ 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)
{ {
@@ -507,55 +606,11 @@ 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, and clears undo history // This does not trigger swap or undo recording.
// (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();
@@ -563,10 +618,6 @@ 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
@@ -587,23 +638,22 @@ 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;
@@ -622,19 +672,11 @@ private:
// 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_;
}; };
+115 -1
View File
@@ -1 +1,115 @@
@AGENTS.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**kte** (Kyle's Text Editor) is a C++20 text editor with a terminal-first design (ncurses) and optional GUI frontends (ImGui via SDL2/OpenGL/Freetype, or Qt6). It uses a WordStar/VDE-style command model. The terminal editor is `kte`; the GUI editor is `kge`.
## Build Commands
```bash
# Configure (from project root, build dir is "build")
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON -DBUILD_TESTS=ON
# Build everything
cmake --build build
# Build specific targets
cmake --build build --target kte # terminal editor
cmake --build build --target kge # GUI editor (requires -DBUILD_GUI=ON)
cmake --build build --target kte_tests # test suite
# Run all tests
cmake --build build --target kte_tests && ./build/kte_tests
```
There is no single-test runner; the test binary runs all tests. Tests use a minimal custom framework in `tests/Test.h` with `TEST()`, `ASSERT_EQ()`, `ASSERT_TRUE()`, `EXPECT_TRUE()` macros.
### Key CMake Options
| Flag | Default | Purpose |
|------|---------|---------|
| `BUILD_GUI` | ON | Build `kge` (ImGui GUI) |
| `KTE_USE_QT` | OFF | Use Qt6 instead of ImGui for 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 |
### Nix
`flake.nix` provides devshells: `default` (ImGui+debug tools), `terminal`, `qt`.
### Docker (cross-platform Linux testing)
```bash
docker build -t kte-linux . && docker run --rm -v "$(pwd):/kte" kte-linux
```
## Architecture
Three-layer design with strict frontend independence:
```
Frontend Layer (Terminal / ImGui / Qt / 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. Prefer `GetLineView(row)` (zero-copy) or `GetLineString(row)` over `Rows()` (legacy, materializes all lines). All text mutations must go through PieceTable API (`insert_text`, `delete_text`) to ensure undo and swap recording work.
- **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`)
- **Qt**: Qt6 (built with `-DBUILD_GUI=ON -DKTE_USE_QT=ON`)
- **Test**: Programmatic frontend for testing (always built, no UI deps)
## Code Style
- **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**: Tabs
- **Error handling**: Fallible ops use `bool func(args..., std::string &err)` pattern. Always clear `err` at start, capture `errno` immediately after syscall failure. Use EINTR-safe wrappers from `SyscallWrappers.h` instead of raw syscalls.
- **ErrorHandler**: Centralized logging to `~/.local/state/kte/error.log` with severity levels (Info/Warning/Error/Critical).
## Testing
Tests live in `tests/test_*.cc`. 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.cc`, `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
- `Buffer::Rows()` is legacy; use `GetLineView()` / `GetLineString()` in new code
- `GetLineView()` returns a `string_view` valid only until next buffer modification
- After editing ops, call `ensure_cursor_visible()` to update viewport
- 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)
+52 -104
View File
@@ -4,17 +4,17 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.13.0") set(KTE_VERSION "1.11.3")
# 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_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)
@@ -22,8 +22,6 @@ 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 ()
@@ -66,26 +64,6 @@ 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 ()
@@ -93,34 +71,8 @@ endif ()
# NCurses for terminal mode # NCurses for terminal mode
set(CURSES_NEED_NCURSES TRUE) set(CURSES_NEED_NCURSES TRUE)
set(CURSES_NEED_WIDE TRUE) 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 # On Alpine Linux, CMake's FindCurses looks in wrong paths
# Manually find the correct ncurses library # Manually find the correct ncurses library
@@ -162,13 +114,25 @@ if (BUILD_GUI)
set(GUI_SOURCES set(GUI_SOURCES
GUIConfig.cc GUIConfig.cc
) )
set(GUI_SOURCES if (KTE_USE_QT)
${GUI_SOURCES} find_package(Qt6 COMPONENTS Widgets REQUIRED)
${FONT_SOURCES} set(GUI_SOURCES
ImGuiFrontend.cc ${GUI_SOURCES}
ImGuiInputHandler.cc QtFrontend.cc
ImGuiRenderer.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
${GUI_SOURCES}
${FONT_SOURCES}
ImGuiFrontend.cc
ImGuiInputHandler.cc
ImGuiRenderer.cc
)
endif ()
endif () endif ()
set(COMMON_SOURCES set(COMMON_SOURCES
@@ -182,9 +146,6 @@ set(COMMON_SOURCES
ErrorHandler.cc ErrorHandler.cc
SyscallWrappers.cc SyscallWrappers.cc
ErrorRecovery.cc ErrorRecovery.cc
RegexGuard.cc
RegexEngine.cc
SearchHighlight.cc
TerminalInputHandler.cc TerminalInputHandler.cc
TerminalRenderer.cc TerminalRenderer.cc
TerminalFrontend.cc TerminalFrontend.cc
@@ -282,9 +243,6 @@ 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
@@ -298,15 +256,24 @@ if (BUILD_GUI)
GUIConfig.h GUIConfig.h
) )
set(GUI_HEADERS if (KTE_USE_QT)
${GUI_HEADERS} set(GUI_HEADERS
${THEME_HEADERS} ${GUI_HEADERS}
${FONT_HEADERS} QtFrontend.h
ImGuiFrontend.h QtInputHandler.h
ImGuiInputHandler.h QtRenderer.h
ImGuiRenderer.h )
fonts/BerkeleyMono.h else ()
) set(GUI_HEADERS
${GUI_HEADERS}
${THEME_HEADERS}
${FONT_HEADERS}
ImGuiFrontend.h
ImGuiInputHandler.h
ImGuiRenderer.h
fonts/BerkeleyMono.h
)
endif ()
endif () endif ()
# kte (terminal-first) executable # kte (terminal-first) executable
@@ -367,6 +334,7 @@ if (BUILD_TESTS)
tests/test_swap_cleanup2.cc tests/test_swap_cleanup2.cc
tests/test_swap_git_editor.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
@@ -376,9 +344,7 @@ if (BUILD_TESTS)
tests/test_migration_coverage.cc tests/test_migration_coverage.cc
tests/test_smart_newline.cc tests/test_smart_newline.cc
tests/test_reflow_undo.cc tests/test_reflow_undo.cc
tests/test_syntax_highlighting.cc
tests/test_paste_split.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
@@ -390,11 +356,9 @@ if (BUILD_TESTS)
ErrorHandler.cc ErrorHandler.cc
SyscallWrappers.cc SyscallWrappers.cc
ErrorRecovery.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
@@ -421,9 +385,6 @@ if (BUILD_TESTS)
if (NOT APPLE AND KTE_STATIC_LINK) if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kte_tests PRIVATE -static) target_link_options(kte_tests PRIVATE -static)
endif () endif ()
enable_testing()
add_test(NAME kte_tests COMMAND kte_tests)
endif () endif ()
if (BUILD_GUI) if (BUILD_GUI)
@@ -451,10 +412,17 @@ 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 ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui) if (KTE_USE_QT)
target_link_libraries(kge ${CURSES_LIBRARIES} Qt6::Widgets)
else ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
endif ()
# Static linking on Linux only (macOS does not support static linking of system libraries) # Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE AND KTE_STATIC_LINK) if (NOT APPLE AND KTE_STATIC_LINK)
@@ -514,30 +482,10 @@ 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, copying non-system dylibs (e.g. Homebrew SDL2/SDL3, freetype, # built app, useful after macdeployqt to ensure non-Qt dylibs are internalized.
# 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)
@@ -545,7 +493,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-system dylibs" COMMENT "Running fixup_bundle on kge.app to internalize non-Qt dylibs"
VERBATIM) VERBATIM)
add_dependencies(kge_fixup_bundle kge) add_dependencies(kge_fixup_bundle kge)
endif () endif ()
+1081 -1634
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -28,6 +28,7 @@ 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)
-2
View File
@@ -9,8 +9,6 @@ RUN apk add --no-cache \
cmake \ cmake \
make \ make \
ncurses-dev \ ncurses-dev \
pcre2-dev \
pkgconf \
sdl2-dev \ sdl2-dev \
mesa-dev \ mesa-dev \
freetype-dev \ freetype-dev \
+107 -251
View File
@@ -1,21 +1,28 @@
#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 "ErrorHandler.h" #include "syntax/HighlighterRegistry.h"
#include "syntax/CppHighlighter.h" #include "syntax/CppHighlighter.h"
#include "syntax/NullHighlighter.h"
namespace { namespace {
// Universal-argument repeat counts are capped here (well below INT_MAX). static std::string
constexpr int kMaxUArgCount = 1000000; buffer_bytes_via_views(const Buffer &b)
} // namespace {
const std::size_t nrows = b.Nrows();
std::string out;
for (std::size_t i = 0; i < nrows; 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)
{ {
@@ -37,46 +44,6 @@ 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>();
@@ -198,25 +165,11 @@ std::size_t
Editor::AddBuffer(const Buffer &buf) Editor::AddBuffer(const Buffer &buf)
{ {
auto &bufs = Buffers(); auto &bufs = Buffers();
// push_back may reallocate the vector's storage, moving every existing
// Buffer to a new address. Drain any in-flight swap records first so the
// writer thread never dereferences an address that's about to move, then
// 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); bufs.push_back(buf);
if (Swap()) { // Attach swap recorder
for (std::size_t i = 0; i < old_addrs.size(); ++i) { if (swap_) {
Buffer *new_addr = &bufs[i]; swap_->Attach(&bufs.back());
if (new_addr != old_addrs[i]) bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
bufs[i].SetSwapRecorder(Swap()->Rehome(old_addrs[i], new_addr));
}
Swap()->Attach(&bufs.back());
bufs.back().SetSwapRecorder(Swap()->RecorderFor(&bufs.back()));
} }
if (bufs.size() == 1) { if (bufs.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
@@ -229,21 +182,10 @@ std::size_t
Editor::AddBuffer(Buffer &&buf) Editor::AddBuffer(Buffer &&buf)
{ {
auto &bufs = Buffers(); auto &bufs = Buffers();
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(std::move(buf)); bufs.push_back(std::move(buf));
if (Swap()) { if (swap_) {
for (std::size_t i = 0; i < old_addrs.size(); ++i) { swap_->Attach(&bufs.back());
Buffer *new_addr = &bufs[i]; bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
if (new_addr != old_addrs[i])
bufs[i].SetSwapRecorder(Swap()->Rehome(old_addrs[i], new_addr));
}
Swap()->Attach(&bufs.back());
bufs.back().SetSwapRecorder(Swap()->RecorderFor(&bufs.back()));
} }
if (bufs.size() == 1) { if (bufs.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
@@ -255,14 +197,6 @@ Editor::AddBuffer(Buffer &&buf)
bool bool
Editor::OpenFile(const std::string &path, std::string &err) Editor::OpenFile(const std::string &path, std::string &err)
{ {
// A file that is already open is switched to, not opened again: two
// buffers for one file would share (and corrupt) one swap journal, and
// closing either would delete the other's journal.
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 // If the current buffer is an unnamed, empty, clean scratch buffer, reuse
// it instead of creating a new one. // it instead of creating a new one.
auto &bufs_ref = Buffers(); auto &bufs_ref = Buffers();
@@ -278,13 +212,32 @@ Editor::OpenFile(const std::string &path, std::string &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 = "";
if (cur.Nrows() > 0)
first = cur.GetLineString(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;
@@ -297,12 +250,31 @@ 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*.
// Highlighting by extension or shebang // Initialize syntax highlighting by extension + shebang via registry (v2)
b.ApplyDetectedFiletype(); b.EnsureHighlighter();
std::string first = "";
if (b.Nrows() > 0)
first = b.GetLineString(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
@@ -342,10 +314,6 @@ 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;
} }
@@ -358,10 +326,6 @@ 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;
@@ -376,35 +340,11 @@ 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; std::string rerr;
try { if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
swap_size_now = std::filesystem::file_size(swp); SetStatus("Swap recovery failed: " + rerr);
} catch (...) { return false;
} }
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;
if (!kte::SwapManager::ReplayFile(*b, swp, rerr, &valid_bytes)) {
SetStatus("Swap recovery failed: " + rerr);
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);
@@ -421,16 +361,8 @@ 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);
@@ -438,9 +370,7 @@ 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 (!kept_as.empty()) { if (!rerr_s.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);
@@ -453,39 +383,6 @@ 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;
@@ -498,15 +395,6 @@ Editor::process_pending_opens_()
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;
@@ -515,59 +403,20 @@ Editor::process_pending_opens_()
} 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 = tmp.Bytes(); const std::string orig = buffer_bytes_via_views(tmp);
std::string rerr; std::string rerr;
std::uint64_t valid = 0; if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
if (kte::SwapManager::ReplayFile(tmp, swp, rerr, &valid)) { const std::string rec = buffer_bytes_via_views(tmp);
std::string rec = tmp.Bytes(); if (rec != orig) {
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 {
@@ -610,8 +459,25 @@ Editor::SwitchTo(std::size_t index)
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 = bufs[curbuf_]; Buffer &b = bufs[curbuf_];
if (b.SyntaxEnabled() && !(b.Highlighter() && b.Highlighter()->HasHighlighter())) if (b.SyntaxEnabled()) {
b.InstallFiletypeHighlighter(); b.EnsureHighlighter();
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;
} }
@@ -623,28 +489,14 @@ Editor::CloseBuffer(std::size_t index)
if (index >= bufs.size()) { if (index >= bufs.size()) {
return false; return false;
} }
if (Swap()) { if (swap_) {
// Always remove swap file when closing a buffer on normal exit. // Always remove swap file when closing a buffer on normal exit.
// Swap files are for crash recovery; on clean close, we don't need them. // Swap files are for crash recovery; on clean close, we don't need them.
// This prevents stale swap files from accumulating (e.g., when used as git editor). // This prevents stale swap files from accumulating (e.g., when used as git editor).
Swap()->Detach(&bufs[index], true); swap_->Detach(&bufs[index], true);
bufs[index].SetSwapRecorder(nullptr); 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();
} }
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index)); bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
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()) { if (bufs.empty()) {
curbuf_ = 0; curbuf_ = 0;
} else if (curbuf_ >= bufs.size()) { } else if (curbuf_ >= bufs.size()) {
@@ -658,20 +510,24 @@ 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;
auto &bufs = Buffers(); auto &bufs = Buffers();
if (Swap()) { if (swap_) {
for (auto &buf : bufs) for (auto &buf : bufs)
Swap()->Detach(&buf, true); swap_->Detach(&buf, true);
} }
bufs.clear(); bufs.clear();
curbuf_ = 0; curbuf_ = 0;
@@ -689,7 +545,7 @@ Editor::UArgStart()
if (ucount_ == 0) { if (ucount_ == 0) {
ucount_ = 1; ucount_ = 1;
} }
ucount_ = std::min(ucount_ * 4, kMaxUArgCount); // no int overflow ucount_ *= 4;
} }
uarg_ = 1; uarg_ = 1;
char buf[64]; char buf[64];
@@ -709,7 +565,7 @@ Editor::UArgDigit(int d)
uarg_ = 1; uarg_ = 1;
ucount_ = 0; ucount_ = 0;
} }
ucount_ = (ucount_ > (kMaxUArgCount - d) / 10) ? kMaxUArgCount : ucount_ * 10 + d; ucount_ = 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);
+62 -23
View File
@@ -79,6 +79,19 @@ 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;
@@ -91,6 +104,18 @@ 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()
{ {
@@ -139,6 +164,18 @@ 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,6 +189,12 @@ 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();
@@ -166,6 +209,19 @@ 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);
@@ -512,11 +568,6 @@ 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`
@@ -556,18 +607,9 @@ public:
// Share another editor's buffer list. When set, this editor operates on // Share another editor's buffer list. When set, this editor operates on
// the provided vector instead of its own. Pass nullptr to detach. // the provided vector instead of its own. Pass nullptr to detach.
// Buffers hold recorders owned by the SwapManager that attached them, so void SetSharedBuffers(std::vector<Buffer> *shared)
// 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_buffers_ = shared;
shared_swap_ = shared ? shared_swap : nullptr;
curbuf_ = 0; curbuf_ = 0;
} }
@@ -575,7 +617,7 @@ public:
// Swap manager access (for advanced integrations/tests) // Swap manager access (for advanced integrations/tests)
[[nodiscard]] kte::SwapManager *Swap() [[nodiscard]] kte::SwapManager *Swap()
{ {
return shared_swap_ ? shared_swap_ : swap_.get(); return swap_.get();
} }
@@ -610,10 +652,14 @@ 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::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of buffers_
@@ -621,7 +667,6 @@ private:
// 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_;
@@ -657,12 +702,6 @@ 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;
+3 -5
View File
@@ -73,15 +73,13 @@ CircuitBreaker::RecordFailure()
{ {
std::lock_guard<std::mutex> lg(mtx_); std::lock_guard<std::mutex> lg(mtx_);
// The window is measured from the previous failure, so check it before const auto now = std::chrono::steady_clock::now();
// recording this one. last_failure_time_ = now;
const bool window_expired = IsWindowExpired();
last_failure_time_ = std::chrono::steady_clock::now();
switch (state_) { switch (state_) {
case State::Closed: case State::Closed:
// Check if we need to reset the failure count (window expired) // Check if we need to reset the failure count (window expired)
if (window_expired) { if (IsWindowExpired()) {
failure_count_ = 0; failure_count_ = 0;
} }
+5
View File
@@ -52,6 +52,11 @@ struct RetryPolicy {
} }
// Conservative policy for non-critical operations: fewer attempts, slower retries
static RetryPolicy Conservative()
{
return RetryPolicy{2, std::chrono::milliseconds(200), 2.5, std::chrono::milliseconds(10000)};
}
}; };
// Retry a function with exponential backoff for transient errors // Retry a function with exponential backoff for transient errors
+6 -24
View File
@@ -1,4 +1,3 @@
#include <cmath>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <fstream> #include <fstream>
@@ -21,7 +20,6 @@
#include "ext/tomlplusplus/toml.hpp" #include "ext/tomlplusplus/toml.hpp"
#if defined(__clang__) #if defined(__clang__)
# pragma clang diagnostic pop # pragma clang diagnostic pop
#elif defined(__GNUC__) #elif defined(__GNUC__)
@@ -29,22 +27,6 @@
#endif #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)
{ {
@@ -109,10 +91,10 @@ GUIConfig::LoadFromTOML(const std::string &path)
if (auto v = (*win)["fullscreen"].value<bool>()) if (auto v = (*win)["fullscreen"].value<bool>())
fullscreen = *v; fullscreen = *v;
if (auto v = (*win)["columns"].value<int64_t>()) { if (auto v = (*win)["columns"].value<int64_t>()) {
if (valid_dimension(*v)) columns = static_cast<int>(*v); if (*v > 0) columns = static_cast<int>(*v);
} }
if (auto v = (*win)["rows"].value<int64_t>()) { if (auto v = (*win)["rows"].value<int64_t>()) {
if (valid_dimension(*v)) rows = static_cast<int>(*v); if (*v > 0) rows = static_cast<int>(*v);
} }
} }
@@ -123,7 +105,7 @@ GUIConfig::LoadFromTOML(const std::string &path)
if (auto v = (*sec)["name"].value<std::string>()) if (auto v = (*sec)["name"].value<std::string>())
font = *v; font = *v;
if (auto v = (*sec)["size"].value<double>()) { if (auto v = (*sec)["size"].value<double>()) {
if (valid_font_size(*v)) font_size = static_cast<float>(*v); if (*v > 0.0) font_size = static_cast<float>(*v);
} }
if (auto v = (*sec)["code"].value<std::string>()) { if (auto v = (*sec)["code"].value<std::string>()) {
code_font = *v; code_font = *v;
@@ -210,21 +192,21 @@ GUIConfig::LoadFromINI(const std::string &path)
try { try {
v = std::stoi(val); v = std::stoi(val);
} catch (...) {} } catch (...) {}
if (valid_dimension(v)) if (v > 0)
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 (valid_dimension(v)) if (v > 0)
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 (valid_font_size(v)) { if (v > 0.0f) {
font_size = v; font_size = v;
} }
} else if (key == "font") { } else if (key == "font") {
+292
View File
@@ -8,6 +8,296 @@
#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>
@@ -819,3 +1109,5 @@ SyntaxInk(const TokenKind k)
} }
} }
} // namespace kte } // namespace kte
#endif // KTE_USE_QT
-42
View File
@@ -1,10 +1,7 @@
// 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 {
@@ -37,43 +34,4 @@ 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
+23 -22
View File
@@ -19,6 +19,8 @@
#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
@@ -42,18 +44,29 @@ apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
if (!b->EditModeDetected() && !b->Filename().empty()) if (!b->EditModeDetected() && !b->Filename().empty())
b->SetEditMode(DetectEditMode(b->Filename())); 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. // Writing mode disables syntax; otherwise follow the global config.
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) { if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
b->SetSyntaxEnabled(true); b->SetSyntaxEnabled(true);
b->EnsureHighlighter(); b->EnsureHighlighter();
if (!b->Highlighter()->HasHighlighter()) if (auto *eng = b->Highlighter()) {
b->ApplyDetectedFiletype(); if (!eng->HasHighlighter()) {
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 {
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
b->SetFiletype("");
eng->InvalidateFrom(0);
}
}
}
} else { } else {
b->SetSyntaxEnabled(false); b->SetSyntaxEnabled(false);
} }
@@ -359,7 +372,7 @@ GUIFrontend::OpenNewWindow_(Editor &primary)
ws->height = h; ws->height = h;
// Secondary editor shares the primary's buffer list // Secondary editor shares the primary's buffer list
ws->editor.SetSharedBuffers(&primary.Buffers(), primary.Swap()); ws->editor.SetSharedBuffers(&primary.Buffers());
ws->editor.SetDimensions(primary.Rows(), primary.Cols()); ws->editor.SetDimensions(primary.Rows(), primary.Cols());
// Open a new untitled buffer and switch to it in the new window. // Open a new untitled buffer and switch to it in the new window.
@@ -456,19 +469,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
// Route input events to the correct window's input handler // Route input events to the correct window's input handler
if (target) { if (target) {
Editor &tgt_ed = (target_idx == 0) ? ed : target->editor; target->input.ProcessSDLEvent(e);
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);
}
} }
} }
+15 -57
View File
@@ -10,19 +10,6 @@
#include "Editor.h" #include "Editor.h"
#include "PasteSplit.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
map_key(const SDL_Keycode key, map_key(const SDL_Keycode key,
@@ -157,24 +144,6 @@ map_key(const SDL_Keycode key,
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately. // If 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;
@@ -214,19 +183,18 @@ 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)
: '?'; : '?';
IH_LOGF( std::fprintf(stderr,
"[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)
@@ -364,11 +332,6 @@ 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_;
@@ -417,11 +380,8 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
// Turn line breaks (\n, \r\n, or bare \r) into Newline // Turn line breaks (\n, \r\n, or bare \r) into Newline
// commands and the rest into InsertText; InsertText itself // commands and the rest into InsertText; InsertText itself
// rejects any embedded '\r'/'\n'. // 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_);
for (const auto &cmd: to_prompt ? SplitPasteForPrompt(text) : SplitPasteIntoCommands(text)) for (const auto &cmd : SplitPasteIntoCommands(text))
q_.push(cmd); q_.push(cmd);
// Suppress the corresponding TEXTINPUT that may follow // Suppress the corresponding TEXTINPUT that may follow
suppress_text_input_once_ = true; suppress_text_input_once_ = true;
@@ -545,17 +505,15 @@ 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_)
+129 -68
View File
@@ -7,10 +7,9 @@
#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"
@@ -67,9 +66,7 @@ ImGuiRenderer::Draw(Editor &ed)
if (!buf) { if (!buf) {
ImGui::TextUnformatted("[no buffer]"); ImGui::TextUnformatted("[no buffer]");
} else { } else {
// Per-row access only: Rows() rebuilds a string for every line of the const auto &lines = buf->Rows();
// 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();
@@ -158,16 +155,27 @@ ImGuiRenderer::Draw(Editor &ed)
max_width_version_ = buf->Version(); max_width_version_ = buf->Version();
} }
// Search matches: the pattern is compiled once per frame (compiling it // Hoist the search-regex compilation out of the per-line loop. Compiling
// per line per frame was a large source of idle CPU on macOS). // std::regex per line per frame was a large source of idle CPU on macOS.
const kte::SearchHighlight search_hl(ed); const bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
const bool search_mode = search_hl.Active(); const bool regex_mode = search_mode && ed.PromptActive() && (
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges; ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind);
std::regex search_rx;
bool search_rx_valid = false;
if (regex_mode) {
try {
search_rx = std::regex(ed.SearchQuery());
search_rx_valid = true;
} catch (const std::regex_error &) {
search_rx_valid = false;
}
}
// Compute the visible row range and skip rendering work for off-screen // Compute the visible row range and skip rendering work for off-screen
// lines. ImGui clips drawing, but string allocation, tab expansion, // lines. ImGui clips drawing, but string allocation, tab expansion,
// syntax-highlight lookups, and width measurement are not free. // syntax-highlight lookups, and width measurement are not free.
const std::size_t total_rows = nlines; const std::size_t total_rows = lines.size();
std::size_t first_vis = 0; std::size_t first_vis = 0;
std::size_t last_vis = total_rows; // exclusive std::size_t last_vis = total_rows; // exclusive
if (row_h > 0.0f && total_rows > 0) { if (row_h > 0.0f && total_rows > 0) {
@@ -213,15 +221,15 @@ 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 >= nlines) if (by >= lines.size())
by = (nlines == 0) ? 0 : (nlines - 1); by = lines.empty() ? 0 : (lines.size() - 1);
if (nlines == 0) if (lines.empty())
return {0, 0}; return {0, 0};
// Expand tabs for the clicked line // Expand tabs for the clicked line
std::string line_clicked = buf->GetLineString(by); std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = kte::kTabWidth; const std::size_t tabw = 8;
std::string click_expanded; std::string click_expanded;
click_expanded.reserve(line_clicked.size() + 16); click_expanded.reserve(line_clicked.size() + 16);
std::size_t click_rx = 0; std::size_t click_rx = 0;
@@ -314,54 +322,75 @@ ImGuiRenderer::Draw(Editor &ed)
for (std::size_t i = first_vis; i < last_vis; ++i) { 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 = buf->GetLineString(i); std::string line = static_cast<std::string>(lines[i]);
// Expand tabs to spaces with width=8, recording each source // Expand tabs to spaces with width=8
// byte's rendered column (rx_of[line.size()] is the line's width). const std::size_t tabw = 8;
const std::size_t tabw = kte::kTabWidth;
std::string expanded; std::string expanded;
expanded.reserve(line.size() + 16); expanded.reserve(line.size() + 16);
std::vector<std::size_t> rx_of(line.size() + 1); std::size_t rx_abs_draw = 0;
for (std::size_t src = 0; src < line.size(); ++src) { for (std::size_t src = 0; src < line.size(); ++src) {
rx_of[src] = expanded.size(); char c = line[src];
if (line[src] == '\t') if (c == '\t') {
expanded.append(tabw - (expanded.size() % tabw), ' '); std::size_t adv = (tabw - (rx_abs_draw % tabw));
else expanded.append(adv, ' ');
expanded.push_back(line[src]); rx_abs_draw += adv;
} } else {
rx_of[line.size()] = expanded.size(); expanded.push_back(c);
auto src_to_rx = [&](std::size_t src) -> std::size_t { rx_abs_draw += 1;
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)); // Helper: convert a rendered column position to an absolute
// pixel x offset from the start of the line. ImGui's scroll
// handles viewport clipping so we measure from column 0.
auto rx_to_px = [&](std::size_t rx_col) -> float {
std::size_t end = std::min(expanded.size(), rx_col);
if (end == 0)
return 0.0f;
return ImGui::CalcTextSize(expanded.c_str(),
expanded.c_str() + end).x;
}; };
// Search highlight ranges for this line in source indices // Compute search highlight ranges for this line in source indices
search_hl.Ranges(line, hl_src_ranges); std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
if (search_mode) {
// In regex mode, reuse the compiled regex hoisted above the loop.
if (regex_mode) {
if (search_rx_valid) {
try {
for (auto it = std::sregex_iterator(line.begin(), line.end(), search_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
rx += 1;
++s;
}
return rx;
};
// 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
@@ -438,21 +467,53 @@ ImGuiRenderer::Draw(Editor &ed)
} }
// 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()) {
const kte::LineHighlight &lh = buf->Highlighter()->GetLine( kte::LineHighlight lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version()); *buf, static_cast<int>(i), buf->Version());
std::vector<kte::HighlightSpan> spans; // clamped, ordered, whole characters // Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties
kte::SanitizeSpans(line, lh.spans, spans); struct SSpan {
std::size_t s;
std::size_t e;
kte::TokenKind k;
};
std::vector<SSpan> spans;
spans.reserve(lh.spans.size());
const std::size_t line_len = line.size();
for (const auto &sp: lh.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s), std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
spans.push_back(SSpan{s, e, sp.kind});
}
std::sort(spans.begin(), spans.end(), [](const SSpan &a, const SSpan &b) {
return a.s < b.s;
});
// Helper to convert a src column to expanded rx position
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
std::size_t rx = 0;
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(static_cast<std::size_t>(sp.col_start)); std::size_t rx_s = src_to_rx_full(sp.s);
std::size_t rx_e = src_to_rx(static_cast<std::size_t>(sp.col_end)); std::size_t rx_e = src_to_rx_full(sp.e);
std::size_t draw_start = rx_s; std::size_t draw_start = rx_s;
if (draw_start >= expanded.size()) if (draw_start >= expanded.size())
continue; 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;
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind)); ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start), ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start),
line_pos.y); line_pos.y);
ImGui::GetWindowDrawList()->AddText( ImGui::GetWindowDrawList()->AddText(
@@ -488,7 +549,7 @@ ImGuiRenderer::Draw(Editor &ed)
// measured per frame; if the user scrolls or edits, the cache is // measured per frame; if the user scrolls or edits, the cache is
// refreshed accordingly. // refreshed accordingly.
if (!expanded.empty()) { if (!expanded.empty()) {
const float line_w = rx_to_px(expanded.size()); float line_w = ImGui::CalcTextSize(expanded.c_str()).x;
if (line_w > max_width_px_) if (line_w > max_width_px_)
max_width_px_ = line_w; max_width_px_ = line_w;
} }
@@ -546,9 +607,9 @@ ImGuiRenderer::Draw(Editor &ed)
// Horizontal scroll: ensure cursor is visible (pixel-based for proportional fonts) // Horizontal scroll: ensure cursor is visible (pixel-based for proportional fonts)
float cursor_px_abs = 0.0f; float cursor_px_abs = 0.0f;
if (cy < nlines) { if (cy < lines.size()) {
std::string cur_line = buf->GetLineString(cy); std::string cur_line = static_cast<std::string>(lines[cy]);
const std::size_t tabw = kte::kTabWidth; const std::size_t tabw = 8;
// Expand tabs for cursor line to measure pixel position // Expand tabs for cursor line to measure pixel position
std::string cur_expanded; std::string cur_expanded;
cur_expanded.reserve(cur_line.size() + 16); cur_expanded.reserve(cur_line.size() + 16);
@@ -730,7 +791,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->Nrows()); unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += " "; left += " ";
left += std::to_string(lcount); left += std::to_string(lcount);
left += "L"; left += "L";
+5 -9
View File
@@ -1,4 +1,6 @@
#include <iostream>
#include <ncurses.h> #include <ncurses.h>
#include <ostream>
#include "KKeymap.h" #include "KKeymap.h"
@@ -38,15 +40,6 @@ 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':
@@ -73,6 +66,9 @@ 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;
-12
View File
@@ -1,12 +0,0 @@
.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
@@ -0,0 +1,79 @@
#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
@@ -0,0 +1,23 @@
// 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);
};
-24
View File
@@ -46,27 +46,3 @@ inline std::vector<MappedInput> SplitPasteIntoCommands(const std::string &text)
return out; 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;
}
+68 -290
View File
@@ -1,5 +1,4 @@
#include <algorithm> #include <algorithm>
#include <cstring>
#include <utility> #include <utility>
#include <limits> #include <limits>
#include <ostream> #include <ostream>
@@ -10,6 +9,13 @@
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,
@@ -52,9 +58,6 @@ 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;
} }
@@ -69,8 +72,6 @@ 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_ = {};
@@ -90,15 +91,9 @@ 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;
} }
@@ -106,6 +101,14 @@ 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,
@@ -189,7 +192,6 @@ 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;
@@ -197,60 +199,8 @@ PieceTable::Clear()
line_index_.clear(); line_index_.clear();
line_index_dirty_ = true; line_index_dirty_ = true;
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);
} }
@@ -271,7 +221,6 @@ 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_ = {};
@@ -305,7 +254,6 @@ 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_ = {};
@@ -325,6 +273,7 @@ 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;
} }
@@ -351,20 +300,16 @@ 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};
} }
// Rebuilt once per modification: an edited buffer's lookups (two per std::size_t off = byte_offset;
// rendered line per frame) used to walk the piece list each time. for (std::size_t i = 0; i < pieces_.size(); ++i) {
if (piece_ends_version_ != version_ || piece_ends_.size() != pieces_.size()) { const auto &p = pieces_[i];
piece_ends_.resize(pieces_.size()); if (off < p.len) {
std::size_t end = 0; return {i, off};
for (std::size_t i = 0; i < pieces_.size(); ++i) }
piece_ends_[i] = end += pieces_[i].len; off -= p.len;
piece_ends_version_ = version_;
} }
const auto it = std::upper_bound(piece_ends_.begin(), piece_ends_.end(), byte_offset); // Should not reach here unless inconsistency; return end
if (it == piece_ends_.end()) return {pieces_.size(), 0};
return {pieces_.size(), 0}; // inconsistent sizes; treat as the end
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)};
} }
@@ -404,102 +349,33 @@ 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);
const char *end = base + pc.len; for (std::size_t j = 0; j < pc.len; ++j) {
// The next line starts after each newline. if (base[j] == '\n') {
for (const char *p = base; (p = static_cast<const char *>(std::memchr(p, '\n', end - p))) != nullptr; ++p) // next line starts after the newline
line_index_.push_back(pos + static_cast<std::size_t>(p - base) + 1); line_index_.push_back(pos + j + 1);
}
}
pos += pc.len; pos += pc.len;
} }
@@ -518,7 +394,6 @@ 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);
@@ -526,7 +401,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;
lineIndexOnInsert(byte_offset, add_.data() + add_start, len); InvalidateLineIndex();
maybeConsolidate(); maybeConsolidate();
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
@@ -540,7 +415,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;
lineIndexOnInsert(byte_offset, add_.data() + add_start, len); InvalidateLineIndex();
coalesceNeighbors(pieces_.size() - 1); coalesceNeighbors(pieces_.size() - 1);
maybeConsolidate(); maybeConsolidate();
version_++; version_++;
@@ -568,7 +443,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;
lineIndexOnInsert(byte_offset, add_.data() + add_start, len); InvalidateLineIndex();
// 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);
@@ -592,16 +467,6 @@ 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;
@@ -648,7 +513,7 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
total_size_ -= len; total_size_ -= len;
dirty_ = true; dirty_ = true;
lineIndexOnDelete(byte_offset, len); InvalidateLineIndex();
if (idx < pieces_.size()) if (idx < pieces_.size())
coalesceNeighbors(idx); coalesceNeighbors(idx);
if (idx > 0) if (idx > 0)
@@ -660,104 +525,6 @@ 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
@@ -795,10 +562,9 @@ 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. Content and byte offsets are unchanged too, so // total_size_ unchanged
// 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_++;
@@ -865,8 +631,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 = lineStart(line_num); std::size_t start = line_index_[line_num];
std::size_t end = (line_num + 1 < line_index_.size()) ? lineStart(line_num + 1) : total_size_; std::size_t end = (line_num + 1 < line_index_.size()) ? line_index_[line_num + 1] : total_size_;
return {start, end}; return {start, end};
} }
@@ -877,10 +643,14 @@ 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();
// Every line but the last ends at a newline (each newline starts an // Trim trailing '\n'
// index entry); leave it out. if (end > start) {
if (end > start && line_num + 1 < line_index_.size()) // To check last char, we can get it via GetRange of len 1 at end-1 without materializing whole
end -= 1; std::string last = GetRange(end - 1, 1);
if (!last.empty() && last[0] == '\n') {
end -= 1;
}
}
return GetRange(start, end - start); return GetRange(start, end - start);
} }
@@ -893,9 +663,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};
const std::size_t ub = lineUpperBound(byte_offset); auto it = std::upper_bound(line_index_.begin(), line_index_.end(), byte_offset);
std::size_t row = ub == 0 ? 0 : ub - 1; std::size_t row = (it == line_index_.begin()) ? 0 : static_cast<std::size_t>((it - line_index_.begin()) - 1);
std::size_t col = byte_offset - lineStart(row); std::size_t col = byte_offset - line_index_[row];
return {row, col}; return {row, col};
} }
@@ -908,12 +678,15 @@ 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 = lineStart(row); std::size_t start = line_index_[row];
std::size_t end = (row + 1 < line_index_.size()) ? lineStart(row + 1) : total_size_; std::size_t end = (row + 1 < line_index_.size()) ? line_index_[row + 1] : total_size_;
// Clamp col to the line's length without its newline (every line but // Clamp col to line length excluding trailing newline
// the last has one). if (end > start) {
if (end > start && row + 1 < line_index_.size()) std::string last = GetRange(end - 1, 1);
end -= 1; if (!last.empty() && last[0] == '\n') {
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;
} }
@@ -929,6 +702,7 @@ 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;
@@ -938,6 +712,7 @@ 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 {
@@ -962,6 +737,7 @@ 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;
@@ -980,6 +756,7 @@ 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 &&
@@ -991,6 +768,7 @@ 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();
+25 -105
View File
@@ -4,7 +4,7 @@
* PieceTable is kte's core text storage data structure. It provides efficient * PieceTable is kte's core text storage data structure. It provides efficient
* insert/delete operations without copying the entire buffer by maintaining a * insert/delete operations without copying the entire buffer by maintaining a
* sequence of "pieces" that reference ranges in two underlying buffers: * sequence of "pieces" that reference ranges in two underlying buffers:
* - original_: A loaded file's content (AdoptOriginal), never modified * - original_: Initial file content (currently unused, reserved for future)
* - add_: All text added during editing * - add_: All text added during editing
* *
* Key advantages: * Key advantages:
@@ -15,8 +15,7 @@
* *
* Performance characteristics: * Performance characteristics:
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth * - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
* - Materialization (Data() call) is O(total_size) but cached until next edit; * - 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 * - Line index is lazily rebuilt on first line-based query after edits
* - Range and Find operations use lightweight caches for repeated queries * - Range and Find operations use lightweight caches for repeated queries
* *
@@ -33,8 +32,7 @@
* *
* Implementation notes: * Implementation notes:
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams) * - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
* - Not thread-safe: even reads update caches. Buffers are used from the * - Thread-safe for concurrent reads (mutex protects caches and lazy rebuilds)
* main thread only (the swap writer thread gets copies of the bytes).
* - Version tracking invalidates caches on mutations * - Version tracking invalidates caches on mutations
*/ */
#pragma once #pragma once
@@ -44,15 +42,15 @@
#include <ostream> #include <ostream>
#include <vector> #include <vector>
#include <limits> #include <limits>
#include <string_view> #include <mutex>
#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,
@@ -69,6 +67,9 @@ 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);
@@ -84,40 +85,18 @@ 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
// The whole text, contiguous; nullptr when empty. Valid until the next char *Data()
// modification.
[[nodiscard]] const char *Data() const
{ {
const std::string_view v = ContentView(); materialize();
return v.empty() ? nullptr : v.data(); return materialized_.empty() ? nullptr : materialized_.data();
} }
// The whole text as one view: in place for a single piece, else the [[nodiscard]] const char *Data() const
// 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
{ {
for (const Piece &p: pieces_) { const_cast<PieceTable *>(this)->materialize();
if (p.len > 0) return materialized_.empty() ? nullptr : materialized_.data();
fn((p.src == Source::Original ? original_ : add_).data() + p.start, p.len);
}
} }
@@ -127,6 +106,13 @@ 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);
@@ -153,36 +139,6 @@ 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,
@@ -219,16 +175,10 @@ 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_; // a loaded file's bytes (AdoptOriginal) std::string original_; // unused for builder use-case, but kept for API symmetry
std::string add_; std::string add_;
std::vector<Piece> pieces_; std::vector<Piece> pieces_;
@@ -238,34 +188,9 @@ 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
@@ -289,13 +214,8 @@ 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_;
}; };
+983
View File
@@ -0,0 +1,983 @@
#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 std::size_t nrows = buf->Nrows();
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) {
// Get line as string for regex/iterator usage and general string ops.
const std::string line = buf->GetLineString(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
@@ -0,0 +1,36 @@
/*
* 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;
};
+536
View File
@@ -0,0 +1,536 @@
// 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
@@ -0,0 +1,40 @@
/*
* 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
@@ -0,0 +1,76 @@
#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
@@ -0,0 +1,27 @@
/*
* 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
};
+5 -4
View File
@@ -848,6 +848,7 @@ pub enum CommandId {
// GUI-specific // GUI-specific
VisualFilePickerToggle, VisualFilePickerToggle,
VisualFontPickerToggle,
ThemeNext, ThemeNext,
ThemePrev, ThemePrev,
ThemeSetByName, ThemeSetByName,
@@ -937,7 +938,7 @@ pub enum CommandError {
**Current C++ Implementation:** **Current C++ Implementation:**
- `Frontend` trait with Init/Step/Shutdown lifecycle - `Frontend` trait with Init/Step/Shutdown lifecycle
- Two implementations: Terminal (ncurses), ImGui (SDL2/OpenGL) - Three implementations: Terminal (ncurses), ImGui (SDL2/OpenGL), Qt
- 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
@@ -985,7 +986,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: event-based, handles text input differently - ImGui/Qt: 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:**
@@ -1074,7 +1075,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: immediate mode GUI, draw calls within Begin/End blocks - ImGui/Qt: retained mode GUI, draw calls within Begin/End blocks
- Syntax highlighting integrated via Buffer::Highlighter() - Syntax highlighting integrated via Buffer::Highlighter()
**Rust Design:** **Rust Design:**
@@ -1199,7 +1200,7 @@ impl Frontend for TerminalFrontend {
#### 9.2 GUI Frontend (egui-based) #### 9.2 GUI Frontend (egui-based)
For GUI, consider replacing ImGui with `egui` (pure Rust, immediate For GUI, consider replacing ImGui/Qt with `egui` (pure Rust, immediate
mode): mode):
```rust ```rust
-344
View File
@@ -1,344 +0,0 @@
#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
@@ -1,79 +0,0 @@
/*
* 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
@@ -1,52 +0,0 @@
#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
@@ -1,18 +0,0 @@
// 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
@@ -1,45 +0,0 @@
#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
@@ -1,44 +0,0 @@
/*
* 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
+221 -802
View File
File diff suppressed because it is too large Load Diff
+8 -121
View File
@@ -24,17 +24,9 @@ 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,
}; };
@@ -88,19 +80,6 @@ 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)
{ {
@@ -115,15 +94,6 @@ 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);
@@ -132,34 +102,15 @@ 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);
@@ -214,6 +165,10 @@ 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);
@@ -228,53 +183,16 @@ 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);
@@ -286,39 +204,17 @@ private:
static std::string SwapDirRoot(); static std::string SwapDirRoot();
static bool write_header(int fd, const JournalCtx &ctx); static bool write_header(int fd);
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err, bool *locked_out = nullptr); static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err);
static void close_ctx(JournalCtx &ctx); static void close_ctx(JournalCtx &ctx);
static bool compact_to_checkpoint(JournalCtx &ctx, const RecordFrames &chkpt_record, static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record,
std::string &err); 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);
@@ -339,11 +235,6 @@ private:
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_;
mutable 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};
@@ -354,10 +245,6 @@ private:
// Error tracking (protected by mtx_) // Error tracking (protected by mtx_)
std::deque<SwapError> errors_; // bounded to max 100 entries 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}; std::size_t total_error_count_{0};
// Circuit breaker for swap operations (protected by mtx_) // Circuit breaker for swap operations (protected by mtx_)
+4 -17
View File
@@ -22,12 +22,10 @@ Open(const char *path, int flags, mode_t mode)
int int
Close(int fd) Close(int fd)
{ {
// Do not retry on EINTR: on Linux (and most systems) the descriptor is int ret;
// released even when close() is interrupted, so a retry could close an do {
// fd that another thread (e.g. the swap writer) has just opened. ret = ::close(fd);
const int ret = ::close(fd); } while (ret == -1 && errno == EINTR);
if (ret == -1 && errno == EINTR)
return 0;
return ret; return ret;
} }
@@ -54,17 +52,6 @@ Fstat(int fd, struct stat *buf)
} }
int
Ftruncate(int fd, off_t length)
{
int ret;
do {
ret = ::ftruncate(fd, length);
} while (ret == -1 && errno == EINTR);
return ret;
}
int int
Fchmod(int fd, mode_t mode) Fchmod(int fd, mode_t mode)
{ {
+4 -8
View File
@@ -12,10 +12,11 @@ namespace syscall {
// Automatically retries on EINTR. // Automatically retries on EINTR.
int Open(const char *path, int flags, mode_t mode = 0); int Open(const char *path, int flags, mode_t mode = 0);
// Wrapper for close(2). // EINTR-safe wrapper for close(2).
// Returns 0 on success, -1 on failure (errno set). // Returns 0 on success, -1 on failure (errno set).
// EINTR is treated as success and never retried: the descriptor has already // Automatically retries on EINTR.
// been released on Linux, and retrying could close an unrelated, reused fd. // Note: Some systems may not restart close() on EINTR, but we retry anyway
// as recommended by POSIX.1-2008.
int Close(int fd); int Close(int fd);
// EINTR-safe wrapper for fsync(2). // EINTR-safe wrapper for fsync(2).
@@ -33,11 +34,6 @@ int Fstat(int fd, struct stat *buf);
// Automatically retries on EINTR. // Automatically retries on EINTR.
int Fchmod(int fd, mode_t mode); 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). // EINTR-safe wrapper for mkstemp(3).
// Returns file descriptor on success, -1 on failure (errno set). // Returns file descriptor on success, -1 on failure (errno set).
// Automatically retries on EINTR. // Automatically retries on EINTR.
-72
View File
@@ -1,72 +0,0 @@
// 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
+6 -52
View File
@@ -1,6 +1,3 @@
#include <cstdio>
#include <ctime>
#include <string>
#include <ncurses.h> #include <ncurses.h>
#include <termios.h> #include <termios.h>
#include <unistd.h> #include <unistd.h>
@@ -15,12 +12,6 @@ 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{};
@@ -52,9 +43,7 @@ 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
// set_escdelay is an ncurses extension function (not a macro), so test for #ifdef set_escdelay
// 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
@@ -97,64 +86,29 @@ 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.
// Its return value only reports an opened buffer; a deferred open that ed.ProcessPendingOpens();
// 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;
int handled = 0; if (input_.Poll(mi)) {
while (handled < 4096 && input_.PollKey(mi)) { if (mi.hasCommand) {
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 renderer_.Draw(ed);
// (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);
} catch (const std::exception &e) {
ed.SetStatus(std::string("Display error: ") + e.what());
}
last_draw_ = now;
first_frame_ = false;
}
} }
-4
View File
@@ -2,7 +2,6 @@
* 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>
@@ -33,9 +32,6 @@ 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_{};
+39 -159
View File
@@ -1,6 +1,4 @@
#include <cstdio> #include <cstdio>
#include <climits>
#include <cwchar>
#include <ncurses.h> #include <ncurses.h>
#include "TerminalInputHandler.h" #include "TerminalInputHandler.h"
@@ -14,22 +12,6 @@ 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;
@@ -39,7 +21,6 @@ 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,
@@ -47,22 +28,9 @@ map_key_to_command(const int ch,
Editor *ed, Editor *ed,
MappedInput &out) MappedInput &out)
{ {
// Handle special keys from ncurses. These are only meaningful when // Handle special keys from ncurses
// 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;
@@ -174,10 +142,9 @@ 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:
// TerminalFrontend::Step picks up the new size before the next draw. k_prefix = false;
// Refresh is the C-g cancel handler, so issuing it here would clear the k_ctrl_pending = false;
// mark and cancel open prompts on every window resize. out = {true, CommandId::Refresh, "", 0};
out.hasCommand = false;
return true; return true;
default: default:
break; break;
@@ -193,37 +160,6 @@ 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
@@ -318,8 +254,34 @@ 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 ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) { if (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};
@@ -335,21 +297,12 @@ map_key_to_command(const int ch,
return true; return true;
} }
// Printable character: ASCII, or (for a regular decoded wide character, // Printable ASCII
// not a keycode) any other printable Unicode codepoint - e.g. accented if (ch >= 0x20 && ch <= 0x7E) {
// 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 = mb; out.arg.assign(1, static_cast<char>(ch));
out.count = 0; out.count = 0;
return true; return true;
} }
@@ -358,88 +311,15 @@ 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 = 0; int ch = getch();
bool is_keycode = false; if (ch == ERR) {
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, is_keycode, ch,
k_prefix_, esc_meta_, k_prefix_, esc_meta_,
k_ctrl_pending_, k_ctrl_pending_,
mouse_selecting_, mouse_selecting_,
-9
View File
@@ -20,15 +20,6 @@ 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);
+94 -99
View File
@@ -6,11 +6,10 @@
#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"
@@ -43,13 +42,11 @@ 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) {
// Per-row access only: Rows() rebuilds a string for every line of the const auto &lines = buf->Rows();
// 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 = static_cast<int>(kte::kTabWidth); const int tabw = 8;
// 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);
@@ -57,18 +54,41 @@ 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;
// Per-row copy: GetLineView() would materialize the whole buffer // Compute matches for this line if search highlighting is active
// after every edit. bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
const std::string line = li < nlines ? buf->GetLineString(li) : std::string(); std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
search_hl.Ranges(line, ranges); if (search_mode && li < lines.size()) {
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;
@@ -120,7 +140,8 @@ TerminalRenderer::Draw(Editor &ed)
return true; return true;
}; };
int written = 0; int written = 0;
if (li < nlines) { if (li < lines.size()) {
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())
@@ -147,11 +168,14 @@ 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 = kte::kTabWidth; constexpr std::size_t tab_width = 8;
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 {
rc += static_cast<std::size_t>(kte::CellWidth(wch)); int w = wcwidth(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);
} }
@@ -163,24 +187,42 @@ 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()) {
const kte::LineHighlight &lh_val = buf->Highlighter()->GetLine( kte::LineHighlight lh_val = buf->Highlighter()->GetLine(
*buf, static_cast<int>(li), buf->Version()); *buf, static_cast<int>(li), buf->Version());
kte::SanitizeSpans(line, lh_val.spans, sane_spans); // Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties
const std::size_t line_len = line.size();
sane_spans.reserve(lh_val.spans.size());
for (const auto &sp: lh_val.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s),
std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
sane_spans.push_back(kte::HighlightSpan{
static_cast<int>(s), static_cast<int>(e), sp.kind
});
}
std::sort(sane_spans.begin(), sane_spans.end(),
[](const kte::HighlightSpan &a, const kte::HighlightSpan &b) {
return a.col_start < b.col_start;
});
} }
// 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 {
const int si = static_cast<int>(src_index); if (sane_spans.empty())
if (si < last_token_si) return kte::TokenKind::Default;
span_i = 0; // asked about an earlier byte: restart int si = static_cast<int>(src_index);
last_token_si = si; for (const auto &sp: sane_spans) {
while (span_i < sane_spans.size() && sane_spans[span_i].col_end <= si) if (si < sp.col_start)
++span_i; break;
if (span_i < sane_spans.size() && sane_spans[span_i].col_start <= si) if (si >= sp.col_start && si < sp.col_end)
return sane_spans[span_i].kind; return sp.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 {
@@ -200,45 +242,6 @@ 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' ';
@@ -283,7 +286,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_hl.Active() && is_src_in_hl(src_i); bool in_hl = search_mode && 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
&& &&
@@ -307,8 +310,10 @@ TerminalRenderer::Draw(Editor &ed)
src_i += wch_len; src_i += wch_len;
continue; continue;
} else { } else {
// normal char (controls are drawn as ^X or '?') // normal char
disp_w = kte::CellWidth(wch); disp_w = wcwidth(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;
@@ -340,7 +345,7 @@ TerminalRenderer::Draw(Editor &ed)
} }
} }
bool in_sel = in_mark || in_vsel; bool in_sel = in_mark || in_vsel;
bool in_hl = search_hl.Active() && from_src && is_src_in_hl(src_i); bool in_hl = search_mode && 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;
@@ -356,12 +361,7 @@ TerminalRenderer::Draw(Editor &ed)
} }
attrset(a); attrset(a);
if (from_src && kte::IsCaretControl(wch)) { if (from_src) {
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,11 +389,8 @@ 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 < nlines && cursor_cache_.valid && cursor_cache_.buf_id == buf->Id() && if (cy < lines.size()) {
cursor_cache_.version == buf->Version() && cursor_cache_.row == cy && cursor_cache_.cx == cx) { const std::string line_for_cursor = static_cast<std::string>(lines[cy]);
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) {
@@ -403,25 +400,23 @@ 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) {
wch = static_cast<unsigned char>(line_for_cursor[src_i_cur]); render_col_cur += 1;
len = 1; src_i_cur += 1;
} else if (res == 0) { } else if (res == 0) {
wch = L'\0'; src_i_cur += 1;
len = 1;
}
if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col_cur % tabw);
render_col_cur += next_tab;
} else { } else {
render_col_cur += static_cast<std::size_t>(kte::CellWidth(wch)); if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col_cur % tabw);
render_col_cur += next_tab;
} else {
int dw = wcwidth(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) {
@@ -546,7 +541,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->Nrows()); unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
left += " "; left += " ";
left += std::to_string(lcount); left += std::to_string(lcount);
left += "L"; left += "L";
-29
View File
@@ -2,9 +2,6 @@
* 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"
@@ -15,30 +12,4 @@ 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
@@ -1,16 +0,0 @@
/*
* 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};
};
-34
View File
@@ -1,9 +1,6 @@
#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 {
@@ -13,12 +10,6 @@ enum class UndoType : std::uint8_t {
Newline, Newline,
DeleteRow, DeleteRow,
InsertRow, 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 {
@@ -27,32 +18,7 @@ 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
@@ -0,0 +1,63 @@
#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_;
};
+39 -303
View File
@@ -1,9 +1,7 @@
#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)
@@ -17,9 +15,6 @@ 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_;
} }
@@ -28,12 +23,7 @@ void
UndoSystem::EndGroup() UndoSystem::EndGroup()
{ {
commit(); commit();
if (group_depth_ > 0) active_group_id_ = 0;
--group_depth_;
if (group_depth_ == 0) {
active_group_id_ = 0;
maybe_prune();
}
} }
@@ -47,7 +37,7 @@ UndoSystem::Begin(UndoType type)
// 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 || type == const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
UndoType::InsertRow || type == UndoType::JoinLines); UndoType::InsertRow);
if (always_standalone) { if (always_standalone) {
commit(); commit();
} }
@@ -87,7 +77,6 @@ UndoSystem::Begin(UndoType type)
case UndoType::Newline: case UndoType::Newline:
case UndoType::DeleteRow: case UndoType::DeleteRow:
case UndoType::InsertRow: case UndoType::InsertRow:
case UndoType::JoinLines:
break; break;
} }
} }
@@ -153,9 +142,6 @@ 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;
@@ -183,8 +169,6 @@ 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();
} }
@@ -255,25 +239,7 @@ void
UndoSystem::mark_saved() 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();
} }
@@ -293,61 +259,19 @@ void
UndoSystem::clear() UndoSystem::clear()
{ {
discard_pending(); discard_pending();
// The root may have siblings (edits made after undoing to the start). free_node(tree_.root);
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.
@@ -356,22 +280,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) {
insert_node_text(*buf_, node); buf_->insert_text(node->row, node->col, node->text);
end_of_text(*buf_, node, ex, ey); buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
buf_->SetCursor(ex, ey); static_cast<std::size_t>(node->row));
} else { } else {
buf_->delete_text(node->row, node->col, node->TextSize()); buf_->delete_text(node->row, node->col, node->text.size());
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->TextSize()); buf_->delete_text(node->row, node->col, node->text.size());
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 {
insert_node_text(*buf_, node); buf_->insert_text(node->row, node->col, node->text);
end_of_text(*buf_, node, ex, ey); buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
buf_->SetCursor(ex, ey); static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::Newline: case UndoType::Newline:
@@ -401,16 +325,6 @@ 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::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;
} }
} }
@@ -418,13 +332,20 @@ 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;
UndoNode *children = node->child; // Free child subtree(s) and sibling branches
node->child = nullptr; 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;
}
delete node; delete node;
FreeUndoForest(children);
} }
@@ -432,27 +353,26 @@ 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
FreeUndoForest(node); while (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;
std::vector<UndoNode *> stack{cur}; for (UndoNode *child = cur->child; child != nullptr; child = child->next) {
while (!stack.empty()) { if (child == target) {
UndoNode *node = stack.back(); out_parent = cur;
stack.pop_back(); return true;
for (UndoNode *child = node->child; child != nullptr; child = child->next) {
if (child == target) {
out_parent = node;
return true;
}
stack.push_back(child);
} }
if (dfs_find_parent(child, target, out_parent))
return true;
} }
return false; return false;
} }
@@ -471,190 +391,11 @@ 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 = base_unsaved_ || (tree_.current != tree_.saved); bool dirty = (tree_.current != tree_.saved);
buf_->SetDirty(dirty); buf_->SetDirty(dirty);
} }
@@ -683,8 +424,6 @@ UndoSystem::type_str(UndoType t)
return "DeleteRow"; return "DeleteRow";
case UndoType::InsertRow: case UndoType::InsertRow:
return "InsertRow"; return "InsertRow";
case UndoType::JoinLines:
return "JoinLines";
} }
return "?"; return "?";
} }
@@ -695,14 +434,11 @@ UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
{ {
if (!root || !target) if (!root || !target)
return false; return false;
std::vector<UndoNode *> stack{root}; if (root == target)
while (!stack.empty()) { return true;
UndoNode *node = stack.back(); for (UndoNode *child = root->child; child != nullptr; child = child->next) {
stack.pop_back(); if (is_descendant(child, target))
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;
} }
-40
View File
@@ -76,35 +76,12 @@ 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
@@ -134,26 +111,9 @@ 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_;
+2 -30
View File
@@ -1,31 +1,3 @@
// Undo logic is implemented in UndoSystem; this file only owns node lifetime. // Placeholder translation unit for UndoTree struct definition.
// 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,16 +7,4 @@ 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);
+4 -31
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-system dylibs into # Fix up a macOS .app bundle by copying non-Qt 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,8 +29,9 @@ 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, copying every non-system dylib (e.g. Homebrew # libs it references recursively. Qt frameworks already live in the bundle after
# SDL2, freetype, pcre2) into Contents/Frameworks; SDL3 is handled below. # macdeployqt; this step is primarily for non-Qt dylibs (glib, icu, pcre2, zstd,
# 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.
@@ -43,34 +44,6 @@ 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.
+17 -8
View File
@@ -2,16 +2,17 @@
lib, lib,
stdenv, stdenv,
cmake, cmake,
pkg-config,
ncurses, ncurses,
pcre2,
SDL2, SDL2,
libGL, libGL,
xorg, xorg,
kdePackages,
qt6Packages ? kdePackages.qt6Packages,
installShellFiles, installShellFiles,
copyDesktopItems, copyDesktopItems,
makeDesktopItem, makeDesktopItem,
graphical ? false, graphical ? false,
graphical-qt ? false,
}: }:
let let
cmakeContent = builtins.readFile ./CMakeLists.txt; cmakeContent = builtins.readFile ./CMakeLists.txt;
@@ -22,30 +23,34 @@ 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 = if graphical then "kge" else "kte"; pname = if graphical then (if graphical-qt then "kge-qt" else "kge") else "kte";
inherit version; inherit version;
src = lib.cleanSource ./.; src = lib.cleanSource ./.;
nativeBuildInputs = [ nativeBuildInputs = [
cmake cmake
pkg-config
installShellFiles installShellFiles
] ++ lib.optionals graphical [ ] ++ lib.optionals graphical [
copyDesktopItems copyDesktopItems
] ++ lib.optionals graphical-qt [
qt6Packages.wrapQtAppsHook
]; ];
buildInputs = [ buildInputs = [
ncurses ncurses
pcre2 # regex engine (optional; std::regex without it)
] ++ lib.optionals graphical [ ] ++ lib.optionals graphical [
SDL2 SDL2
libGL libGL
xorg.libX11 xorg.libX11
] ++ lib.optionals graphical-qt [
kdePackages.qt6ct
qt6Packages.qtbase
]; ];
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" "-DKTE_STATIC_LINK=OFF"
]; ];
@@ -56,7 +61,7 @@ stdenv.mkDerivation {
desktopName = "kge"; desktopName = "kge";
genericName = "Text Editor"; genericName = "Text Editor";
comment = "kyle's graphical text editor"; comment = "kyle's graphical text editor";
exec = "kge %F"; exec = if graphical-qt then "kge-qt %F" else "kge %F";
icon = "kge"; icon = "kge";
terminal = false; terminal = false;
categories = [ "Utility" "TextEditor" "Development" ]; categories = [ "Utility" "TextEditor" "Development" ];
@@ -82,7 +87,11 @@ stdenv.mkDerivation {
installManPage ../docs/kte.1 installManPage ../docs/kte.1
${lib.optionalString graphical '' ${lib.optionalString graphical ''
cp kge $out/bin/kge ${if graphical-qt then ''
cp kge $out/bin/kge-qt
'' else ''
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
@@ -95,6 +104,6 @@ stdenv.mkDerivation {
meta = { meta = {
description = "kyle's text editor" + lib.optionalString graphical " (graphical)"; description = "kyle's text editor" + lib.optionalString graphical " (graphical)";
platforms = lib.platforms.unix; platforms = lib.platforms.unix;
mainProgram = if graphical then "kge" else "kte"; mainProgram = if graphical then (if graphical-qt then "kge-qt" else "kge") else "kte";
}; };
} }
+3 -1
View File
@@ -20,7 +20,7 @@ kte follows a clean separation of concerns with three main layers:
``` ```
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ Frontend Layer (Terminal/ImGui) │ │ Frontend Layer (Terminal/ImGui/Qt) │
│ - TerminalFrontend / ImGuiFrontend │ │ - TerminalFrontend / ImGuiFrontend │
│ - InputHandler + Renderer interfaces │ │ - InputHandler + Renderer interfaces │
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
@@ -154,6 +154,7 @@ Implementations:
`TerminalInputHandler`, `TerminalRenderer`) `TerminalInputHandler`, `TerminalRenderer`)
- **ImGui**: Dear ImGui-based (`ImGuiFrontend`, `ImGuiInputHandler`, - **ImGui**: Dear ImGui-based (`ImGuiFrontend`, `ImGuiInputHandler`,
`ImGuiRenderer`) `ImGuiRenderer`)
- **Qt**: Qt-based (`QtFrontend`, `QtInputHandler`, `QtRenderer`)
- **Test**: Programmatic testing (`TestFrontend`, `TestInputHandler`, - **Test**: Programmatic testing (`TestFrontend`, `TestInputHandler`,
`TestRenderer`) `TestRenderer`)
@@ -195,6 +196,7 @@ Large files that may need attention:
into logical groups into logical groups
- `Swap.cc` (1300 lines) - Crash recovery system (migrated to direct - `Swap.cc` (1300 lines) - Crash recovery system (migrated to direct
PieceTable operations) PieceTable operations)
- `QtFrontend.cc` (985 lines) - Qt integration
- `ImGuiRenderer.cc` (930 lines) - ImGui rendering - `ImGuiRenderer.cc` (930 lines) - ImGui rendering
- `PieceTable.cc` (800 lines) - Core data structure - `PieceTable.cc` (800 lines) - Core data structure
- `Buffer.cc` (763 lines) - Document model - `Buffer.cc` (763 lines) - Document model
-678
View File
@@ -1,678 +0,0 @@
# 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.
+124
View File
@@ -0,0 +1,124 @@
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).
+7 -2
View File
@@ -12,8 +12,9 @@
packages = eachSystem (system: rec { packages = eachSystem (system: rec {
default = kte; default = kte;
full = kge; full = kge;
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; }; kte = (pkgsFor system).callPackage ./default.nix { graphical = false; graphical-qt = false; };
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; }; kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; };
qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; };
}); });
devShells = eachSystem (system: devShells = eachSystem (system:
@@ -26,6 +27,10 @@
terminal = pkgs.mkShell { terminal = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.kte ]; inputsFrom = [ self.packages.${system}.kte ];
}; };
qt = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.qt ];
packages = with pkgs; [ gdb valgrind ];
};
} }
); );
-14
View File
@@ -1,14 +0,0 @@
[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;
+6 -38
View File
@@ -1,5 +1,4 @@
#include <clocale> #include <clocale>
#include <langinfo.h>
#include <cctype> #include <cctype>
#include <cerrno> #include <cerrno>
#include <cstdio> #include <cstdio>
@@ -25,8 +24,12 @@
#include "ErrorHandler.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
@@ -110,33 +113,10 @@ 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. // Ensure the error handler (and its log file) is initialised early.
kte::ErrorHandler::Instance(); kte::ErrorHandler::Instance();
@@ -312,20 +292,6 @@ 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 */
@@ -354,6 +320,8 @@ main(int argc, char *argv[])
fe->Step(editor, running); fe->Step(editor, running);
} }
fe->Shutdown();
return 0; return 0;
} catch (const std::exception &e) { } catch (const std::exception &e) {
std::string msg = std::string("Unhandled exception: ") + e.what(); std::string msg = std::string("Unhandled exception: ") + e.what();
+16
View File
@@ -14,3 +14,19 @@ zip -r kge.app.zip kge.app
sha256sum kge.app.zip sha256sum kge.app.zip
open . open .
cd .. cd ..
# Qt build disabled — ImGui frontend is the primary GUI.
# 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
# macdeployqt kge-qt.app -always-overwrite -verbose=3
# 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 ..
+2 -5
View File
@@ -163,14 +163,11 @@ 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;
// A raw-string delimiter is at most 16 characters (and has no while (k < n && s[k] != '(') {
// 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] == '(' && delim.size() <= 16) { if (k < n && s[k] == '(') {
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 -35
View File
@@ -46,45 +46,14 @@ 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
{ {
StatefulHighlighter::LineState prev;
(void) HighlightLineStateful(buf, row, prev, out);
}
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()) if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return state; return;
std::string s = buf.GetLineString(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;
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];
@@ -113,8 +82,7 @@ GoHighlighter::HighlightLineStateful(const Buffer &buf,
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
state.in_block_comment = true; break;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -184,6 +152,5 @@ GoHighlighter::HighlightLineStateful(const Buffer &buf,
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+1 -6
View File
@@ -5,17 +5,12 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class GoHighlighter final : public StatefulHighlighter { class GoHighlighter final : public LanguageHighlighter {
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_;
+163 -75
View File
@@ -1,122 +1,194 @@
#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() = default; HighlighterEngine::~HighlighterEngine()
{
// 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);
clear_caches_locked();
}
void
HighlighterEngine::clear_caches_locked() const
{
cache_.clear(); cache_.clear();
states_.clear(); state_cache_.clear();
state_last_contig_.clear();
} }
void LineHighlight
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
{ {
if (!have_version_ || buf_version != version_) { std::unique_lock<std::mutex> lock(mtx_);
// Content changed without OnEdit: nothing cached can be trusted. auto it = cache_.find(row);
clear_caches_locked(); if (it != cache_.end() && it->second.version == buf_version) {
version_ = buf_version; return it->second; // return by value (copy)
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;
if (hl_ && row >= 0 && !hl_->Stateful()) { result.spans.clear();
hl_->HighlightLine(buf, row, result.spans);
} else if (hl_ && row >= 0) { if (!hl_) {
const auto *stateful = static_cast<const StatefulHighlighter *>(hl_.get()); // Cache empty result and return it
// Continue from the last row with a known end state (or the row cache_[row] = result;
// just above the target, if that is known), caching every row's return result;
// 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); // Copy shared_ptr-like raw pointer for use outside critical sections
StatefulHighlighter::LineState state = (start >= 0) ? states_[static_cast<std::size_t>(start)] LanguageHighlighter *hl_ptr = hl_.get();
: StatefulHighlighter::LineState{}; bool is_stateful = dynamic_cast<StatefulHighlighter *>(hl_ptr) != nullptr;
const int nrows = static_cast<int>(buf.Nrows());
for (int r = start + 1; r <= row && r < nrows; ++r) { if (!is_stateful) {
LineHighlight lh; // Stateless fast path: we can release the lock while computing to reduce contention
lh.version = buf_version; lock.unlock();
state = stateful->HighlightLineStateful(buf, r, state, lh.spans); hl_ptr->HighlightLine(buf, row, result.spans);
if (static_cast<std::size_t>(r) == states_.size()) // Update cache and return
states_.push_back(state); std::lock_guard<std::mutex> gl(mtx_);
else cache_[row] = result;
states_[static_cast<std::size_t>(r)] = state; return result;
if (r == row) }
result = std::move(lh);
else if (row - r <= kCacheNear) // Stateful path: we need to walk from a known previous state. Keep lock while consulting caches,
cache_.insert_or_assign(r, std::move(lh)); // but release during heavy computation.
// Rows further above only needed their end state (kept in auto *stateful = static_cast<StatefulHighlighter *>(hl_ptr);
// states_): caching their spans too held about 10x the file size
// in memory after jumping to the end of a large file. 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.Nrows()) {
if (r > best)
best = r;
}
}
}
if (best >= 0) {
start_row = best;
prev_state = state_cache_.at(best).state;
} }
} }
// 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;
}
// 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;
}
void // Store in cache and return by value
HighlighterEngine::trim_cache_locked(const int row) const lock.lock();
{ cache_[row] = result;
if (cache_.size() <= kCacheMax) return result;
return;
cache_.erase(cache_.begin(), cache_.lower_bound(row - kCacheNear));
cache_.erase(cache_.upper_bound(row + kCacheNear), cache_.end());
} }
void void
HighlighterEngine::InvalidateFrom(int row) HighlighterEngine::InvalidateFrom(int row)
{ {
invalidate_from_locked(row); std::lock_guard<std::mutex> lock(mtx_);
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::OnEdit(int row, std::uint64_t buf_version) HighlighterEngine::ensure_worker_started() const
{ {
if (have_version_) if (worker_running_.load())
invalidate_from_locked(row); return;
version_ = buf_version; worker_running_.store(true);
have_version_ = true; worker_ = std::thread([this]() {
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;
@@ -132,5 +204,21 @@ 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
+46 -34
View File
@@ -1,11 +1,14 @@
// HighlighterEngine.h - caching layer for per-line highlights (main thread // HighlighterEngine.h - caching layer for per-line highlights
// 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"
@@ -21,20 +24,14 @@ public:
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl); void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
// Highlights for a line at a buffer version, computed if not cached. // Retrieve highlights for a given line and buffer version.
// The reference is valid until the next call on this engine (a later // Returns a copy to avoid lifetime issues across threads/renderers.
// lookup may evict the entry); copy it to keep it longer. // If cache is stale, recompute using the current highlighter.
const LineHighlight &GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const; LineHighlight GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
// Invalidate cached lines from row (inclusive) // 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
{ {
@@ -42,37 +39,52 @@ public:
} }
// Compute highlights for the visible range so drawing hits the cache. // Phase 3: viewport-first prefetch and background warming
// There is deliberately no background warming: a worker thread would read // Compute only the visible range now, and enqueue a background warm-around task.
// the Buffer while the main thread edits it (and could outlive a moved // warm_margin: how many extra lines above/below to warm in the background.
// 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_;
// Cached spans by row, valid for version_ (ordered so InvalidateFrom can // Simple cache by row index (mutable to allow caching in const GetLine)
// drop a tail cheaply). mutable std::unordered_map<int, LineHighlight> cache_;
mutable std::map<int, LineHighlight> cache_;
// For stateful highlighters: states_[r] is the state after row r, for // For stateful highlighters, remember per-line state (state after finishing that row)
// every row 0..states_.size()-1 (always a contiguous prefix). struct StateEntry {
mutable std::vector<StatefulHighlighter::LineState> states_; std::uint64_t version{0};
// Using the interface type; forward-declare via header
StatefulHighlighter::LineState state;
};
// Buffer version the caches describe. mutable std::unordered_map<int, StateEntry> state_cache_;
mutable std::uint64_t version_{0};
mutable bool have_version_{false};
void clear_caches_locked() const; // Track best known contiguous state row for a given version to avoid O(n) scans
mutable std::unordered_map<std::uint64_t, int> state_last_contig_;
void invalidate_from_locked(int row) const; // Thread-safety for caches and background worker state
mutable std::mutex mtx_;
// Span cache bounds: rows within kCacheNear of a requested row are // Background warmer
// cached; past kCacheMax entries, those far from the current row go. struct WarmRequest {
static constexpr int kCacheNear = 1024; const Buffer *buf{nullptr};
static constexpr std::size_t kCacheMax = 16384; std::uint64_t version{0};
int start_row{0};
int end_row{0}; // inclusive
// Visible rows to skip touching in the background (inclusive range).
// These are computed synchronously by PrefetchViewport.
int skip_first{0};
int skip_last{-1};
};
void trim_cache_locked(int row) const; mutable std::condition_variable cv_;
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
+16 -11
View File
@@ -55,26 +55,31 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS
std::string s = buf.GetLineString(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;
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\"" // Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) { if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) {
auto pos = s.find(state.raw_delim); auto pos = s.find(state.raw_delim);
if (pos == std::string::npos) { if (pos == std::string::npos) {
push(out, 0, n, TokenKind::String); push(out, 0, n, TokenKind::String);
return state; // still inside return state; // still inside
} else {
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
push(out, 0, end, TokenKind::String);
// remainder processed normally
s = s.substr(end);
n = static_cast<int>(s.size());
state.in_raw_string = false;
state.raw_delim.clear();
// Continue parsing remainder as a separate small loop
int base = end;
// original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
// For simplicity, mark rest as Default
if (n > 0)
push(out, base, base + n, TokenKind::Default);
return state;
} }
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
push(out, 0, end, TokenKind::String);
state.in_raw_string = false;
state.raw_delim.clear();
// Resume the normal tokenizer at the closing delimiter's end, on the
// same (unmodified) `s`/`n`, so anything after it - including a new
// triple-quoted string opening on this same line - is re-scanned
// instead of being dumped into a single opaque Default span.
i = end;
} }
int i = 0;
// Detect comment start '#', ignoring inside strings // Detect comment start '#', ignoring inside strings
while (i < n) { while (i < n) {
char c = s[i]; char c = s[i];
+2 -35
View File
@@ -47,42 +47,11 @@ RustHighlighter::RustHighlighter()
void void
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
StatefulHighlighter::LineState prev;
(void) HighlightLineStateful(buf, row, prev, out);
}
StatefulHighlighter::LineState
RustHighlighter::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()) if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return state; return;
std::string s = buf.GetLineString(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;
// 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;
}
}
while (i < n) { while (i < n) {
char c = s[i]; char c = s[i];
if (c == ' ' || c == '\t') { if (c == ' ' || c == '\t') {
@@ -110,8 +79,7 @@ RustHighlighter::HighlightLineStateful(const Buffer &buf,
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
state.in_block_comment = true; break;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -172,6 +140,5 @@ RustHighlighter::HighlightLineStateful(const Buffer &buf,
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+1 -6
View File
@@ -5,17 +5,12 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class RustHighlighter final : public StatefulHighlighter { class RustHighlighter final : public LanguageHighlighter {
public: public:
RustHighlighter(); RustHighlighter();
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_;
+3 -35
View File
@@ -47,42 +47,12 @@ SqlHighlighter::SqlHighlighter()
void void
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
StatefulHighlighter::LineState prev;
(void) HighlightLineStateful(buf, row, prev, out);
}
StatefulHighlighter::LineState
SqlHighlighter::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()) if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return state; return;
std::string s = buf.GetLineString(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;
// 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;
}
}
while (i < n) { while (i < n) {
char c = s[i]; char c = s[i];
if (c == ' ' || c == '\t') { if (c == ' ' || c == '\t') {
@@ -98,7 +68,7 @@ SqlHighlighter::HighlightLineStateful(const Buffer &buf,
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
break; break;
} }
// block comment: /* ... */ (may span multiple lines) // simple block comment on same line: /* ... */
if (c == '/' && i + 1 < n && s[i + 1] == '*') { if (c == '/' && i + 1 < n && s[i + 1] == '*') {
int j = i + 2; int j = i + 2;
bool closed = false; bool closed = false;
@@ -112,8 +82,7 @@ SqlHighlighter::HighlightLineStateful(const Buffer &buf,
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
state.in_block_comment = true; break;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -182,6 +151,5 @@ SqlHighlighter::HighlightLineStateful(const Buffer &buf,
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+1 -6
View File
@@ -5,17 +5,12 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class SqlHighlighter final : public StatefulHighlighter { class SqlHighlighter final : public LanguageHighlighter {
public: public:
SqlHighlighter(); SqlHighlighter();
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_;
+1 -1
View File
@@ -94,7 +94,7 @@ public:
[[nodiscard]] std::string [[nodiscard]] std::string
Text() const Text() const
{ {
const auto &rows = Buf().LinesForTests(); const auto &rows = Buf().Rows();
std::string out; std::string out;
for (std::size_t i = 0; i < rows.size(); ++i) { for (std::size_t i = 0; i < rows.size(); ++i) {
out += static_cast<std::string>(rows[i]); out += static_cast<std::string>(rows[i]);
+1 -16
View File
@@ -1,32 +1,17 @@
#include "Test.h" #include "Test.h"
#include <iostream> #include <iostream>
#include <chrono> #include <chrono>
#include <string>
#include <vector>
// Usage: kte_tests [substring...] runs the tests whose names contain any of
// the substrings (all tests when none are given).
int int
main(int argc, char **argv) main()
{ {
using namespace std::chrono; using namespace std::chrono;
auto &reg = ktet::registry(); auto &reg = ktet::registry();
std::vector<std::string> filters(argv + 1, argv + argc);
auto selected = [&filters](const std::string &name) {
if (filters.empty())
return true;
for (const auto &f: filters)
if (name.find(f) != std::string::npos)
return true;
return false;
};
std::cout << "kte unit tests: " << reg.size() << " test(s)\n"; std::cout << "kte unit tests: " << reg.size() << " test(s)\n";
int failed = 0; int failed = 0;
auto t0 = steady_clock::now(); auto t0 = steady_clock::now();
for (const auto &tc: reg) { for (const auto &tc: reg) {
if (!selected(tc.name))
continue;
auto ts = steady_clock::now(); auto ts = steady_clock::now();
try { try {
tc.fn(); tc.fn();
File diff suppressed because it is too large Load Diff
+36 -9
View File
@@ -7,7 +7,7 @@
* Benchmarks cover: * Benchmarks cover:
* - PieceTable operations (insert, delete, GetLine, GetLineRange) * - PieceTable operations (insert, delete, GetLine, GetLineRange)
* - Buffer operations (Nrows, GetLineString, GetLineView) * - Buffer operations (Nrows, GetLineString, GetLineView)
* - Iteration patterns (GetLineString vs GetLineView) * - Iteration patterns (comparing old Rows() vs new GetLineString/GetLineView)
* - Syntax highlighting on large files * - Syntax highlighting on large files
* *
* Each benchmark reports execution time in milliseconds. * Each benchmark reports execution time in milliseconds.
@@ -240,6 +240,26 @@ TEST (Benchmark_Buffer_GetLineView_Sequential)
} }
TEST (Benchmark_Buffer_Rows_Materialization)
{
std::cout << "\n=== Buffer Rows() Materialization Benchmark ===\n";
Buffer buf;
std::string data = generate_large_file(10000, 80);
buf.insert_text(0, 0, data);
std::size_t total_chars = 0;
{
BenchmarkTimer timer("Rows() materialization + iteration on 10K lines");
const auto &rows = buf.Rows();
for (std::size_t i = 0; i < rows.size(); ++i) {
total_chars += rows[i].size();
}
}
EXPECT_TRUE(total_chars > 0);
}
TEST (Benchmark_Buffer_Iteration_Comparison) TEST (Benchmark_Buffer_Iteration_Comparison)
{ {
std::cout << "\n=== Buffer Iteration Pattern Comparison ===\n"; std::cout << "\n=== Buffer Iteration Pattern Comparison ===\n";
@@ -247,8 +267,16 @@ TEST (Benchmark_Buffer_Iteration_Comparison)
std::string data = generate_large_file(5000, 80); std::string data = generate_large_file(5000, 80);
buf.insert_text(0, 0, data); buf.insert_text(0, 0, data);
std::size_t sum2 = 0, sum3 = 0; std::size_t sum1 = 0, sum2 = 0, sum3 = 0;
std::size_t newlines = 0;
// Pattern 1: Old style with Rows()
{
BenchmarkTimer timer("Pattern 1: Rows() + iteration");
const auto &rows = buf.Rows();
for (std::size_t i = 0; i < rows.size(); ++i) {
sum1 += rows[i].size();
}
}
// Pattern 2: New style with GetLineString // Pattern 2: New style with GetLineString
{ {
@@ -262,15 +290,14 @@ TEST (Benchmark_Buffer_Iteration_Comparison)
{ {
BenchmarkTimer timer("Pattern 3: Nrows() + GetLineView (zero-copy)"); BenchmarkTimer timer("Pattern 3: Nrows() + GetLineView (zero-copy)");
for (std::size_t i = 0; i < buf.Nrows(); ++i) { for (std::size_t i = 0; i < buf.Nrows(); ++i) {
const auto v = buf.GetLineView(i); sum3 += buf.GetLineView(i).size();
sum3 += v.size();
newlines += !v.empty() && v.back() == '\n';
} }
} }
// GetLineView includes the newlines GetLineString strips. // sum1 and sum2 should match (both strip newlines)
ASSERT_EQ(sum3, sum2 + newlines); ASSERT_EQ(sum1, sum2);
EXPECT_TRUE(newlines > 0); // sum3 includes newlines, so it will be larger
EXPECT_TRUE(sum3 > sum2);
} }
+1 -1
View File
@@ -56,7 +56,7 @@ static void
check_buffer_matches_model(const Buffer &b, const std::string &model) check_buffer_matches_model(const Buffer &b, const std::string &model)
{ {
auto expected_lines = split_lines_preserve_trailing_empty(model); auto expected_lines = split_lines_preserve_trailing_empty(model);
const auto &rows = b.LinesForTests(); const auto &rows = b.Rows();
ASSERT_EQ(rows.size(), expected_lines.size()); ASSERT_EQ(rows.size(), expected_lines.size());
ASSERT_EQ(b.Nrows(), rows.size()); ASSERT_EQ(b.Nrows(), rows.size());
-75
View File
@@ -108,78 +108,3 @@ TEST(CommandSemantics_CopyRegion_And_KillRegion)
ASSERT_EQ(b.MarkSet(), false); ASSERT_EQ(b.MarkSet(), false);
ASSERT_EQ(h.Text(), std::string("hello ")); ASSERT_EQ(h.Text(), std::string("hello "));
} }
TEST(CommandSemantics_Syntax_OnOff_SetsUserOverride)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
// Before any explicit :syntax command, nothing has overridden the
// frontend's config-driven default.
ASSERT_EQ(b.SyntaxUserOverride(), false);
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "off"));
ASSERT_EQ(b.SyntaxEnabled(), false);
// This flag is what a frontend's per-frame "apply config default" pass
// must check before re-enabling syntax, or a manual :syntax off gets
// silently undone on the next frame.
ASSERT_EQ(b.SyntaxUserOverride(), true);
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "on"));
ASSERT_EQ(b.SyntaxEnabled(), true);
ASSERT_EQ(b.SyntaxUserOverride(), true);
}
// Word motion and word deletion with counts, across line ends and
// punctuation; deleting backwards N words kills them in text order.
TEST(CommandSemantics_WordMotionAndDelete_WithCounts)
{
const std::string text = "alpha beta, gamma\n\n delta.epsilon zeta\nend";
struct Pos {
std::size_t x, y;
};
auto run_motion = [&](CommandId id, Pos start, int count) {
TestHarness h;
h.Buf().insert_text(0, 0, text);
h.Buf().SetCursor(start.x, start.y);
(void) h.Exec(id, "", count);
return Pos{h.Buf().Curx(), h.Buf().Cury()};
};
Pos p = run_motion(CommandId::WordNext, {0, 0}, 1);
ASSERT_EQ(p.x, (std::size_t) 6);
p = run_motion(CommandId::WordNext, {0, 0}, 3);
ASSERT_EQ(p.y, (std::size_t) 2);
ASSERT_EQ(p.x, (std::size_t) 2);
p = run_motion(CommandId::WordNext, {0, 0}, 5);
ASSERT_EQ(p.y, (std::size_t) 2);
ASSERT_EQ(p.x, (std::size_t) 16);
p = run_motion(CommandId::WordPrev, {3, 3}, 1);
ASSERT_EQ(p.y, (std::size_t) 3);
ASSERT_EQ(p.x, (std::size_t) 0);
p = run_motion(CommandId::WordPrev, {3, 3}, 3);
ASSERT_EQ(p.y, (std::size_t) 2);
ASSERT_EQ(p.x, (std::size_t) 8);
p = run_motion(CommandId::WordPrev, {3, 3}, 50);
ASSERT_EQ(p.y, (std::size_t) 0);
ASSERT_EQ(p.x, (std::size_t) 0);
{
TestHarness h;
h.Buf().insert_text(0, 0, text);
h.Buf().SetCursor(3, 3);
ASSERT_TRUE(h.Exec(CommandId::DeleteWordPrev, "", 3));
ASSERT_EQ(h.Text(), std::string("alpha beta, gamma\n\n delta."));
ASSERT_EQ(h.EditorRef().KillRingHead(), std::string("epsilon zeta\nend"));
}
{
TestHarness h;
h.Buf().insert_text(0, 0, text);
h.Buf().SetCursor(6, 0);
ASSERT_TRUE(h.Exec(CommandId::DeleteWordNext, "", 2));
ASSERT_EQ(h.Text(), std::string("alpha delta.epsilon zeta\nend"));
ASSERT_EQ(h.EditorRef().KillRingHead(), std::string("beta, gamma\n\n "));
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ read_file_bytes(const std::string &path)
static std::string static std::string
buffer_bytes_via_views(const Buffer &b) buffer_bytes_via_views(const Buffer &b)
{ {
const auto &rows = b.LinesForTests(); const auto &rows = b.Rows();
std::string out; std::string out;
for (std::size_t i = 0; i < rows.size(); i++) { for (std::size_t i = 0; i < rows.size(); i++) {
auto v = b.GetLineView(i); auto v = b.GetLineView(i);
-15
View File
@@ -37,21 +37,6 @@ TEST(KKeymap_KPrefix_CanonicalChords)
} }
TEST(KKeymap_KPrefix_UppercaseE_RejectedAndDistinctFromLowercaseE)
{
CommandId id{};
// 'e' is a real binding (OpenFileStart).
ASSERT_TRUE(KLookupKCommand('e', false, id));
ASSERT_EQ(id, CommandId::OpenFileStart);
// 'E' must be rejected, not silently aliased to 'e' via case-insensitive
// lookup (the switch normalizes to lowercase, so this has to be special-
// cased before it).
ASSERT_EQ(KLookupKCommand('E', false, id), false);
}
TEST(KKeymap_CtrlChords_CanonicalChords) TEST(KKeymap_CtrlChords_CanonicalChords)
{ {
CommandId id{}; CommandId id{};
+14 -18
View File
@@ -2,13 +2,13 @@
* test_migration_coverage.cc - Edge case tests for Buffer::Line migration * test_migration_coverage.cc - Edge case tests for Buffer::Line migration
* *
* This file provides comprehensive test coverage for the migration from * This file provides comprehensive test coverage for the migration from
* the removed Buffer::Rows() to direct PieceTable operations using Nrows(), GetLineString(), * Buffer::Rows() to direct PieceTable operations using Nrows(), GetLineString(),
* and GetLineView(). * and GetLineView().
* *
* Tests cover: * Tests cover:
* - Edge cases: empty buffers, single lines, very long lines * - Edge cases: empty buffers, single lines, very long lines
* - Boundary conditions: first line, last line, out-of-bounds * - Boundary conditions: first line, last line, out-of-bounds
* - Consistency: GetLineString vs GetLineView vs LinesForTests() * - Consistency: GetLineString vs GetLineView vs Rows()
* - Performance: large files, many small operations * - Performance: large files, many small operations
* - Correctness: special characters, newlines, unicode * - Correctness: special characters, newlines, unicode
*/ */
@@ -117,7 +117,7 @@ TEST (Migration_ManyEmptyLines)
// ============================================================================ // ============================================================================
// Consistency Tests: GetLineString vs GetLineView vs LinesForTests() // Consistency Tests: GetLineString vs GetLineView vs Rows()
// ============================================================================ // ============================================================================
TEST (Migration_Consistency_AllMethods) TEST (Migration_Consistency_AllMethods)
@@ -125,13 +125,13 @@ TEST (Migration_Consistency_AllMethods)
Buffer buf; Buffer buf;
buf.insert_text(0, 0, std::string("abc\n123\nxyz")); buf.insert_text(0, 0, std::string("abc\n123\nxyz"));
const auto &rows = buf.LinesForTests(); const auto &rows = buf.Rows();
ASSERT_EQ(buf.Nrows(), rows.size()); ASSERT_EQ(buf.Nrows(), rows.size());
for (std::size_t i = 0; i < buf.Nrows(); ++i) { for (std::size_t i = 0; i < buf.Nrows(); ++i) {
std::string via_string = buf.GetLineString(i); std::string via_string = buf.GetLineString(i);
std::string via_rows = std::string(rows[i]); std::string via_rows = std::string(rows[i]);
// GetLineString and LinesForTests() both strip newlines // GetLineString and Rows() both strip newlines
ASSERT_EQ(via_string, via_rows); ASSERT_EQ(via_string, via_rows);
// GetLineView includes the raw range (with newlines if present) // GetLineView includes the raw range (with newlines if present)
// Just verify it's accessible // Just verify it's accessible
@@ -148,18 +148,18 @@ TEST (Migration_Consistency_AfterEdits)
// Edit: insert in middle // Edit: insert in middle
buf.insert_text(1, 2, std::string("XX")); buf.insert_text(1, 2, std::string("XX"));
const auto &rows = buf.LinesForTests(); const auto &rows = buf.Rows();
ASSERT_EQ(buf.Nrows(), rows.size()); ASSERT_EQ(buf.Nrows(), rows.size());
for (std::size_t i = 0; i < buf.Nrows(); ++i) { for (std::size_t i = 0; i < buf.Nrows(); ++i) {
// GetLineString and LinesForTests() both strip newlines // GetLineString and Rows() both strip newlines
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i])); ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
} }
// Edit: delete line // Edit: delete line
buf.delete_row(1); buf.delete_row(1);
const auto &rows2 = buf.LinesForTests(); const auto &rows2 = buf.Rows();
ASSERT_EQ(buf.Nrows(), rows2.size()); ASSERT_EQ(buf.Nrows(), rows2.size());
for (std::size_t i = 0; i < buf.Nrows(); ++i) { for (std::size_t i = 0; i < buf.Nrows(); ++i) {
@@ -332,7 +332,7 @@ TEST (Migration_Stress_ManySmallInserts)
ASSERT_EQ(buf.GetLineString(1).size(), (std::size_t) 100); ASSERT_EQ(buf.GetLineString(1).size(), (std::size_t) 100);
// Verify consistency // Verify consistency
const auto &rows = buf.LinesForTests(); const auto &rows = buf.Rows();
ASSERT_EQ(buf.GetLineString(1), std::string(rows[1])); ASSERT_EQ(buf.GetLineString(1), std::string(rows[1]));
} }
@@ -367,11 +367,11 @@ TEST (Migration_Stress_AlternatingInsertDelete)
} }
// Verify consistency after many operations // Verify consistency after many operations
const auto &rows = buf.LinesForTests(); const auto &rows = buf.Rows();
ASSERT_EQ(buf.Nrows(), rows.size()); ASSERT_EQ(buf.Nrows(), rows.size());
for (std::size_t i = 0; i < buf.Nrows(); ++i) { for (std::size_t i = 0; i < buf.Nrows(); ++i) {
// GetLineString and LinesForTests() both strip newlines // GetLineString and Rows() both strip newlines
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i])); ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
} }
} }
@@ -414,21 +414,17 @@ TEST (Migration_EmptyBufferCheck_Pattern)
TEST (Migration_SyntaxHighlighter_Pattern) TEST (Migration_SyntaxHighlighter_Pattern)
{ {
// Test the pattern used in syntax highlighters: loop Nrows(), fetch each // Test the pattern used in syntax highlighters
// line via GetLineString(row). Assert the pattern actually returns the
// right content, not just that it doesn't crash - see syntax highlighter
// correctness tests (test_syntax_highlighting.cc) for tokenization coverage.
Buffer buf; Buffer buf;
buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}")); buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}"));
ASSERT_EQ(buf.Nrows(), static_cast<std::size_t>(3));
const std::vector<std::string> expected = {"int main() {", " return 0;", "}"};
for (std::size_t row = 0; row < buf.Nrows(); ++row) { for (std::size_t row = 0; row < buf.Nrows(); ++row) {
// This is the pattern used in all migrated highlighters
if (row >= buf.Nrows()) { if (row >= buf.Nrows()) {
break; // Should never happen break; // Should never happen
} }
std::string line = buf.GetLineString(row); std::string line = buf.GetLineString(row);
ASSERT_EQ(line, expected[row]); // Successfully accessed line - size() is always valid for std::string
} }
} }
-12
View File
@@ -102,15 +102,3 @@ TEST(PasteSplit_MultilineCRLF)
ASSERT_TRUE(cmds[3].id == CommandId::Newline); ASSERT_TRUE(cmds[3].id == CommandId::Newline);
ASSERT_TRUE(cmds[4].arg == "line3"); ASSERT_TRUE(cmds[4].arg == "line3");
} }
// Pasting into a prompt must not produce Newline (which accepts the prompt):
// line breaks become spaces in a single InsertText.
TEST(PasteSplit_ForPrompt_FlattensLineBreaks)
{
const auto cmds = SplitPasteForPrompt("foo\nbar\r\nbaz\rqux");
ASSERT_EQ(cmds.size(), (std::size_t) 1);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText);
ASSERT_EQ(cmds[0].arg, std::string("foo bar baz qux"));
ASSERT_EQ(SplitPasteForPrompt("").size(), (std::size_t) 0);
}
+1 -58
View File
@@ -196,61 +196,4 @@ TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic)
ASSERT_EQ(r.second, exp_end); ASSERT_EQ(r.second, exp_end);
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line)); ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
} }
} }
// Random edits against a std::string model, starting from adopted original
// storage, with consolidation forced often: every read path (ranges, views,
// lines, offset conversions) must agree with the model after each edit.
TEST (PieceTable_ReferenceModel_AllReadPaths)
{
std::mt19937 rng(0x5EEDu);
for (int round = 0; round < 20; ++round) {
PieceTable pt(0, 16, 8, 64); // tiny limits: consolidation runs constantly
std::string model = "first line\nsecond\n\nthird line here\nlast";
pt.AdoptOriginal(std::string(model));
for (int step = 0; step < 300; ++step) {
const std::size_t n = model.size();
if (n == 0 || rng() % 3 != 0) {
static const char *frags[] = {"x", "\n", "ab\ncd", "", "tail\n", "\n\n", "é"};
const std::string ins = frags[rng() % 7];
const std::size_t at = n ? rng() % (n + 1) : 0;
pt.Insert(at, ins.data(), ins.size());
model.insert(at, ins);
} else {
const std::size_t at = rng() % n;
const std::size_t len = 1 + rng() % std::min<std::size_t>(n - at, 7);
pt.Delete(at, len);
model.erase(at, len);
}
ASSERT_EQ(pt.Size(), model.size());
ASSERT_EQ(std::string(pt.ContentView()), model);
// Line structure.
std::vector<std::size_t> starts{0};
for (std::size_t i = 0; i < model.size(); ++i)
if (model[i] == '\n')
starts.push_back(i + 1);
ASSERT_EQ(pt.LineCount(), starts.size());
for (std::size_t l = 0; l < starts.size(); ++l) {
const std::size_t end = l + 1 < starts.size() ? starts[l + 1] - 1 : model.size();
ASSERT_EQ(pt.GetLine(l), model.substr(starts[l], end - starts[l]));
ASSERT_EQ(pt.LineColToByteOffset(l, 1000), end);
}
// Random ranges and views (edit first, so views take the
// no-materialization path where they can).
for (int k = 0; k < 4 && !model.empty(); ++k) {
const std::size_t off = rng() % model.size();
const std::size_t len = 1 + rng() % 9;
ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len));
pt.Insert(0, "", 0); // no-op: must not disturb anything
ASSERT_EQ(std::string(pt.View(off, len)), model.substr(off, len));
const auto [row, col] = pt.ByteOffsetToLineCol(off);
ASSERT_EQ(starts[row] + col, off);
}
std::string chunks;
pt.ForEachChunk([&](const char *d, std::size_t len) {
chunks.append(d, len);
});
ASSERT_EQ(chunks, model);
}
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ static std::string
to_string_rows(const Buffer &buf) to_string_rows(const Buffer &buf)
{ {
std::string out; std::string out;
for (const auto &r: buf.LinesForTests()) { for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r); out += static_cast<std::string>(r);
out.push_back('\n'); out.push_back('\n');
} }
@@ -45,7 +45,7 @@ TEST(ReflowParagraph_IndentedBullets_PreserveStructure)
const int width = 80; const int width = 80;
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width)); ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
const auto &rows = buf->LinesForTests(); const auto &rows = buf->Rows();
const std::string result = to_string_rows(*buf); const std::string result = to_string_rows(*buf);
// We should have 3 lines (plus possibly a trailing empty line) // We should have 3 lines (plus possibly a trailing empty line)
+2 -2
View File
@@ -12,7 +12,7 @@ static std::string
to_string_rows(const Buffer &buf) to_string_rows(const Buffer &buf)
{ {
std::string out; std::string out;
for (const auto &r: buf.LinesForTests()) { for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r); out += static_cast<std::string>(r);
out.push_back('\n'); out.push_back('\n');
} }
@@ -46,7 +46,7 @@ TEST(ReflowParagraph_NumberedList_HangingIndent)
const int width = 25; const int width = 25;
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width)); ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
const auto &rows = buf->LinesForTests(); const auto &rows = buf->Rows();
ASSERT_TRUE(!rows.empty()); ASSERT_TRUE(!rows.empty());
const std::string dump = to_string_rows(*buf); const std::string dump = to_string_rows(*buf);
+2 -2
View File
@@ -12,7 +12,7 @@ static std::string
to_string_rows(const Buffer &buf) to_string_rows(const Buffer &buf)
{ {
std::string out; std::string out;
for (const auto &r: buf.LinesForTests()) { for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r); out += static_cast<std::string>(r);
out.push_back('\n'); out.push_back('\n');
} }
@@ -50,7 +50,7 @@ TEST (ReflowUndo)
const std::string reflowed_dump = to_string_rows(*buf); const std::string reflowed_dump = to_string_rows(*buf);
ASSERT_TRUE(reflowed_dump != original_dump); ASSERT_TRUE(reflowed_dump != original_dump);
ASSERT_TRUE(buf->Nrows() > 1); ASSERT_TRUE(buf->Rows().size() > 1);
// Undo reflow // Undo reflow
ASSERT_TRUE(Execute(ed, "undo", "", 1)); ASSERT_TRUE(Execute(ed, "undo", "", 1));
+46
View File
@@ -0,0 +1,46 @@
#include "Test.h"
#include "OptimizedSearch.h"
#include <string>
#include <vector>
static std::vector<std::size_t>
ref_find_all(const std::string &text, const std::string &pat)
{
std::vector<std::size_t> res;
if (pat.empty())
return res;
std::size_t from = 0;
while (true) {
auto p = text.find(pat, from);
if (p == std::string::npos)
break;
res.push_back(p);
from = p + pat.size();
}
return res;
}
TEST(OptimizedSearch_basic_cases)
{
OptimizedSearch os;
struct Case {
std::string text;
std::string pat;
} cases[] = {
{"", ""},
{"", "a"},
{"a", ""},
{"a", "a"},
{"aaaaa", "aa"},
{"hello world", "world"},
{"abcabcabc", "abc"},
{"the quick brown fox", "fox"},
};
for (auto &c: cases) {
auto got = os.find_all(c.text, c.pat, 0);
auto ref = ref_find_all(c.text, c.pat);
ASSERT_EQ(got, ref);
}
}
-30
View File
@@ -101,36 +101,6 @@ TEST(SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState)
} }
TEST(SearchFlow_SearchReplace_EmptyWith_ReplacesAdjacentOverlappingMatches)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
// "aaaa" with "aa" -> "" must remove both non-overlapping occurrences, not
// just the first: after deleting the match at column 0, the next "aa" now
// sits at column 0 too (not column 1), so the scan must resume at the
// deletion point rather than one character past it.
b.insert_text(0, 0, "aaaa\n");
b.SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind);
ASSERT_TRUE(h.Exec(CommandId::InsertText, "aa"));
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
// Leave the replacement empty and accept.
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_EQ(b.GetLineString(0), std::string(""));
}
TEST(SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter) TEST(SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter)
{ {
TestHarness h; TestHarness h;
+2 -429
View File
@@ -7,11 +7,9 @@
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce #include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
#include <chrono>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
#include <memory>
#include <fstream> #include <fstream>
#include <string> #include <string>
#include <unistd.h> #include <unistd.h>
@@ -37,7 +35,7 @@ read_file_bytes(const std::string &path)
static std::string static std::string
buffer_bytes_via_views(const Buffer &b) buffer_bytes_via_views(const Buffer &b)
{ {
const auto &rows = b.LinesForTests(); const auto &rows = b.Rows();
std::string out; std::string out;
for (std::size_t i = 0; i < rows.size(); i++) { for (std::size_t i = 0; i < rows.size(); i++) {
auto v = b.GetLineView(i); auto v = b.GetLineView(i);
@@ -166,9 +164,7 @@ TEST(SwapRecoveryPrompt_Discard_DeletesSwapAndOpensClean)
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard); ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
ASSERT_EQ(ed.PromptActive(), true); ASSERT_EQ(ed.PromptActive(), true);
// An explicit 'n' discards. (An empty answer cancels instead; see // Default answer (empty) is 'no' => discard.
// SwapRecoveryPrompt_EmptyAnswer_KeepsSwap.)
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "n"));
ASSERT_TRUE(Execute(ed, CommandId::Newline)); ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None); ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
ASSERT_EQ(ed.PromptActive(), false); ASSERT_EQ(ed.PromptActive(), false);
@@ -282,426 +278,3 @@ TEST(SwapRecoveryPrompt_CorruptSwap_OffersDelete)
std::remove(file_path.c_str()); std::remove(file_path.c_str());
std::filesystem::remove_all(xdg_root); std::filesystem::remove_all(xdg_root);
} }
// Declining to delete an unreadable swap keeps it aside as ".corrupt" so the
// new session journals into a fresh file instead of behind the bad data.
TEST(SwapRecoveryPrompt_CorruptSwap_Decline_KeepsAsideAndStartsFresh)
{
ktet::InstallDefaultCommandsOnce();
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
(std::string("kte_ut_xdg_state_corrupt_keep_") +
std::to_string((int) ::getpid()));
std::filesystem::remove_all(xdg_root);
std::filesystem::create_directories(xdg_root);
const ScopedXdgStateHome scoped(xdg_root.string());
const std::filesystem::path work = xdg_root / "work";
std::filesystem::create_directories(work);
const std::string file_path = (work / "corrupt_keep.txt").string();
write_file_bytes(file_path, "base\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(file_path, err));
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
std::filesystem::create_directories(std::filesystem::path(swap_path).parent_path());
write_file_bytes(swap_path, std::string(80, 'z')); // long enough to be "trusted", bad magic
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(b.Filename());
ASSERT_EQ(ed.ProcessPendingOpens(), false);
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::DeleteCorruptSwap);
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "n"));
ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
ASSERT_TRUE(std::filesystem::exists(swap_path + ".corrupt"));
// New edits journal into a fresh, replayable file.
Buffer *cur = ed.CurrentBuffer();
ASSERT_TRUE(cur != nullptr);
cur->insert_text(0, 0, std::string("X"));
ed.Swap()->Flush(cur);
Buffer check;
ASSERT_TRUE(check.OpenFromFile(file_path, err));
std::string rerr;
ASSERT_TRUE(kte::SwapManager::ReplayFile(check, swap_path, rerr));
ASSERT_EQ(check.GetLineString(0), std::string("Xbase"));
std::remove(file_path.c_str());
std::filesystem::remove_all(xdg_root);
}
namespace {
// Journal for `file_path` holding the edits in `edit`, via a throwaway manager.
static std::string
make_journal(const std::string &file_path, void (*edit)(Buffer &), std::string &expected)
{
Buffer b;
std::string err;
(void) b.OpenFromFile(file_path, err);
std::remove(kte::SwapManager::ComputeSwapPathForTests(b).c_str());
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
edit(b);
sm.Flush(&b);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
expected = buffer_bytes_via_views(b);
// Keep the journal as a crash would leave it (Detach would checkpoint).
const std::string bytes = read_file_bytes(swap_path);
b.SetSwapRecorder(nullptr);
sm.Detach(&b, true);
write_file_bytes(swap_path, bytes);
return swap_path;
}
struct XdgSandbox {
std::filesystem::path root;
std::unique_ptr<ScopedXdgStateHome> scoped;
explicit XdgSandbox(const std::string &tag)
{
root = std::filesystem::temp_directory_path() /
(std::string("kte_ut_xdg_") + tag + "_" + std::to_string((int) ::getpid()));
std::filesystem::remove_all(root);
std::filesystem::create_directories(root / "work");
scoped = std::make_unique<ScopedXdgStateHome>(root.string());
}
~XdgSandbox()
{
scoped.reset();
std::filesystem::remove_all(root);
}
};
static void
answer(Editor &ed, const char *yn)
{
ASSERT_TRUE(Execute(ed, CommandId::InsertText, yn));
ASSERT_TRUE(Execute(ed, CommandId::Newline));
}
} // namespace
// Recovery must work when another named buffer is current (the AddBuffer
// path): opening used to delete the very swap file it was about to replay.
TEST(SwapRecoveryPrompt_Recover_WithAnotherNamedBufferOpen)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("recover_named");
const std::string other = (sb.root / "work" / "other.txt").string();
const std::string file = (sb.root / "work" / "r.txt").string();
write_file_bytes(other, "other\n");
write_file_bytes(file, "base\n");
std::string expected;
const std::string swp = make_journal(file, [](Buffer &b) {
b.insert_text(0, 0, std::string("A"));
}, expected);
Editor ed;
ed.SetDimensions(24, 80);
std::string err;
ASSERT_TRUE(ed.OpenFile(other, err));
ed.RequestOpenFile(file);
(void) ed.ProcessPendingOpens();
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
answer(ed, "y");
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), expected);
ASSERT_TRUE(std::filesystem::exists(swp));
}
// After recovering from a journal whose last record is torn, the torn bytes
// are dropped, so records from the recovered session stay replayable.
TEST(SwapRecoveryPrompt_Recover_TornTail_JournalStaysReplayable)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("recover_torn");
const std::string file = (sb.root / "work" / "t.txt").string();
write_file_bytes(file, "base\n");
std::string expected;
const std::string swp = make_journal(file, [](Buffer &b) {
b.insert_text(0, 0, std::string("A"));
}, expected);
write_file_bytes(swp, read_file_bytes(swp) + std::string("\x01\x20\x00\x00zz", 6)); // torn record
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(file);
(void) ed.ProcessPendingOpens();
answer(ed, "y");
Buffer *cur = ed.CurrentBuffer();
ASSERT_EQ(buffer_bytes_via_views(*cur), std::string("Abase\n"));
cur->insert_text(0, 1, std::string("Z"));
ed.Swap()->Flush(cur);
Buffer check;
std::string err, rerr;
ASSERT_TRUE(check.OpenFromFile(file, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(check, swp, rerr));
ASSERT_EQ(buffer_bytes_via_views(check), std::string("AZbase\n"));
}
// Opening a file that is already open switches to its buffer instead of
// creating a second buffer sharing the same swap journal.
TEST(SwapRecoveryPrompt_OpenTwice_SwitchesToExisting)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("open_twice");
const std::string file = (sb.root / "work" / "d.txt").string();
const std::string other = (sb.root / "work" / "o.txt").string();
write_file_bytes(file, "x\n");
write_file_bytes(other, "y\n");
Editor ed;
ed.SetDimensions(24, 80);
std::string err;
ASSERT_TRUE(ed.OpenFile(file, err));
ASSERT_TRUE(ed.OpenFile(other, err));
const std::size_t n = ed.Buffers().size();
ASSERT_TRUE(ed.OpenFile(file, err));
ASSERT_EQ(ed.Buffers().size(), n);
ASSERT_EQ(ed.CurrentBuffer()->Filename(), std::filesystem::canonical(file).string());
ed.RequestOpenFile(other);
(void) ed.ProcessPendingOpens();
ASSERT_EQ(ed.Buffers().size(), n);
ASSERT_EQ(ed.CurrentBuffer()->Filename(), std::filesystem::canonical(other).string());
}
// Distinct paths must not map to the same swap file.
TEST(SwapPath_EncodingIsOneToOne)
{
ASSERT_TRUE(kte::SwapManager::ComputeSwapPathForFilename("/x/a!b") !=
kte::SwapManager::ComputeSwapPathForFilename("/x/a/b"));
ASSERT_TRUE(kte::SwapManager::ComputeSwapPathForFilename("/x/a%21b") !=
kte::SwapManager::ComputeSwapPathForFilename("/x/a!b"));
}
// Reload restarts the journal: edits discarded by the reload must not come
// back through crash recovery.
TEST(SwapJournal_Reload_ResetsJournal)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("reload");
const std::string file = (sb.root / "work" / "rl.txt").string();
write_file_bytes(file, "hello\nworld\n");
Editor ed;
ed.SetDimensions(24, 80);
std::string err;
ASSERT_TRUE(ed.OpenFile(file, err));
Buffer *cur = ed.CurrentBuffer();
cur->SetCursor(0, 0);
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "XXXX"));
// The buffer is dirty: the first reload only asks for confirmation.
ASSERT_TRUE(Execute(ed, CommandId::ReloadBuffer));
ASSERT_TRUE(ed.CurrentBuffer()->Dirty());
ASSERT_TRUE(Execute(ed, CommandId::ReloadBuffer));
cur = ed.CurrentBuffer();
cur->SetCursor(0, 1);
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "YY"));
ed.Swap()->Flush(cur);
const std::string live = buffer_bytes_via_views(*cur);
ASSERT_EQ(live, std::string("hello\nYYworld\n"));
Buffer check;
std::string rerr;
ASSERT_TRUE(check.OpenFromFile(file, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(check, kte::SwapManager::ComputeSwapPathForTests(*cur), rerr));
ASSERT_EQ(buffer_bytes_via_views(check), live);
}
// A journal whose base file changed on disk afterwards is not replayed onto
// the new content; the user is offered to delete it instead.
TEST(SwapRecoveryPrompt_StaleJournal_NotReplayed)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("stale");
const std::string file = (sb.root / "work" / "s.txt").string();
write_file_bytes(file, "base\n");
std::string expected;
(void) make_journal(file, [](Buffer &b) {
b.insert_text(0, 0, std::string("A"));
}, expected);
write_file_bytes(file, "completely different content\n"); // changed after the crash
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(file);
(void) ed.ProcessPendingOpens();
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::DeleteCorruptSwap);
answer(ed, "y");
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), std::string("completely different content\n"));
}
// A journal held by another live session is neither replayed, removed nor
// written to by a second session opening the same file.
TEST(SwapRecoveryPrompt_LiveJournalOfOtherSession_LeftAlone)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("live");
const std::string file = (sb.root / "work" / "l.txt").string();
write_file_bytes(file, "base\n");
// "Other process": a session with the journal open (and locked).
Buffer other;
std::string err;
ASSERT_TRUE(other.OpenFromFile(file, err));
kte::SwapManager other_sm;
other_sm.Attach(&other);
other.SetSwapRecorder(other_sm.RecorderFor(&other));
other.insert_text(0, 0, std::string("O"));
other_sm.Flush(&other);
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(other);
ASSERT_TRUE(kte::SwapManager::JournalInUse(swp));
const std::string before = read_file_bytes(swp);
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(file);
(void) ed.ProcessPendingOpens();
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), std::string("base\n"));
ed.CurrentBuffer()->insert_text(0, 0, std::string("X"));
ed.Swap()->Flush(ed.CurrentBuffer());
ASSERT_EQ(read_file_bytes(swp), before);
other.SetSwapRecorder(nullptr);
other_sm.Detach(&other, true);
}
// A session locked out of a journal (another session owns it) must not
// delete it when it closes or saves the buffer.
TEST(SwapJournal_SecondSession_DoesNotDeleteOwnersJournal)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("owner");
const std::string file = (sb.root / "work" / "g.txt").string();
write_file_bytes(file, "base\n");
Editor a;
a.SetDimensions(24, 80);
std::string err;
ASSERT_TRUE(a.OpenFile(file, err));
a.CurrentBuffer()->insert_text(0, 0, std::string("A"));
a.Swap()->Flush(a.CurrentBuffer());
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*a.CurrentBuffer());
ASSERT_TRUE(std::filesystem::exists(swp));
{
Editor b;
b.SetDimensions(24, 80);
b.AddBuffer(Buffer());
b.RequestOpenFile(file);
(void) b.ProcessPendingOpens();
b.CurrentBuffer()->insert_text(0, 0, std::string("B"));
b.Swap()->Flush(b.CurrentBuffer());
ASSERT_TRUE(b.CloseBuffer(b.CurrentBufferIndex()));
}
ASSERT_TRUE(std::filesystem::exists(swp));
// A's journal still replays to A's content.
Buffer check;
std::string rerr;
ASSERT_TRUE(check.OpenFromFile(file, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(check, swp, rerr));
ASSERT_EQ(buffer_bytes_via_views(check), std::string("Abase\n"));
}
// Enter alone (or any key other than y/n) at the recovery prompt cancels and
// keeps the journal: it used to count as "no" and delete it.
TEST(SwapRecoveryPrompt_EmptyAnswer_KeepsSwap)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("empty_answer");
const std::string file = (sb.root / "work" / "e.txt").string();
write_file_bytes(file, "base\n");
std::string expected;
const std::string swp = make_journal(file, [](Buffer &b) {
b.insert_text(0, 0, std::string("A"));
}, expected);
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(file);
(void) ed.ProcessPendingOpens();
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
answer(ed, "x");
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
ASSERT_TRUE(!ed.PromptActive());
ASSERT_TRUE(std::filesystem::exists(swp));
}
// A timestamp-only change (touch, git checkout back and forth) does not make
// a journal stale: its base content is unchanged.
TEST(SwapRecoveryPrompt_TouchedFile_StillRecoverable)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("touched");
const std::string file = (sb.root / "work" / "t2.txt").string();
write_file_bytes(file, "base\n");
std::string expected;
(void) make_journal(file, [](Buffer &b) {
b.insert_text(0, 0, std::string("A"));
}, expected);
std::filesystem::last_write_time(file, std::filesystem::last_write_time(file) + std::chrono::hours(1));
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(file);
(void) ed.ProcessPendingOpens();
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
answer(ed, "y");
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), expected);
}
// A recovered buffer stays modified even after an edit and its undo: its
// content differs from the file on disk in every undo state.
TEST(SwapRecoveryPrompt_Recovered_StaysDirtyAfterUndo)
{
ktet::InstallDefaultCommandsOnce();
XdgSandbox sb("recovered_dirty");
const std::string file = (sb.root / "work" / "rd.txt").string();
write_file_bytes(file, "base\n");
std::string expected;
(void) make_journal(file, [](Buffer &b) {
b.insert_text(0, 0, std::string("IMPORTANT "));
}, expected);
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(file);
(void) ed.ProcessPendingOpens();
answer(ed, "y");
ASSERT_TRUE(ed.CurrentBuffer()->Dirty());
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "a"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_TRUE(ed.CurrentBuffer()->Dirty());
}
+4 -118
View File
@@ -6,9 +6,6 @@
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <fstream> #include <fstream>
#include <chrono>
#include <filesystem>
#include <thread>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -32,7 +29,7 @@ read_file_bytes(const std::string &path)
static std::string static std::string
buffer_bytes_via_views(const Buffer &b) buffer_bytes_via_views(const Buffer &b)
{ {
const auto &rows = b.LinesForTests(); const auto &rows = b.Rows();
std::string out; std::string out;
for (std::size_t i = 0; i < rows.size(); i++) { for (std::size_t i = 0; i < rows.size(); i++) {
auto v = b.GetLineView(i); auto v = b.GetLineView(i);
@@ -106,10 +103,7 @@ TEST(SwapReplay_RecordFlushReopenReplay_ExactBytesMatch)
} }
// A crash mid-append leaves a torn final record. Replay keeps every complete TEST(SwapReplay_TruncatedLog_FailsSafely)
// record before it (that tail is exactly what the journal exists to save),
// succeeds, and reports the incomplete record through err.
TEST(SwapReplay_TruncatedLog_RecoversPrefix)
{ {
const std::string path = "./.kte_ut_swap_replay_2.txt"; const std::string path = "./.kte_ut_swap_replay_2.txt";
std::remove(path.c_str()); std::remove(path.c_str());
@@ -119,13 +113,10 @@ TEST(SwapReplay_TruncatedLog_RecoversPrefix)
std::string err; std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err)); ASSERT_TRUE(b.OpenFromFile(path, err));
// Start from a fresh journal even if an earlier failed run left one.
std::remove(kte::SwapManager::ComputeSwapPathForTests(b).c_str());
kte::SwapManager sm; kte::SwapManager sm;
sm.Attach(&b); sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b)); b.SetSwapRecorder(sm.RecorderFor(&b));
b.insert_text(0, 0, std::string("X")); b.insert_text(0, 0, std::string("X"));
b.insert_text(0, 1, std::string("Y"));
sm.Flush(&b); sm.Flush(&b);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b); const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
b.SetSwapRecorder(nullptr); b.SetSwapRecorder(nullptr);
@@ -140,11 +131,8 @@ TEST(SwapReplay_TruncatedLog_RecoversPrefix)
Buffer b2; Buffer b2;
ASSERT_TRUE(b2.OpenFromFile(path, err)); ASSERT_TRUE(b2.OpenFromFile(path, err));
std::string rerr; std::string rerr;
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, trunc_path, rerr)); ASSERT_EQ(kte::SwapManager::ReplayFile(b2, trunc_path, rerr), false);
ASSERT_TRUE(rerr.find("incomplete record") != std::string::npos); ASSERT_EQ(rerr.empty(), false);
// Detach appended a final checkpoint; that is the torn record, so both
// inserts before it are recovered.
ASSERT_EQ(b2.GetLineString(0), std::string("XYhello"));
std::remove(path.c_str()); std::remove(path.c_str());
std::remove(swap_path.c_str()); std::remove(swap_path.c_str());
@@ -237,105 +225,3 @@ TEST(SwapCompaction_RewritesToSingleCheckpoint)
std::remove(path.c_str()); std::remove(path.c_str());
std::remove(swap_path.c_str()); std::remove(swap_path.c_str());
} }
// Inserts larger than one record's 24-bit payload are split into several INS
// records; each must be positioned after the previous chunk, including when a
// chunk boundary falls mid-line.
TEST(SwapReplay_LargeInsert_SplitAcrossRecords)
{
const std::string path = "./.kte_ut_swap_replay_large.txt";
std::remove(path.c_str());
write_file_bytes(path, "tail\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
std::remove(kte::SwapManager::ComputeSwapPathForTests(b).c_str());
// ~17 MiB of 1000-byte lines, so the 16 MiB chunk boundary splits a line.
std::string big;
const std::string line = std::string(999, 'a') + "\n";
while (big.size() < (17u << 20))
big += line;
big += "mid";
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
b.insert_text(0, 0, big);
sm.Flush(&b);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
const std::string expected = buffer_bytes_via_views(b);
b.SetSwapRecorder(nullptr);
sm.Detach(&b, false);
Buffer b2;
ASSERT_TRUE(b2.OpenFromFile(path, err));
std::string rerr;
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, rerr));
ASSERT_TRUE(buffer_bytes_via_views(b2) == expected);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
// When a record cannot be written, later position-based records no longer
// apply to the journal's state. The journal must drop them until a full
// checkpoint re-establishes the content, so replay still matches the buffer.
TEST(SwapReplay_LostRecord_ResyncsWithCheckpoint)
{
const std::string path = "./.kte_ut_swap_replay_gap.txt";
std::remove(path.c_str());
write_file_bytes(path, "base\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
std::remove(swap_path.c_str());
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
b.insert_text(0, 0, std::string("A"));
sm.Flush(&b);
// Make the journal unopenable so the next record is lost.
sm.ResetJournal(b); // closes and removes the file; base is now the disk file
ASSERT_TRUE(b.SaveAs(path, err)); // disk = "Abase"
std::filesystem::create_directory(swap_path);
b.insert_text(0, 1, std::string("B"));
sm.Flush(&b);
std::filesystem::remove(swap_path);
b.insert_text(0, 2, std::string("C")); // dropped; triggers a resync checkpoint
sm.Flush(&b);
b.insert_text(0, 3, std::string("D"));
sm.Flush(&b);
// If the writer handled B before its edit asked for a checkpoint, that
// checkpoint failed too and the next attempt is rate-limited; the
// per-frame retry then brings the journal back in step without an edit.
std::this_thread::sleep_for(std::chrono::milliseconds(1100));
sm.RetryGapCheckpoints();
sm.Flush(&b);
const std::string expected = buffer_bytes_via_views(b);
ASSERT_EQ(expected, std::string("ABCDbase\n"));
// Replay the journal as it stands mid-session (as after a crash); Detach
// would append a final checkpoint that hides the problem.
const std::string crash_copy = swap_path + ".crash";
write_file_bytes(crash_copy, read_file_bytes(swap_path));
b.SetSwapRecorder(nullptr);
sm.Detach(&b, true);
Buffer b2;
ASSERT_TRUE(b2.OpenFromFile(path, err));
std::string rerr;
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, crash_copy, rerr));
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
std::remove(path.c_str());
std::remove(crash_copy.c_str());
}
+3 -5
View File
@@ -123,11 +123,9 @@ TEST(SwapWriter_Header_Records_And_CRC)
static const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'}; static const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
for (int i = 0; i < 8; ++i) for (int i = 0; i < 8; ++i)
ASSERT_EQ(bytes[(std::size_t) i], magic[i]); ASSERT_EQ(bytes[(std::size_t) i], magic[i]);
ASSERT_EQ(read_le32(bytes.data() + 8), (std::uint32_t) 2); // v2: chunked checkpoints ASSERT_EQ(read_le32(bytes.data() + 8), (std::uint32_t) 1);
// flags bit 0: the base file's identity (size, mtime) is recorded, since // flags currently 0
// the buffer is file-backed; bit 1: its content CRC-32 too. ASSERT_EQ(read_le32(bytes.data() + 12), (std::uint32_t) 0);
ASSERT_EQ(read_le32(bytes.data() + 12), (std::uint32_t) 3);
ASSERT_EQ(read_le64(bytes.data() + 24), (std::uint64_t) std::filesystem::file_size(b.Filename()));
ASSERT_TRUE(read_le64(bytes.data() + 16) != 0); ASSERT_TRUE(read_le64(bytes.data() + 16) != 0);
// Records // Records
-167
View File
@@ -1,167 +0,0 @@
// test_syntax_highlighting.cc - tokenization correctness for LanguageHighlighter
// implementations, focused on multi-line state propagation (block comments,
// triple-quoted strings) that stateless-looking per-line scans easily get wrong.
#include "Test.h"
#include "Buffer.h"
#include "Highlight.h"
#include "syntax/LanguageHighlighter.h"
#include "syntax/GoHighlighter.h"
#include "syntax/RustHighlighter.h"
#include "syntax/SqlHighlighter.h"
#include "syntax/PythonHighlighter.h"
#include <string>
#include <vector>
using kte::HighlightSpan;
using kte::StatefulHighlighter;
using kte::TokenKind;
namespace {
bool
has_kind(const std::vector<HighlightSpan> &spans, TokenKind k)
{
for (const auto &sp: spans) {
if (sp.kind == k)
return true;
}
return false;
}
} // namespace
TEST (Syntax_Go_MultiLineBlockComment_PropagatesAcrossLines)
{
Buffer b;
b.replace_all_bytes("/* start\nmiddle line\nend */\nfunc foo() {}\n");
kte::GoHighlighter hl;
StatefulHighlighter::LineState state;
std::vector<HighlightSpan> spans;
// Line 0 opens an unclosed block comment; state must carry forward.
state = hl.HighlightLineStateful(b, 0, state, spans);
ASSERT_TRUE(state.in_block_comment);
// Line 1 is entirely inside the comment; the whole line must be Comment,
// not re-tokenized as code (this is the bug: without state propagation
// "middle" and "line" would come back as identifiers).
spans.clear();
state = hl.HighlightLineStateful(b, 1, state, spans);
ASSERT_TRUE(state.in_block_comment);
ASSERT_TRUE(!spans.empty());
for (const auto &sp: spans) {
ASSERT_TRUE(sp.kind == TokenKind::Comment);
}
// Line 2 closes the comment.
spans.clear();
state = hl.HighlightLineStateful(b, 2, state, spans);
ASSERT_TRUE(!state.in_block_comment);
ASSERT_TRUE(has_kind(spans, TokenKind::Comment));
// Line 3 is ordinary code again: "func" must be a Keyword, not a Comment.
spans.clear();
state = hl.HighlightLineStateful(b, 3, state, spans);
ASSERT_TRUE(!state.in_block_comment);
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
}
TEST (Syntax_Rust_MultiLineBlockComment_PropagatesAcrossLines)
{
Buffer b;
b.replace_all_bytes("/* start\nmiddle line\nend */\nfn foo() {}\n");
kte::RustHighlighter hl;
StatefulHighlighter::LineState state;
std::vector<HighlightSpan> spans;
state = hl.HighlightLineStateful(b, 0, state, spans);
ASSERT_TRUE(state.in_block_comment);
spans.clear();
state = hl.HighlightLineStateful(b, 1, state, spans);
ASSERT_TRUE(state.in_block_comment);
for (const auto &sp: spans) {
ASSERT_TRUE(sp.kind == TokenKind::Comment);
}
spans.clear();
state = hl.HighlightLineStateful(b, 2, state, spans);
ASSERT_TRUE(!state.in_block_comment);
spans.clear();
state = hl.HighlightLineStateful(b, 3, state, spans);
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
}
TEST (Syntax_Sql_MultiLineBlockComment_PropagatesAcrossLines)
{
Buffer b;
b.replace_all_bytes("/* start\nmiddle line\nend */\nSELECT 1;\n");
kte::SqlHighlighter hl;
StatefulHighlighter::LineState state;
std::vector<HighlightSpan> spans;
state = hl.HighlightLineStateful(b, 0, state, spans);
ASSERT_TRUE(state.in_block_comment);
spans.clear();
state = hl.HighlightLineStateful(b, 1, state, spans);
ASSERT_TRUE(state.in_block_comment);
for (const auto &sp: spans) {
ASSERT_TRUE(sp.kind == TokenKind::Comment);
}
spans.clear();
state = hl.HighlightLineStateful(b, 2, state, spans);
ASSERT_TRUE(!state.in_block_comment);
spans.clear();
state = hl.HighlightLineStateful(b, 3, state, spans);
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
}
TEST (Syntax_Python_TripleQuote_ClosesAndReopensOnSameLine)
{
Buffer b;
// Line 0 opens a triple-quoted string that stays open.
// Line 1 closes the first string ("end'''") and, on the SAME line, opens a
// second triple-quoted string ("'''start of") that stays open into line 2.
b.replace_all_bytes("x = '''abc\nend''' + '''start of\nnext string'''\n");
kte::PythonHighlighter hl;
StatefulHighlighter::LineState state;
std::vector<HighlightSpan> spans;
state = hl.HighlightLineStateful(b, 0, state, spans);
ASSERT_TRUE(state.in_raw_string);
ASSERT_EQ(state.raw_delim, std::string("'''"));
spans.clear();
state = hl.HighlightLineStateful(b, 1, state, spans);
// The bug: without re-scanning the remainder after the closing ''' the
// second opening ''' on this line is never noticed, so state.in_raw_string
// would incorrectly come back false here.
ASSERT_TRUE(state.in_raw_string);
ASSERT_EQ(state.raw_delim, std::string("'''"));
// Line 2 is inside the second string; it must be highlighted as String,
// not as ordinary code.
spans.clear();
state = hl.HighlightLineStateful(b, 2, state, spans);
ASSERT_TRUE(!spans.empty());
bool all_string = true;
for (const auto &sp: spans) {
if (sp.kind != TokenKind::String)
all_string = false;
}
ASSERT_TRUE(all_string);
}
+155 -461
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -11,7 +11,7 @@ static std::string
dump_buf(const Buffer &buf) dump_buf(const Buffer &buf)
{ {
std::string out; std::string out;
for (const auto &r: buf.LinesForTests()) { for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r); out += static_cast<std::string>(r);
out.push_back('\n'); out.push_back('\n');
} }