Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
387d9d6dea | ||
|
|
b1576c4228 | ||
|
|
501245da3d | ||
|
|
2125583407 | ||
|
|
082f4facd3 | ||
|
|
72286ebb8b | ||
|
|
c9e8b7dbf2 | ||
|
|
fe4e2abb3a | ||
|
|
f4032dcf25 | ||
|
|
3be85ef4ad | ||
|
|
e1ebf5900b | ||
|
|
8f9a99899b | ||
|
|
2da35f57d1 | ||
|
|
c5df5035fa | ||
|
|
d490245f30 | ||
|
|
7039c14507 | ||
|
|
95bcf8ef09 | ||
|
|
927accbca4 | ||
|
|
323e2377fe | ||
|
|
29d0ee8244 | ||
|
|
7dae9cc305 | ||
|
|
306a337516 | ||
|
|
6bc3a6e0af | ||
|
|
2496722b43 | ||
|
|
41e28a8ab7 | ||
|
|
e782d24e32 | ||
|
|
a33c1759e7 | ||
|
|
fc15a58a2b | ||
|
|
404b983009 | ||
|
|
7165bb286b | ||
|
|
bba7d34539 | ||
|
|
b9dffd8645 | ||
|
|
790fbc90e1 | ||
|
|
b9f91b0ed2 | ||
|
|
78d78f6f54 | ||
|
|
868ee3fe2f | ||
|
|
8cb688c574 | ||
|
|
853416f65a | ||
|
|
1e312fcd0b | ||
|
|
b1b0b3de57 | ||
|
|
ca3a6e675b | ||
|
|
93901928da | ||
|
|
91ceb9bb01 | ||
|
|
a0441e4c25 | ||
|
|
13f1b7281f | ||
|
|
4dbefcc41d | ||
|
|
58706489bb | ||
|
|
61a7338f88 | ||
|
|
1005f5dbe8 | ||
|
|
6c2727789e | ||
|
|
fb50a02f6a | ||
|
|
e279945136 | ||
|
|
cb9b6bdf45 | ||
|
|
7e16680a26 | ||
|
|
eaa605f548 | ||
|
|
ec8159ea2e | ||
|
|
061e836fa4 | ||
|
|
9f3da72765 | ||
|
|
812cde090f | ||
|
|
0075c7aa86 | ||
|
|
7b8ccbb8db | ||
|
|
f853fa60b1 | ||
|
|
4c42761fc0 | ||
|
|
2a1c49a0f0 | ||
|
|
f861e42c2c | ||
|
|
a69664037d | ||
|
|
aa53d75f96 | ||
|
|
8cac2e9af8 | ||
|
|
fc9f83966c | ||
|
|
66259db177 | ||
|
|
ae5193eb5f | ||
|
|
00098fee16 | ||
|
|
1bdc04ce1a | ||
|
|
f36aa9f456 | ||
|
|
52089d3564 | ||
|
|
0f2cc41ed1 | ||
|
|
64139b4868 | ||
|
|
d8d2ae8a31 | ||
|
|
18ce005ec0 | ||
|
|
22f7573361 | ||
|
|
96e82cc6dd | ||
|
|
480f1390c7 | ||
|
|
3126a5e523 | ||
|
|
b60a8dc491 | ||
|
|
056c9af38e | ||
|
|
6413e14455 | ||
|
|
d1a45581bf | ||
|
|
81a5c25071 | ||
|
|
5667a6d7bd | ||
|
|
99c4bb2066 | ||
|
|
953fee97d7 | ||
|
|
d7e35727f1 |
+2
-58
@@ -1,58 +1,2 @@
|
|||||||
# Project Guidelines
|
Read AGENTS.md at the repository root before anything else. It is the only
|
||||||
|
project guide; this file exists because Junie cannot import it.
|
||||||
kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
|
|
||||||
It
|
|
||||||
replaces the earlier C implementation, ke (see the ke manual in
|
|
||||||
`docs/ke.md`). The
|
|
||||||
design draws inspiration from Antirez' kilo, with keybindings rooted in
|
|
||||||
the
|
|
||||||
WordStar/VDE family and emacs. The spiritual parent is `mg(1)`.
|
|
||||||
|
|
||||||
These guidelines summarize the goals, interfaces, key operations, and
|
|
||||||
current
|
|
||||||
development practices for kte.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Keep the core small, fast, and understandable.
|
|
||||||
- Provide an ncurses-based terminal-first editing experience, with an
|
|
||||||
additional ImGui GUI.
|
|
||||||
- Preserve familiar keybindings from ke while modernizing the internals.
|
|
||||||
- Favor simple data structures (e.g., piece table) and incremental
|
|
||||||
evolution.
|
|
||||||
|
|
||||||
Project entry point: `main.cpp`
|
|
||||||
|
|
||||||
## Core Components (current codebase)
|
|
||||||
|
|
||||||
- Buffer: editing model and file I/O (`Buffer.h/.cpp`).
|
|
||||||
- PieceTable: editable in-memory text representation (
|
|
||||||
`PieceTable.h/.cpp`).
|
|
||||||
- InputHandler: interface for handling text input (`InputHandler.h/`),
|
|
||||||
along
|
|
||||||
with `TerminalInputHandler` (ncurses-based) and `GUIInputHandler`.
|
|
||||||
- Renderer: interface for rendering text (`Renderer.h`), along with
|
|
||||||
`TerminalRenderer` (ncurses-based) and `GUIRenderer`.
|
|
||||||
- Editor: top-level editor state (`Editor.h/.cpp`).
|
|
||||||
- Command: command model (`Command.h/.cpp`).
|
|
||||||
- General purpose editor functionality (`Editing.h/.cpp`)
|
|
||||||
|
|
||||||
## Keybindings (inherited from ke)
|
|
||||||
|
|
||||||
The file `docs/ke.md` contains the canonical reference for keybindings.
|
|
||||||
|
|
||||||
## Contributing/Development Notes
|
|
||||||
|
|
||||||
- C++ standard: C++20.
|
|
||||||
- Keep dependencies minimal.
|
|
||||||
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
|
||||||
changing
|
|
||||||
behavior.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Previous editor manual: `ke.md` (canonical keybinding/spec reference
|
|
||||||
for now).
|
|
||||||
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# kte
|
||||||
|
|
||||||
|
kte (Kyle's Text Editor) is a C++20 text editor with a terminal-first design (ncurses) and an optional ImGui GUI frontend (SDL2/OpenGL/Freetype); it uses a WordStar/VDE-style command model. Standards: none. The Metacircular service standards do not apply here.
|
||||||
|
|
||||||
|
## Bootstrap (run once per checkout, idempotent)
|
||||||
|
nix develop (default shell; also provides a `terminal` devshell) or system SDL2/Freetype/ncurses if not using nix.
|
||||||
|
|
||||||
|
## Gate (run before saying "done"; must exit 0)
|
||||||
|
make gate # what it runs: configure (cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON -DBUILD_TESTS=ON) -> build (cmake --build build) -> test (./build/kte_tests)
|
||||||
|
Fast check while editing: cmake --build build --target kte_tests && ./build/kte_tests
|
||||||
|
Also run clang-tidy on changed files via build/compile_commands.json (not part of `make gate`; clang-tidy is not on this Mac's PATH).
|
||||||
|
|
||||||
|
## Run
|
||||||
|
Terminal editor: `./build/kte <file>` (always built).
|
||||||
|
GUI editor: `./build/kge <file>` (built when `-DBUILD_GUI=ON`; requires SDL2/OpenGL/Freetype).
|
||||||
|
Docker (cross-platform Linux testing): `docker build -t kte-linux . && docker run --rm -v "$(pwd):/kte" kte-linux`.
|
||||||
|
|
||||||
|
## Sensors the harness watches
|
||||||
|
- `make gate` exit status and last 40 lines; `kte_tests` runs via ctest too (`ctest --test-dir build`)
|
||||||
|
- clang-tidy warning count on the diff (when clang-tidy is available)
|
||||||
|
- for GUI bugs: launch `./build/kge` and report what was seen; a green test suite is not proof
|
||||||
|
|
||||||
|
## Rules (do not re-litigate)
|
||||||
|
- Release: bump CMakeLists version, checkpoint, `./make-release.sh`, report the SHA256 it prints; a GitHub push failure at the same commit is fine; then update `/opt/homebrew/Library/Taps/kisom/homebrew-tap`.
|
||||||
|
- All text mutations must go through the PieceTable API (`insert_text`, `delete_text`) so undo and swap recording work.
|
||||||
|
- Read lines with `Nrows()` and `GetLineString()` (a copy, cheap on any buffer) or `GetLineView()` (zero-copy when the line lies within one piece; a line straddling an edit materializes the whole buffer); `GetLineView()` and `ContentView()` are valid only until the next buffer modification.
|
||||||
|
- C++20, compiled with `-Wall -Wextra -Werror -pedantic`; Clang uses `-stdlib=libc++`.
|
||||||
|
- Naming: PascalCase for classes/methods, snake_case for variables, trailing underscore for private members (e.g. `pieces_`); indentation is tabs.
|
||||||
|
- Fallible ops use `bool func(args..., std::string &err)`: clear `err` at start, capture `errno` immediately after syscall failure, use EINTR-safe wrappers from `SyscallWrappers.h` instead of raw syscalls.
|
||||||
|
- After editing ops, call `ensure_cursor_visible()` to update the viewport.
|
||||||
|
|
||||||
|
## Docs that govern
|
||||||
|
- `docs/ke.md` — canonical keybinding/spec reference, inherited from the predecessor editor `ke`
|
||||||
|
- `REWRITE.md`, `ROADMAP.md`, `CONFIG.md` — design rationale, roadmap, and config reference
|
||||||
|
- `README.md` — project overview
|
||||||
|
|
||||||
|
## Remote target
|
||||||
|
None: kte builds and runs locally (or in the Docker/CI Linux image); it has no remote deployment target.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three-layer design with strict frontend independence:
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend Layer (Terminal / ImGui / Test)
|
||||||
|
InputHandler.h, Renderer.h, Frontend.h interfaces
|
||||||
|
↓
|
||||||
|
Command Layer
|
||||||
|
CommandId enum → CommandRegistry → handler functions in Command.cc
|
||||||
|
↓
|
||||||
|
Core Model Layer
|
||||||
|
Editor → Buffer → PieceTable
|
||||||
|
UndoSystem (tree-based, records at PieceTable level)
|
||||||
|
SwapManager (crash recovery journal per buffer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
- **PieceTable** (`PieceTable.h/.cc`) - Text storage. Lazy materialization; most ops work on the piece list directly. Line index and materialization caches must be invalidated on content changes.
|
||||||
|
- **Buffer** (`Buffer.h/.cc`) - Wraps PieceTable.
|
||||||
|
- **Editor** (`Editor.h/.cc`) - Top-level state container. Primarily getters/setters; editing logic lives in commands.
|
||||||
|
- **Command** (`Command.h/.cc`) - 120+ editing commands. This is the main place to add new editing operations. Register via `CommandRegistry::Register()` in `InstallDefaultCommands()`.
|
||||||
|
- **UndoSystem/UndoTree/UndoNode** - Tree-based undo with branching. Group related ops with `buf.Undo()->BeginGroup()` / `EndGroup()`.
|
||||||
|
- **Swap** (`Swap.h/.cc`) - Append-only crash recovery journal. Uses circuit breaker pattern for resilience. Files in `~/.local/state/kte/`.
|
||||||
|
- **Syntax highlighting** (`syntax/`) - Pluggable per-language highlighters registered in `HighlighterRegistry`. Per-line caching with buffer version tracking.
|
||||||
|
|
||||||
|
### Frontend Implementations
|
||||||
|
|
||||||
|
Each frontend implements three interfaces (`Frontend.h`, `InputHandler.h`, `Renderer.h`):
|
||||||
|
- **Terminal**: ncurses-based (always built)
|
||||||
|
- **ImGui**: SDL2+OpenGL+Freetype (built with `-DBUILD_GUI=ON`)
|
||||||
|
- **Test**: Programmatic frontend for testing (always built, no UI deps)
|
||||||
|
|
||||||
|
### Key CMake Options
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `BUILD_GUI` | ON | Build `kge` (ImGui GUI) |
|
||||||
|
| `BUILD_TESTS` | ON | Build test suite |
|
||||||
|
| `ENABLE_ASAN` | OFF | AddressSanitizer |
|
||||||
|
| `KTE_STATIC_LINK` | OFF | Static linking (Linux only) |
|
||||||
|
| `KTE_ENABLE_TREESITTER` | OFF | Tree-sitter syntax highlighting |
|
||||||
|
| `KTE_USE_PCRE2` | ON | Regex search/replace via PCRE2 when `libpcre2-8` is found (std::regex otherwise; see `RegexEngine.h`) |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live in `tests/test_*.cc`, run by the single `kte_tests` binary (also registered with ctest as `add_test(NAME kte_tests COMMAND kte_tests)`); `./build/kte_tests Swap_ Undo_` runs only the tests whose names contain one of the arguments. Tests use a minimal custom framework in `tests/Test.h` with `TEST()`, `ASSERT_EQ()`, `ASSERT_TRUE()`, `EXPECT_TRUE()` macros. Use `TestFrontend`/`TestInputHandler`/`TestRenderer` for integration tests that exercise the full Editor+Buffer+Command stack without UI dependencies.
|
||||||
|
|
||||||
|
Key test files by area:
|
||||||
|
- PieceTable: `test_piece_table.cc`
|
||||||
|
- Buffer I/O: `test_buffer_io.cc`
|
||||||
|
- Commands: `test_command_semantics.cc`
|
||||||
|
- Search/replace: `test_search_replace_flow.cc`
|
||||||
|
- Undo: `test_undo.cc`
|
||||||
|
- Swap (crash recovery): `test_swap_*.cc` (7 files)
|
||||||
|
- Reflow: `test_reflow_paragraph.cc`, `test_reflow_indented_bullets.cc`
|
||||||
|
- Integration: `test_daily_workflows.cc`
|
||||||
|
|
||||||
|
## Important Caveats
|
||||||
|
|
||||||
|
- `GetLineView()` and `ContentView()` return a `string_view` valid only until the next buffer modification; views of different lines need not be contiguous.
|
||||||
|
- Buffers, PieceTable and HighlighterEngine are used from the main thread only (the swap writer thread receives copies of the bytes).
|
||||||
|
- All source files are in the project root (no `src/` directory); tests are in `tests/`; syntax highlighters in `syntax/`; themes in `themes/`; embedded fonts in `fonts/`.
|
||||||
|
- External deps: `ext/imgui/` (Dear ImGui), `ext/tomlplusplus/` (TOML parser).
|
||||||
|
- GUI config: `~/.config/kte/kge.toml` (TOML preferred over legacy INI).
|
||||||
|
- ErrorHandler: centralized logging to `~/.local/state/kte/error.log` with severity levels (Info/Warning/Error/Critical).
|
||||||
@@ -99,12 +99,18 @@ Buffer::RefreshOnDiskIdentity()
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The bytes to save, as views of the piece table's storage in order (saving
|
||||||
|
// never makes a contiguous copy of the buffer).
|
||||||
|
using ByteChunks = std::vector<std::string_view>;
|
||||||
|
|
||||||
|
|
||||||
static bool
|
static bool
|
||||||
write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
|
write_all_fd(int fd, const ByteChunks &chunks, std::string &err)
|
||||||
{
|
{
|
||||||
|
for (const std::string_view chunk: chunks) {
|
||||||
std::size_t off = 0;
|
std::size_t off = 0;
|
||||||
while (off < len) {
|
while (off < chunk.size()) {
|
||||||
ssize_t n = ::write(fd, data + off, len - off);
|
ssize_t n = ::write(fd, chunk.data() + off, chunk.size() - off);
|
||||||
if (n < 0) {
|
if (n < 0) {
|
||||||
if (errno == EINTR)
|
if (errno == EINTR)
|
||||||
continue;
|
continue;
|
||||||
@@ -113,6 +119,7 @@ write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
|
|||||||
}
|
}
|
||||||
off += static_cast<std::size_t>(n);
|
off += static_cast<std::size_t>(n);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +143,133 @@ 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
|
||||||
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err)
|
write_in_place(const std::string &path, const ByteChunks &data, std::string &err)
|
||||||
{
|
{
|
||||||
|
int flags = O_WRONLY | O_TRUNC;
|
||||||
|
#ifdef O_CLOEXEC
|
||||||
|
flags |= O_CLOEXEC;
|
||||||
|
#endif
|
||||||
|
const int fd = kte::syscall::Open(path.c_str(), flags);
|
||||||
|
if (fd < 0) {
|
||||||
|
err = std::string("Failed to open file for writing: ") + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
struct stat before{};
|
||||||
|
const bool have_mode = ::fstat(fd, &before) == 0;
|
||||||
|
bool ok = write_all_fd(fd, data, err);
|
||||||
|
// Without privilege, writing clears setuid/setgid; put them back.
|
||||||
|
if (ok && have_mode)
|
||||||
|
(void) kte::syscall::Fchmod(fd, before.st_mode & 07777);
|
||||||
|
if (ok && kte::syscall::Fsync(fd) != 0) {
|
||||||
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Create a new file named from `tmpl` (a mkstemp template, which receives
|
||||||
|
// the chosen name) holding the content, fsynced.
|
||||||
|
static bool
|
||||||
|
write_new_file(std::string &tmpl, const ByteChunks &data, std::string &err, int &create_errno)
|
||||||
|
{
|
||||||
|
create_errno = 0;
|
||||||
|
std::vector<char> name(tmpl.begin(), tmpl.end());
|
||||||
|
name.push_back('\0');
|
||||||
|
const int fd = kte::syscall::Mkstemp(name.data());
|
||||||
|
if (fd < 0) {
|
||||||
|
create_errno = errno;
|
||||||
|
err = "Failed to create " + tmpl + ": " + std::strerror(create_errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tmpl.assign(name.data());
|
||||||
|
const std::string &path = tmpl;
|
||||||
|
bool ok = write_all_fd(fd, data, err);
|
||||||
|
if (ok && kte::syscall::Fsync(fd) != 0) {
|
||||||
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
if (!ok)
|
||||||
|
(void) ::unlink(path.c_str());
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whether a failure to create a file beside the target means only that the
|
||||||
|
// directory will not take a new name (not writable, name too long), so the
|
||||||
|
// target itself may still be rewritten in place. Anything else (ENOSPC,
|
||||||
|
// EDQUOT, EIO, ...) would most likely hit the in-place write too, after it
|
||||||
|
// had already truncated the file.
|
||||||
|
static bool
|
||||||
|
may_write_in_place_after(int create_errno)
|
||||||
|
{
|
||||||
|
return create_errno == EACCES || create_errno == EPERM || create_errno == ENAMETOOLONG;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
atomic_write_file(const std::string &path_in, const ByteChunks &data, std::string &err)
|
||||||
|
{
|
||||||
|
// Write through a symlink to its target: renaming over the link itself
|
||||||
|
// would replace the link with a regular file.
|
||||||
|
std::string path = path_in;
|
||||||
|
try {
|
||||||
|
if (std::filesystem::is_symlink(path_in))
|
||||||
|
path = std::filesystem::weakly_canonical(path_in).string();
|
||||||
|
} catch (...) {
|
||||||
|
// Fall back to the given path.
|
||||||
|
}
|
||||||
|
|
||||||
|
struct stat dst_st{};
|
||||||
|
const bool dst_exists = ::stat(path.c_str(), &dst_st) == 0;
|
||||||
|
// Never replace a directory, device node, FIFO or socket with a file.
|
||||||
|
if (dst_exists && !S_ISREG(dst_st.st_mode)) {
|
||||||
|
err = "Refusing to overwrite a non-regular file: " + path;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file with other hard links must be rewritten in place, or those
|
||||||
|
// names would keep the old content.
|
||||||
|
if (dst_exists && S_ISREG(dst_st.st_mode) && dst_st.st_nlink > 1) {
|
||||||
|
// Truncating in place risks the only copy if the write then fails
|
||||||
|
// (ENOSPC, EIO, crash), so first write and sync a copy beside it. If
|
||||||
|
// the directory will not take a new file (not writable, name too
|
||||||
|
// long), write in place anyway rather than refuse to save; any
|
||||||
|
// other failure (out of space, I/O error) refuses the save.
|
||||||
|
std::string copy = path + ".kte-save.XXXXXX";
|
||||||
|
std::string copy_err;
|
||||||
|
int copy_errno = 0;
|
||||||
|
const bool have_copy = write_new_file(copy, data, copy_err, copy_errno);
|
||||||
|
if (!have_copy && !may_write_in_place_after(copy_errno)) {
|
||||||
|
err = "Save failed, file left unchanged: " + copy_err;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!write_in_place(path, data, err)) {
|
||||||
|
if (have_copy)
|
||||||
|
err += " (the new content is in " + copy + ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (have_copy)
|
||||||
|
(void) ::unlink(copy.c_str());
|
||||||
|
best_effort_fsync_dir(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Create a temp file in the same directory so rename() is atomic.
|
// Create a temp file in the same directory so rename() is atomic.
|
||||||
std::filesystem::path p(path);
|
std::filesystem::path p(path);
|
||||||
std::filesystem::path dir = p.parent_path();
|
std::filesystem::path dir = p.parent_path();
|
||||||
@@ -161,32 +292,41 @@ atomic_write_file(const std::string &path, const char *data, std::size_t len, st
|
|||||||
};
|
};
|
||||||
|
|
||||||
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(errno) + err;
|
err = std::string("Failed to create temp file for save: ") + std::strerror(saved_errno) + err;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::string tmp_path(buf.data());
|
std::string tmp_path(buf.data());
|
||||||
|
|
||||||
// If the destination exists, carry over its permissions.
|
bool ok = write_all_fd(fd, data, err);
|
||||||
struct stat dst_st{};
|
// Ownership and mode go on after the data: without privilege, both
|
||||||
if (::stat(path.c_str(), &dst_st) == 0) {
|
// chown and write clear setuid/setgid, so the mode is applied last.
|
||||||
(void) kte::syscall::Fchmod(fd, dst_st.st_mode);
|
if (ok && dst_exists) {
|
||||||
|
if (::fchown(fd, dst_st.st_uid, dst_st.st_gid) != 0) {
|
||||||
|
// Expected without privilege when the owner differs; keep ours.
|
||||||
}
|
}
|
||||||
|
(void) kte::syscall::Fchmod(fd, dst_st.st_mode & 07777);
|
||||||
bool ok = write_all_fd(fd, data, len, err);
|
} else if (ok) {
|
||||||
if (ok) {
|
// mkstemp creates 0600; a new file gets the usual 0666 & ~umask.
|
||||||
// Retry fsync on transient errors
|
(void) kte::syscall::Fchmod(fd, 0666 & ~g_process_umask);
|
||||||
auto fsync_fn = [&]() -> bool {
|
}
|
||||||
return kte::syscall::Fsync(fd) == 0;
|
// Never retry fsync: after a writeback error Linux may mark the pages
|
||||||
};
|
// clean, so a second fsync can succeed although the data was lost, and
|
||||||
|
// the rename below would then replace a good file with a bad one.
|
||||||
std::string fsync_err;
|
if (ok && kte::syscall::Fsync(fd) != 0) {
|
||||||
if (!kte::RetryOnTransientError(fsync_fn, kte::RetryPolicy::Aggressive(), fsync_err)) {
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
||||||
err = std::string("fsync failed: ") + std::strerror(errno) + fsync_err;
|
|
||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
(void) kte::syscall::Close(fd);
|
(void) kte::syscall::Close(fd);
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -217,46 +357,31 @@ Buffer::Buffer(const Buffer &other)
|
|||||||
{
|
{
|
||||||
curx_ = other.curx_;
|
curx_ = other.curx_;
|
||||||
cury_ = other.cury_;
|
cury_ = other.cury_;
|
||||||
rx_ = other.rx_;
|
|
||||||
nrows_ = other.nrows_;
|
|
||||||
rowoffs_ = other.rowoffs_;
|
rowoffs_ = other.rowoffs_;
|
||||||
coloffs_ = other.coloffs_;
|
coloffs_ = other.coloffs_;
|
||||||
rows_ = other.rows_;
|
|
||||||
content_ = other.content_;
|
content_ = other.content_;
|
||||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
|
||||||
filename_ = other.filename_;
|
filename_ = other.filename_;
|
||||||
is_file_backed_ = other.is_file_backed_;
|
is_file_backed_ = other.is_file_backed_;
|
||||||
|
is_virtual_ = other.is_virtual_;
|
||||||
dirty_ = other.dirty_;
|
dirty_ = other.dirty_;
|
||||||
read_only_ = other.read_only_;
|
read_only_ = other.read_only_;
|
||||||
mark_set_ = other.mark_set_;
|
mark_set_ = other.mark_set_;
|
||||||
mark_curx_ = other.mark_curx_;
|
mark_curx_ = other.mark_curx_;
|
||||||
mark_cury_ = other.mark_cury_;
|
mark_cury_ = other.mark_cury_;
|
||||||
// Copy syntax/highlighting flags
|
// Copy edit mode + syntax/highlighting flags
|
||||||
|
edit_mode_ = other.edit_mode_;
|
||||||
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
version_ = other.version_;
|
version_ = other.version_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
|
syntax_user_override_ = other.syntax_user_override_;
|
||||||
filetype_ = other.filetype_;
|
filetype_ = other.filetype_;
|
||||||
// Fresh undo system for the copy
|
// Fresh undo system for the copy
|
||||||
undo_tree_ = std::make_unique<UndoTree>();
|
undo_tree_ = std::make_unique<UndoTree>();
|
||||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||||
|
|
||||||
// Recreate a highlighter engine for this copy based on filetype/syntax state
|
// Recreate a highlighter engine for this copy based on filetype/syntax state
|
||||||
if (syntax_enabled_) {
|
if (syntax_enabled_)
|
||||||
// Allocate engine and install an appropriate highlighter
|
InstallFiletypeHighlighter();
|
||||||
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
|
||||||
if (!filetype_.empty()) {
|
|
||||||
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
|
|
||||||
if (hl) {
|
|
||||||
highlighter_->SetHighlighter(std::move(hl));
|
|
||||||
} else {
|
|
||||||
// Unsupported filetype -> NullHighlighter keeps syntax pipeline active
|
|
||||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No filetype -> keep syntax enabled but use NullHighlighter
|
|
||||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
// Fresh engine has empty caches; nothing to invalidate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -267,22 +392,23 @@ Buffer::operator=(const Buffer &other)
|
|||||||
return *this;
|
return *this;
|
||||||
curx_ = other.curx_;
|
curx_ = other.curx_;
|
||||||
cury_ = other.cury_;
|
cury_ = other.cury_;
|
||||||
rx_ = other.rx_;
|
|
||||||
nrows_ = other.nrows_;
|
|
||||||
rowoffs_ = other.rowoffs_;
|
rowoffs_ = other.rowoffs_;
|
||||||
coloffs_ = other.coloffs_;
|
coloffs_ = other.coloffs_;
|
||||||
rows_ = other.rows_;
|
|
||||||
content_ = other.content_;
|
content_ = other.content_;
|
||||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
|
||||||
filename_ = other.filename_;
|
filename_ = other.filename_;
|
||||||
is_file_backed_ = other.is_file_backed_;
|
is_file_backed_ = other.is_file_backed_;
|
||||||
|
is_virtual_ = other.is_virtual_;
|
||||||
|
id_ = NextBufferId(); // a copy is a different document
|
||||||
dirty_ = other.dirty_;
|
dirty_ = other.dirty_;
|
||||||
read_only_ = other.read_only_;
|
read_only_ = other.read_only_;
|
||||||
mark_set_ = other.mark_set_;
|
mark_set_ = other.mark_set_;
|
||||||
mark_curx_ = other.mark_curx_;
|
mark_curx_ = other.mark_curx_;
|
||||||
mark_cury_ = other.mark_cury_;
|
mark_cury_ = other.mark_cury_;
|
||||||
|
edit_mode_ = other.edit_mode_;
|
||||||
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
version_ = other.version_;
|
version_ = other.version_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
|
syntax_user_override_ = other.syntax_user_override_;
|
||||||
filetype_ = other.filetype_;
|
filetype_ = other.filetype_;
|
||||||
// Recreate undo system for this instance
|
// Recreate undo system for this instance
|
||||||
undo_tree_ = std::make_unique<UndoTree>();
|
undo_tree_ = std::make_unique<UndoTree>();
|
||||||
@@ -290,19 +416,8 @@ Buffer::operator=(const Buffer &other)
|
|||||||
|
|
||||||
// Recreate highlighter engine consistent with syntax settings
|
// Recreate highlighter engine consistent with syntax settings
|
||||||
highlighter_.reset();
|
highlighter_.reset();
|
||||||
if (syntax_enabled_) {
|
if (syntax_enabled_)
|
||||||
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
InstallFiletypeHighlighter();
|
||||||
if (!filetype_.empty()) {
|
|
||||||
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
|
|
||||||
if (hl) {
|
|
||||||
highlighter_->SetHighlighter(std::move(hl));
|
|
||||||
} else {
|
|
||||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,28 +426,38 @@ Buffer::operator=(const Buffer &other)
|
|||||||
Buffer::Buffer(Buffer &&other) noexcept
|
Buffer::Buffer(Buffer &&other) noexcept
|
||||||
: curx_(other.curx_),
|
: curx_(other.curx_),
|
||||||
cury_(other.cury_),
|
cury_(other.cury_),
|
||||||
rx_(other.rx_),
|
|
||||||
nrows_(other.nrows_),
|
|
||||||
rowoffs_(other.rowoffs_),
|
rowoffs_(other.rowoffs_),
|
||||||
coloffs_(other.coloffs_),
|
coloffs_(other.coloffs_),
|
||||||
rows_(std::move(other.rows_)),
|
|
||||||
filename_(std::move(other.filename_)),
|
filename_(std::move(other.filename_)),
|
||||||
is_file_backed_(other.is_file_backed_),
|
is_file_backed_(other.is_file_backed_),
|
||||||
|
is_virtual_(other.is_virtual_),
|
||||||
|
id_(other.id_),
|
||||||
dirty_(other.dirty_),
|
dirty_(other.dirty_),
|
||||||
read_only_(other.read_only_),
|
read_only_(other.read_only_),
|
||||||
mark_set_(other.mark_set_),
|
mark_set_(other.mark_set_),
|
||||||
mark_curx_(other.mark_curx_),
|
mark_curx_(other.mark_curx_),
|
||||||
mark_cury_(other.mark_cury_),
|
mark_cury_(other.mark_cury_),
|
||||||
|
visual_line_active_(other.visual_line_active_),
|
||||||
|
visual_line_anchor_y_(other.visual_line_anchor_y_),
|
||||||
|
visual_line_active_y_(other.visual_line_active_y_),
|
||||||
undo_tree_(std::move(other.undo_tree_)),
|
undo_tree_(std::move(other.undo_tree_)),
|
||||||
undo_sys_(std::move(other.undo_sys_))
|
undo_sys_(std::move(other.undo_sys_))
|
||||||
{
|
{
|
||||||
// Move syntax/highlighting state
|
// Move edit mode + syntax/highlighting state
|
||||||
|
edit_mode_ = other.edit_mode_;
|
||||||
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
version_ = other.version_;
|
version_ = other.version_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
|
syntax_user_override_ = other.syntax_user_override_;
|
||||||
filetype_ = std::move(other.filetype_);
|
filetype_ = std::move(other.filetype_);
|
||||||
highlighter_ = std::move(other.highlighter_);
|
highlighter_ = std::move(other.highlighter_);
|
||||||
content_ = std::move(other.content_);
|
content_ = std::move(other.content_);
|
||||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
on_disk_identity_ = other.on_disk_identity_;
|
||||||
|
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
||||||
|
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
||||||
|
// so the journal's Buffer* key follows this object to its new address.
|
||||||
|
swap_rec_ = other.swap_rec_;
|
||||||
|
other.swap_rec_ = nullptr;
|
||||||
// Update UndoSystem's buffer reference to point to this object
|
// Update UndoSystem's buffer reference to point to this object
|
||||||
if (undo_sys_) {
|
if (undo_sys_) {
|
||||||
undo_sys_->UpdateBufferReference(*this);
|
undo_sys_->UpdateBufferReference(*this);
|
||||||
@@ -349,28 +474,38 @@ Buffer::operator=(Buffer &&other) noexcept
|
|||||||
|
|
||||||
curx_ = other.curx_;
|
curx_ = other.curx_;
|
||||||
cury_ = other.cury_;
|
cury_ = other.cury_;
|
||||||
rx_ = other.rx_;
|
|
||||||
nrows_ = other.nrows_;
|
|
||||||
rowoffs_ = other.rowoffs_;
|
rowoffs_ = other.rowoffs_;
|
||||||
coloffs_ = other.coloffs_;
|
coloffs_ = other.coloffs_;
|
||||||
rows_ = std::move(other.rows_);
|
|
||||||
filename_ = std::move(other.filename_);
|
filename_ = std::move(other.filename_);
|
||||||
is_file_backed_ = other.is_file_backed_;
|
is_file_backed_ = other.is_file_backed_;
|
||||||
|
is_virtual_ = other.is_virtual_;
|
||||||
|
id_ = other.id_;
|
||||||
dirty_ = other.dirty_;
|
dirty_ = other.dirty_;
|
||||||
read_only_ = other.read_only_;
|
read_only_ = other.read_only_;
|
||||||
mark_set_ = other.mark_set_;
|
mark_set_ = other.mark_set_;
|
||||||
mark_curx_ = other.mark_curx_;
|
mark_curx_ = other.mark_curx_;
|
||||||
mark_cury_ = other.mark_cury_;
|
mark_cury_ = other.mark_cury_;
|
||||||
|
visual_line_active_ = other.visual_line_active_;
|
||||||
|
visual_line_anchor_y_ = other.visual_line_anchor_y_;
|
||||||
|
visual_line_active_y_ = other.visual_line_active_y_;
|
||||||
undo_tree_ = std::move(other.undo_tree_);
|
undo_tree_ = std::move(other.undo_tree_);
|
||||||
undo_sys_ = std::move(other.undo_sys_);
|
undo_sys_ = std::move(other.undo_sys_);
|
||||||
|
|
||||||
// Move syntax/highlighting state
|
// Move edit mode + syntax/highlighting state
|
||||||
|
edit_mode_ = other.edit_mode_;
|
||||||
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
version_ = other.version_;
|
version_ = other.version_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
|
syntax_user_override_ = other.syntax_user_override_;
|
||||||
filetype_ = std::move(other.filetype_);
|
filetype_ = std::move(other.filetype_);
|
||||||
highlighter_ = std::move(other.highlighter_);
|
highlighter_ = std::move(other.highlighter_);
|
||||||
content_ = std::move(other.content_);
|
content_ = std::move(other.content_);
|
||||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
on_disk_identity_ = other.on_disk_identity_;
|
||||||
|
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
||||||
|
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
||||||
|
// so the journal's Buffer* key follows this object to its new address.
|
||||||
|
swap_rec_ = other.swap_rec_;
|
||||||
|
other.swap_rec_ = nullptr;
|
||||||
// Update UndoSystem's buffer reference to point to this object
|
// Update UndoSystem's buffer reference to point to this object
|
||||||
if (undo_sys_) {
|
if (undo_sys_) {
|
||||||
undo_sys_->UpdateBufferReference(*this);
|
undo_sys_->UpdateBufferReference(*this);
|
||||||
@@ -409,77 +544,95 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|||||||
const std::string norm = normalize_path(path);
|
const std::string norm = normalize_path(path);
|
||||||
// If the file doesn't exist, initialize an empty, non-file-backed buffer
|
// If the file doesn't exist, initialize an empty, non-file-backed buffer
|
||||||
// with the provided filename. Do not touch the filesystem until Save/SaveAs.
|
// with the provided filename. Do not touch the filesystem until Save/SaveAs.
|
||||||
if (!std::filesystem::exists(norm)) {
|
std::error_code exists_ec;
|
||||||
rows_.clear();
|
const bool exists = std::filesystem::exists(norm, exists_ec);
|
||||||
nrows_ = 0;
|
if (exists_ec) {
|
||||||
|
// e.g. name too long, or a parent directory we may not search
|
||||||
|
err = "Cannot open " + norm + ": " + exists_ec.message();
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!exists) {
|
||||||
filename_ = norm;
|
filename_ = norm;
|
||||||
is_file_backed_ = false;
|
is_file_backed_ = false;
|
||||||
|
is_virtual_ = false;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
|
||||||
// Reset cursor/viewport state
|
// Reset cursor/viewport state
|
||||||
curx_ = cury_ = rx_ = 0;
|
curx_ = cury_ = 0;
|
||||||
rowoffs_ = coloffs_ = 0;
|
rowoffs_ = coloffs_ = 0;
|
||||||
mark_set_ = false;
|
mark_set_ = false;
|
||||||
mark_curx_ = mark_cury_ = 0;
|
mark_curx_ = mark_cury_ = 0;
|
||||||
|
|
||||||
// Empty PieceTable
|
// Empty PieceTable
|
||||||
content_.Clear();
|
content_.Clear();
|
||||||
rows_cache_dirty_ = true;
|
// History recorded against the previous content no longer applies.
|
||||||
|
if (undo_sys_)
|
||||||
|
undo_sys_->clear();
|
||||||
|
MarkContentChanged();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::ifstream in(norm, std::ios::in | std::ios::binary);
|
// Open non-blocking so a named pipe cannot hang the editor, then accept
|
||||||
if (!in) {
|
// regular files only: a directory reads as garbage (or throws), a FIFO
|
||||||
err = "Failed to open file: " + norm;
|
// blocks, and saving over a device node would replace it with a file.
|
||||||
|
int oflags = O_RDONLY | O_NONBLOCK;
|
||||||
|
#ifdef O_CLOEXEC
|
||||||
|
oflags |= O_CLOEXEC;
|
||||||
|
#endif
|
||||||
|
const int fd = kte::syscall::Open(norm.c_str(), oflags);
|
||||||
|
if (fd < 0) {
|
||||||
|
err = "Failed to open file: " + norm + ": " + std::strerror(errno);
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
struct stat st{};
|
||||||
|
if (kte::syscall::Fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
|
||||||
|
err = S_ISDIR(st.st_mode) ? ("Is a directory: " + norm) : ("Not a regular file: " + norm);
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read entire file into PieceTable as-is
|
// Read to EOF in chunks (the file may change size while we read).
|
||||||
std::string data;
|
std::string data;
|
||||||
in.seekg(0, std::ios::end);
|
try {
|
||||||
if (!in) {
|
data.reserve(static_cast<std::size_t>(st.st_size));
|
||||||
err = "Failed to seek to end of file: " + norm;
|
char chunk[1 << 16];
|
||||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
for (;;) {
|
||||||
return false;
|
const ssize_t n = ::read(fd, chunk, sizeof(chunk));
|
||||||
}
|
if (n > 0) {
|
||||||
auto sz = in.tellg();
|
data.append(chunk, static_cast<std::size_t>(n));
|
||||||
if (sz < 0) {
|
} else if (n == 0) {
|
||||||
err = "Failed to get file size: " + norm;
|
break;
|
||||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
} else if (errno != EINTR) {
|
||||||
return false;
|
err = "Failed to read file: " + norm + ": " + std::strerror(errno);
|
||||||
}
|
(void) kte::syscall::Close(fd);
|
||||||
if (sz > 0) {
|
|
||||||
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);
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content_.Clear();
|
} catch (const std::bad_alloc &) {
|
||||||
if (!data.empty())
|
(void) kte::syscall::Close(fd);
|
||||||
content_.Append(data.data(), data.size());
|
err = "File too large to load: " + norm;
|
||||||
rows_cache_dirty_ = true;
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
nrows_ = 0; // not used under PieceTable
|
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;
|
||||||
|
}
|
||||||
filename_ = norm;
|
filename_ = norm;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
|
is_virtual_ = false;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
RefreshOnDiskIdentity();
|
RefreshOnDiskIdentity();
|
||||||
|
|
||||||
@@ -490,9 +643,10 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|||||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||||
// Clear any existing history for a fresh load
|
// Clear any existing history for a fresh load
|
||||||
undo_sys_->clear();
|
undo_sys_->clear();
|
||||||
|
MarkContentChanged();
|
||||||
|
|
||||||
// Reset cursor/viewport state
|
// Reset cursor/viewport state
|
||||||
curx_ = cury_ = rx_ = 0;
|
curx_ = cury_ = 0;
|
||||||
rowoffs_ = coloffs_ = 0;
|
rowoffs_ = coloffs_ = 0;
|
||||||
mark_set_ = false;
|
mark_set_ = false;
|
||||||
mark_curx_ = mark_cury_ = 0;
|
mark_curx_ = mark_cury_ = 0;
|
||||||
@@ -508,13 +662,11 @@ Buffer::Save(std::string &err) const
|
|||||||
err = "Buffer is not file-backed; use SaveAs()";
|
err = "Buffer is not file-backed; use SaveAs()";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const std::size_t sz = content_.Size();
|
ByteChunks chunks;
|
||||||
const char *data = sz ? content_.Data() : nullptr;
|
content_.ForEachChunk([&](const char *d, std::size_t n) {
|
||||||
if (sz && !data) {
|
chunks.emplace_back(d, n);
|
||||||
err = "Internal error: buffer materialization failed";
|
});
|
||||||
return false;
|
if (!atomic_write_file(filename_, chunks, err)) {
|
||||||
}
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -548,19 +700,18 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
|||||||
out_path = path;
|
out_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::size_t sz = content_.Size();
|
ByteChunks chunks;
|
||||||
const char *data = sz ? content_.Data() : nullptr;
|
content_.ForEachChunk([&](const char *d, std::size_t n) {
|
||||||
if (sz && !data) {
|
chunks.emplace_back(d, n);
|
||||||
err = "Internal error: buffer materialization failed";
|
});
|
||||||
return false;
|
if (!atomic_write_file(out_path, chunks, err)) {
|
||||||
}
|
|
||||||
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;
|
||||||
@@ -592,43 +743,54 @@ Buffer::insert_text(int row, int col, std::string_view text)
|
|||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(col));
|
||||||
if (!text.empty()) {
|
if (!text.empty()) {
|
||||||
content_.Insert(off, text.data(), text.size());
|
content_.Insert(off, text.data(), text.size());
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_)
|
if (swap_rec_)
|
||||||
swap_rec_->OnInsert(row, col, text);
|
swap_rec_->OnInsert(row, col, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== Adapter helpers for PieceTable-backed Buffer =====
|
void
|
||||||
std::string_view
|
Buffer::ApplyDetectedFiletype()
|
||||||
Buffer::GetLineView(std::size_t row) const
|
|
||||||
{
|
{
|
||||||
// Get byte range for the logical line and return a view into materialized data
|
EnsureHighlighter();
|
||||||
auto range = content_.GetLineRange(row); // [start,end) in bytes
|
const std::string first = Nrows() > 0 ? GetLineString(0) : std::string();
|
||||||
const char *base = content_.Data(); // materializes if needed
|
const std::string ft = kte::HighlighterRegistry::DetectForPath(filename_, first);
|
||||||
if (!base)
|
SetFiletype(ft);
|
||||||
return std::string_view();
|
SetSyntaxEnabled(true);
|
||||||
const std::size_t start = range.first;
|
InstallFiletypeHighlighter();
|
||||||
const std::size_t len = (range.second > range.first) ? (range.second - range.first) : 0;
|
|
||||||
return std::string_view(base + start, len);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
Buffer::ensure_rows_cache() const
|
Buffer::InstallFiletypeHighlighter()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(buffer_mutex_);
|
EnsureHighlighter();
|
||||||
if (!rows_cache_dirty_)
|
auto hl = filetype_.empty() ? nullptr : kte::HighlighterRegistry::CreateFor(filetype_);
|
||||||
return;
|
if (!hl)
|
||||||
rows_.clear();
|
hl = std::make_unique<kte::NullHighlighter>();
|
||||||
const std::size_t lc = content_.LineCount();
|
highlighter_->SetHighlighter(std::move(hl));
|
||||||
rows_.reserve(lc);
|
|
||||||
for (std::size_t i = 0; i < lc; ++i) {
|
|
||||||
rows_.emplace_back(content_.GetLine(i));
|
|
||||||
}
|
}
|
||||||
// Keep nrows_ in sync for any legacy code that still reads it
|
|
||||||
const_cast<Buffer *>(this)->nrows_ = rows_.size();
|
|
||||||
rows_cache_dirty_ = false;
|
// ===== 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
|
||||||
|
Buffer::GetLineView(std::size_t row) const
|
||||||
|
{
|
||||||
|
const auto [start, end] = content_.GetLineRange(row); // [start,end) in bytes
|
||||||
|
return end > start ? content_.View(start, end - start) : std::string_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -643,13 +805,17 @@ Buffer::content_LineCount_() const
|
|||||||
std::string
|
std::string
|
||||||
Buffer::BytesForTests() const
|
Buffer::BytesForTests() const
|
||||||
{
|
{
|
||||||
const std::size_t sz = content_.Size();
|
return Bytes();
|
||||||
if (sz == 0)
|
}
|
||||||
return std::string();
|
|
||||||
const char *data = content_.Data();
|
|
||||||
if (!data)
|
std::vector<std::string>
|
||||||
return std::string();
|
Buffer::LinesForTests() const
|
||||||
return std::string(data, data + sz);
|
{
|
||||||
|
std::vector<std::string> out;
|
||||||
|
for (std::size_t i = 0; i < Nrows(); ++i)
|
||||||
|
out.push_back(GetLineString(i));
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -664,51 +830,18 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
if (col < 0)
|
if (col < 0)
|
||||||
col = 0;
|
col = 0;
|
||||||
|
|
||||||
|
// `len` counts each newline as one character, so the deletion is simply
|
||||||
|
// the next `len` bytes from the (clamped) start, capped at end of buffer.
|
||||||
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(col));
|
||||||
std::size_t r = static_cast<std::size_t>(row);
|
|
||||||
std::size_t c = static_cast<std::size_t>(col);
|
|
||||||
std::size_t remaining = len;
|
|
||||||
const std::size_t lc = content_.LineCount();
|
|
||||||
|
|
||||||
while (remaining > 0 && r < lc) {
|
|
||||||
const std::string line = content_.GetLine(r); // logical line (without trailing '\n')
|
|
||||||
const std::size_t L = line.size();
|
|
||||||
if (c < L) {
|
|
||||||
const std::size_t take = std::min(remaining, L - c);
|
|
||||||
c += take;
|
|
||||||
remaining -= take;
|
|
||||||
}
|
|
||||||
if (remaining == 0)
|
|
||||||
break;
|
|
||||||
// Consume newline between lines as one char, if there is a next line
|
|
||||||
if (r + 1 < lc) {
|
|
||||||
remaining -= 1; // the newline
|
|
||||||
r += 1;
|
|
||||||
c = 0;
|
|
||||||
} else {
|
|
||||||
// At last line and still remaining: delete to EOF
|
|
||||||
const std::size_t total = content_.Size();
|
const std::size_t total = content_.Size();
|
||||||
const std::size_t actual = (total > start) ? (total - start) : 0;
|
const std::size_t actual = (total > start) ? std::min(len, total - start) : 0;
|
||||||
if (actual == 0)
|
if (actual == 0)
|
||||||
return;
|
return;
|
||||||
content_.Delete(start, actual);
|
content_.Delete(start, actual);
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_)
|
if (swap_rec_)
|
||||||
swap_rec_->OnDelete(row, col, actual);
|
swap_rec_->OnDelete(row, col, actual);
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute end offset at (r,c)
|
|
||||||
std::size_t end = content_.LineColToByteOffset(r, c);
|
|
||||||
if (end > start) {
|
|
||||||
const std::size_t actual = end - start;
|
|
||||||
content_.Delete(start, actual);
|
|
||||||
rows_cache_dirty_ = true;
|
|
||||||
if (swap_rec_)
|
|
||||||
swap_rec_->OnDelete(row, col, actual);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -724,7 +857,7 @@ Buffer::split_line(int row, const int col)
|
|||||||
static_cast<std::size_t>(c));
|
static_cast<std::size_t>(c));
|
||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off, &nl, 1);
|
content_.Insert(off, &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_)
|
if (swap_rec_)
|
||||||
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
|
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
|
||||||
}
|
}
|
||||||
@@ -743,7 +876,7 @@ Buffer::join_lines(int row)
|
|||||||
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
||||||
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
||||||
content_.Delete(end_of_line, 1);
|
content_.Delete(end_of_line, 1);
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_)
|
if (swap_rec_)
|
||||||
swap_rec_->OnDelete(row, col, 1);
|
swap_rec_->OnDelete(row, col, 1);
|
||||||
}
|
}
|
||||||
@@ -759,12 +892,16 @@ Buffer::insert_row(int row, const std::string_view text)
|
|||||||
content_.Insert(off, text.data(), text.size());
|
content_.Insert(off, text.data(), text.size());
|
||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off + text.size(), &nl, 1);
|
content_.Insert(off + text.size(), &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_) {
|
if (swap_rec_) {
|
||||||
// Avoid allocation: emit the row text insertion (if any) and the newline insertion.
|
// One record: the first of two could trigger a journal checkpoint,
|
||||||
if (!text.empty())
|
// whose snapshot already holds the newline the second then added
|
||||||
swap_rec_->OnInsert(row, 0, text);
|
// again on replay.
|
||||||
swap_rec_->OnInsert(row, static_cast<int>(text.size()), std::string_view("\n", 1));
|
std::string line;
|
||||||
|
line.reserve(text.size() + 1);
|
||||||
|
line.append(text);
|
||||||
|
line.push_back('\n');
|
||||||
|
swap_rec_->OnInsert(row, 0, line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,7 +923,7 @@ Buffer::delete_row(int row)
|
|||||||
if (actual == 0)
|
if (actual == 0)
|
||||||
return;
|
return;
|
||||||
content_.Delete(start, actual);
|
content_.Delete(start, actual);
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_)
|
if (swap_rec_)
|
||||||
swap_rec_->OnDelete(row, 0, actual);
|
swap_rec_->OnDelete(row, 0, actual);
|
||||||
}
|
}
|
||||||
@@ -798,7 +935,50 @@ Buffer::replace_all_bytes(const std::string_view bytes)
|
|||||||
content_.Clear();
|
content_.Clear();
|
||||||
if (!bytes.empty())
|
if (!bytes.empty())
|
||||||
content_.Append(bytes.data(), bytes.size());
|
content_.Append(bytes.data(), bytes.size());
|
||||||
rows_cache_dirty_ = true;
|
if (undo_sys_)
|
||||||
|
undo_sys_->clear();
|
||||||
|
MarkContentChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Buffer::insert_spans(int row, int col, const std::vector<TextSpan> &spans)
|
||||||
|
{
|
||||||
|
if (row < 0)
|
||||||
|
row = 0;
|
||||||
|
if (col < 0)
|
||||||
|
col = 0;
|
||||||
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
|
static_cast<std::size_t>(col));
|
||||||
|
content_.InsertSpans(off, spans);
|
||||||
|
edited_at_(row);
|
||||||
|
if (swap_rec_) {
|
||||||
|
// The journal needs the bytes as one record (see insert_row).
|
||||||
|
std::string text;
|
||||||
|
content_.VisitSpans(spans, [&](const char *d, std::size_t n) {
|
||||||
|
text.append(d, n);
|
||||||
|
});
|
||||||
|
if (!text.empty())
|
||||||
|
swap_rec_->OnInsert(row, col, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<TextSpan>
|
||||||
|
Buffer::SpansAt(int row, int col, std::size_t len) const
|
||||||
|
{
|
||||||
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(std::max(row, 0)),
|
||||||
|
static_cast<std::size_t>(std::max(col, 0)));
|
||||||
|
return content_.SpansInRange(off, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<TextSpan>
|
||||||
|
Buffer::TakeDeletedSpans(int row, int col, std::size_t len)
|
||||||
|
{
|
||||||
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(std::max(row, 0)),
|
||||||
|
static_cast<std::size_t>(std::max(col, 0)));
|
||||||
|
return content_.TakeDeletedSpans(off, len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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_), rendered column (rx_)
|
* - Cursor state: Current position (curx_, cury_)
|
||||||
* - 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,23 +17,18 @@
|
|||||||
*
|
*
|
||||||
* 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. Legacy Line wrapper:
|
* 3. Content access:
|
||||||
* - Buffer::Line provides a string-like interface for legacy command code
|
* - Nrows(), GetLineString(): per-line access
|
||||||
* - New code should prefer direct PieceTable operations
|
* - GetLineView(): line access via string_view (zero-copy within a piece)
|
||||||
* - 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>
|
||||||
@@ -49,7 +44,6 @@
|
|||||||
#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 };
|
||||||
@@ -118,12 +112,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::size_t Rx() const
|
|
||||||
{
|
|
||||||
return rx_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::size_t Nrows() const
|
[[nodiscard]] std::size_t Nrows() const
|
||||||
{
|
{
|
||||||
return content_LineCount_();
|
return content_LineCount_();
|
||||||
@@ -142,169 +130,7 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Line wrapper used by legacy command paths.
|
// A line's text without its trailing newline.
|
||||||
// Keep this lightweight: store materialized bytes only for that line.
|
|
||||||
class Line {
|
|
||||||
public:
|
|
||||||
Line() = default;
|
|
||||||
|
|
||||||
|
|
||||||
explicit Line(const char *s)
|
|
||||||
{
|
|
||||||
assign_from(s ? std::string(s) : std::string());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
explicit Line(const std::string &s)
|
|
||||||
{
|
|
||||||
assign_from(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Line(const Line &other) = default;
|
|
||||||
|
|
||||||
Line &operator=(const Line &other) = default;
|
|
||||||
|
|
||||||
Line(Line &&other) noexcept = default;
|
|
||||||
|
|
||||||
Line &operator=(Line &&other) noexcept = default;
|
|
||||||
|
|
||||||
// capacity helpers
|
|
||||||
void Clear()
|
|
||||||
{
|
|
||||||
s_.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// size/access
|
|
||||||
[[nodiscard]] std::size_t size() const
|
|
||||||
{
|
|
||||||
return s_.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool empty() const
|
|
||||||
{
|
|
||||||
return s_.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// read-only raw view
|
|
||||||
[[nodiscard]] const char *Data() const
|
|
||||||
{
|
|
||||||
return s_.data();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::size_t Size() const
|
|
||||||
{
|
|
||||||
return s_.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// element access (read-only)
|
|
||||||
[[nodiscard]] char operator[](std::size_t i) const
|
|
||||||
{
|
|
||||||
return (i < s_.size()) ? s_[i] : '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// conversions
|
|
||||||
explicit operator std::string() const
|
|
||||||
{
|
|
||||||
return s_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// string-like API used by command/renderer layers (implemented via materialization for now)
|
|
||||||
[[nodiscard]] std::string substr(std::size_t pos) const
|
|
||||||
{
|
|
||||||
return pos < s_.size() ? s_.substr(pos) : std::string();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::string substr(std::size_t pos, std::size_t len) const
|
|
||||||
{
|
|
||||||
return pos < s_.size() ? s_.substr(pos, len) : std::string();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// minimal find() to support search within a line
|
|
||||||
[[nodiscard]] std::size_t find(const std::string &needle, const std::size_t pos = 0) const
|
|
||||||
{
|
|
||||||
return s_.find(needle, pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void erase(std::size_t pos)
|
|
||||||
{
|
|
||||||
if (pos < s_.size())
|
|
||||||
s_.erase(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void erase(std::size_t pos, std::size_t len)
|
|
||||||
{
|
|
||||||
if (pos < s_.size())
|
|
||||||
s_.erase(pos, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void insert(std::size_t pos, const std::string &seg)
|
|
||||||
{
|
|
||||||
if (pos > s_.size())
|
|
||||||
pos = s_.size();
|
|
||||||
s_.insert(pos, seg);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Line &operator+=(const Line &other)
|
|
||||||
{
|
|
||||||
s_ += other.s_;
|
|
||||||
return *this;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Line &operator+=(const std::string &s)
|
|
||||||
{
|
|
||||||
s_ += s;
|
|
||||||
return *this;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Line &operator=(const std::string &s)
|
|
||||||
{
|
|
||||||
assign_from(s);
|
|
||||||
return *this;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
void assign_from(const std::string &s)
|
|
||||||
{
|
|
||||||
s_ = s;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
std::string s_;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] const std::vector<Line> &Rows() const
|
|
||||||
{
|
|
||||||
ensure_rows_cache();
|
|
||||||
return rows_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::vector<Line> &Rows()
|
|
||||||
{
|
|
||||||
ensure_rows_cache();
|
|
||||||
return rows_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Lightweight, lazy per-line accessors that avoid materializing all rows.
|
|
||||||
// Prefer these over Rows() in hot paths to reduce memory overhead on large files.
|
|
||||||
[[nodiscard]] std::string GetLineString(std::size_t row) const
|
[[nodiscard]] std::string GetLineString(std::size_t row) const
|
||||||
{
|
{
|
||||||
return content_.GetLine(row);
|
return content_.GetLine(row);
|
||||||
@@ -317,10 +143,25 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Zero-copy view of a line. Points into the materialized backing store; becomes
|
// View of a line's raw bytes, including its trailing '\n' if any. No copy
|
||||||
// invalid after subsequent edits. Use immediately.
|
// when the line lies within one piece (an unedited stretch); a line that
|
||||||
|
// straddles an edit materializes the whole buffer, so per-row loops over
|
||||||
|
// an edited buffer should prefer GetLineString(). Invalid after the next
|
||||||
|
// edit; views of different lines need not be contiguous.
|
||||||
[[nodiscard]] std::string_view GetLineView(std::size_t row) const;
|
[[nodiscard]] std::string_view GetLineView(std::size_t row) const;
|
||||||
|
|
||||||
|
// The whole text as one contiguous view (materializes an edited buffer).
|
||||||
|
// Invalid after the next edit.
|
||||||
|
[[nodiscard]] std::string_view ContentView() const
|
||||||
|
{
|
||||||
|
return content_.ContentView();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// A copy of the whole text, assembled from the pieces (no materialized
|
||||||
|
// copy is kept).
|
||||||
|
[[nodiscard]] std::string Bytes() const;
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] const std::string &Filename() const
|
[[nodiscard]] const std::string &Filename() const
|
||||||
{
|
{
|
||||||
@@ -334,6 +175,7 @@ public:
|
|||||||
{
|
{
|
||||||
filename_ = name;
|
filename_ = name;
|
||||||
is_file_backed_ = false;
|
is_file_backed_ = false;
|
||||||
|
is_virtual_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -343,6 +185,23 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// A virtual buffer (e.g. "+HELP+") has a display name that is not a path:
|
||||||
|
// it is never journaled, and saving it asks for a file name.
|
||||||
|
[[nodiscard]] bool IsVirtual() const
|
||||||
|
{
|
||||||
|
return is_virtual_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Identity of this document, stable for its lifetime, including when the
|
||||||
|
// Buffer object is moved (e.g. by the editor's buffer vector). Copies get
|
||||||
|
// a new id. Use it, not the object's address, to key per-buffer caches.
|
||||||
|
[[nodiscard]] std::uint64_t Id() const
|
||||||
|
{
|
||||||
|
return id_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool Dirty() const
|
[[nodiscard]] bool Dirty() const
|
||||||
{
|
{
|
||||||
return dirty_;
|
return dirty_;
|
||||||
@@ -375,12 +234,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetRenderX(const std::size_t rx)
|
|
||||||
{
|
|
||||||
rx_ = rx;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetOffsets(const std::size_t row, const std::size_t col)
|
void SetOffsets(const std::size_t row, const std::size_t col)
|
||||||
{
|
{
|
||||||
rowoffs_ = row;
|
rowoffs_ = row;
|
||||||
@@ -390,13 +243,17 @@ public:
|
|||||||
|
|
||||||
void SetDirty(bool d)
|
void SetDirty(bool d)
|
||||||
{
|
{
|
||||||
|
// The version tracks content, not the dirty flag: every text mutation
|
||||||
|
// bumps it (see edited_at_), including undo/redo back to the saved state.
|
||||||
dirty_ = d;
|
dirty_ = d;
|
||||||
if (d) {
|
|
||||||
++version_;
|
|
||||||
if (highlighter_) {
|
|
||||||
highlighter_->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whole-buffer content change (load, reload, replace): bump the version
|
||||||
|
// and drop all cached highlighting.
|
||||||
|
void MarkContentChanged()
|
||||||
|
{
|
||||||
|
edited_at_(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -469,15 +326,22 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The selected rows, clamped to the buffer: they are stored as row numbers
|
||||||
|
// and go stale when lines are removed, and commands must never act on rows
|
||||||
|
// past the end (edits there were misplaced and could not be undone).
|
||||||
[[nodiscard]] std::size_t VisualLineStartY() const
|
[[nodiscard]] std::size_t VisualLineStartY() const
|
||||||
{
|
{
|
||||||
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_;
|
const std::size_t y = visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_
|
||||||
|
: visual_line_active_y_;
|
||||||
|
return clamp_row_(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::size_t VisualLineEndY() const
|
[[nodiscard]] std::size_t VisualLineEndY() const
|
||||||
{
|
{
|
||||||
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_y_;
|
const std::size_t y = visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_
|
||||||
|
: visual_line_anchor_y_;
|
||||||
|
return clamp_row_(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -500,6 +364,15 @@ public:
|
|||||||
|
|
||||||
[[nodiscard]] std::string AsString() const;
|
[[nodiscard]] std::string AsString() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
[[nodiscard]] std::size_t clamp_row_(std::size_t y) const
|
||||||
|
{
|
||||||
|
const std::size_t n = Nrows();
|
||||||
|
return (n == 0) ? 0 : (y < n ? y : n - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
// Syntax highlighting integration (per-buffer)
|
// Syntax highlighting integration (per-buffer)
|
||||||
[[nodiscard]] std::uint64_t Version() const
|
[[nodiscard]] std::uint64_t Version() const
|
||||||
{
|
{
|
||||||
@@ -517,6 +390,7 @@ public:
|
|||||||
void SetEditMode(EditMode m)
|
void SetEditMode(EditMode m)
|
||||||
{
|
{
|
||||||
edit_mode_ = m;
|
edit_mode_ = m;
|
||||||
|
edit_mode_detected_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -525,6 +399,13 @@ public:
|
|||||||
edit_mode_ = (edit_mode_ == EditMode::Code)
|
edit_mode_ = (edit_mode_ == EditMode::Code)
|
||||||
? EditMode::Writing
|
? EditMode::Writing
|
||||||
: EditMode::Code;
|
: EditMode::Code;
|
||||||
|
edit_mode_detected_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool EditModeDetected() const
|
||||||
|
{
|
||||||
|
return edit_mode_detected_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -540,6 +421,25 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Marks that the user explicitly set syntax state via a command (:syntax
|
||||||
|
// on/off, :set filetype=...), as opposed to it being auto-applied from
|
||||||
|
// GUIConfig. Frontends that re-apply the config-driven default every
|
||||||
|
// frame (e.g. ImGuiFrontend::apply_syntax_to_buffer) must check this
|
||||||
|
// first, mirroring EditModeDetected()'s "don't stomp a manual toggle"
|
||||||
|
// pattern - otherwise a manual :syntax off is silently undone on the very
|
||||||
|
// next frame.
|
||||||
|
void SetSyntaxUserOverride(bool on)
|
||||||
|
{
|
||||||
|
syntax_user_override_ = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool SyntaxUserOverride() const
|
||||||
|
{
|
||||||
|
return syntax_user_override_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetFiletype(const std::string &ft)
|
void SetFiletype(const std::string &ft)
|
||||||
{
|
{
|
||||||
filetype_ = ft;
|
filetype_ = ft;
|
||||||
@@ -571,6 +471,15 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Enable syntax with the highlighter the filename (or a shebang on the
|
||||||
|
// first line) calls for, or none for an unknown type.
|
||||||
|
void ApplyDetectedFiletype();
|
||||||
|
|
||||||
|
// Install the highlighter for Filetype() (the null highlighter for an
|
||||||
|
// empty or unsupported one), creating the engine if needed.
|
||||||
|
void InstallFiletypeHighlighter();
|
||||||
|
|
||||||
|
|
||||||
// Swap journal integration (set by Editor)
|
// Swap journal integration (set by Editor)
|
||||||
void SetSwapRecorder(kte::SwapRecorder *rec)
|
void SetSwapRecorder(kte::SwapRecorder *rec)
|
||||||
{
|
{
|
||||||
@@ -598,11 +507,55 @@ public:
|
|||||||
|
|
||||||
void delete_row(int row);
|
void delete_row(int row);
|
||||||
|
|
||||||
|
// Insert the bytes of storage spans (see TextSpan.h) at (row, col)
|
||||||
|
// without copying them into storage again. Used by undo for large texts.
|
||||||
|
void insert_spans(int row, int col, const std::vector<TextSpan> &spans);
|
||||||
|
|
||||||
// Replace the entire buffer content with raw bytes.
|
// Replace the entire buffer content with raw bytes.
|
||||||
// Intended for crash recovery (swap replay) and test harnesses.
|
// Intended for crash recovery (swap replay) and test harnesses.
|
||||||
// This does not trigger swap or undo recording.
|
// This does not trigger swap or undo recording, and clears undo history
|
||||||
|
// (recorded against the previous content, which it also references).
|
||||||
void replace_all_bytes(std::string_view bytes);
|
void replace_all_bytes(std::string_view bytes);
|
||||||
|
|
||||||
|
// ===== Span access for undo (see TextSpan.h) =====
|
||||||
|
[[nodiscard]] std::vector<TextSpan> SpansAt(int row, int col, std::size_t len) const;
|
||||||
|
|
||||||
|
// Spans removed by the most recent deletion, if it was exactly `len`
|
||||||
|
// bytes at (row, col) (see PieceTable::TakeDeletedSpans).
|
||||||
|
[[nodiscard]] std::vector<TextSpan> TakeDeletedSpans(int row, int col, std::size_t len);
|
||||||
|
|
||||||
|
[[nodiscard]] bool SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const
|
||||||
|
{
|
||||||
|
return content_.SpansEqual(spans, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
template<typename Fn>
|
||||||
|
void VisitSpans(const std::vector<TextSpan> &spans, Fn &&fn) const
|
||||||
|
{
|
||||||
|
content_.VisitSpans(spans, std::forward<Fn>(fn));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Total size of the content in bytes.
|
||||||
|
[[nodiscard]] std::size_t ContentBytes() const
|
||||||
|
{
|
||||||
|
return content_.Size();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Byte offset of (row, col) in the content (clamped), and back.
|
||||||
|
[[nodiscard]] std::size_t RowColToOffset(std::size_t row, std::size_t col) const
|
||||||
|
{
|
||||||
|
return content_.LineColToByteOffset(row, col);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::pair<std::size_t, std::size_t> OffsetToRowCol(std::size_t off) const
|
||||||
|
{
|
||||||
|
return content_.ByteOffsetToLineCol(off);
|
||||||
|
}
|
||||||
|
|
||||||
// Undo system accessors (created per-buffer)
|
// Undo system accessors (created per-buffer)
|
||||||
[[nodiscard]] UndoSystem *Undo();
|
[[nodiscard]] UndoSystem *Undo();
|
||||||
|
|
||||||
@@ -610,6 +563,10 @@ public:
|
|||||||
|
|
||||||
#if defined(KTE_TESTS)
|
#if defined(KTE_TESTS)
|
||||||
// Test-only: return the raw buffer bytes (including newlines) as a string.
|
// Test-only: return the raw buffer bytes (including newlines) as a string.
|
||||||
|
|
||||||
|
// Every line without its newline (tests only).
|
||||||
|
[[nodiscard]] std::vector<std::string> LinesForTests() const;
|
||||||
|
|
||||||
[[nodiscard]] std::string BytesForTests() const;
|
[[nodiscard]] std::string BytesForTests() const;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -630,22 +587,23 @@ private:
|
|||||||
|
|
||||||
// State mirroring original C struct (without undo_tree)
|
// State mirroring original C struct (without undo_tree)
|
||||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
|
||||||
std::size_t nrows_ = 0; // number of rows
|
|
||||||
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
|
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
|
||||||
mutable std::vector<Line> rows_; // materialized cache of rows (without trailing newlines)
|
|
||||||
// PieceTable is the source of truth.
|
// PieceTable is the source of truth.
|
||||||
PieceTable content_{};
|
PieceTable content_{};
|
||||||
mutable bool rows_cache_dirty_ = true; // invalidate on edits / I/O
|
|
||||||
|
|
||||||
// Helper to rebuild rows_ from content_
|
|
||||||
void ensure_rows_cache() const;
|
|
||||||
|
|
||||||
// Helper to query content_.LineCount() while keeping header minimal
|
// Helper to query content_.LineCount() while keeping header minimal
|
||||||
std::size_t content_LineCount_() const;
|
std::size_t content_LineCount_() const;
|
||||||
|
|
||||||
std::string filename_;
|
std::string filename_;
|
||||||
bool is_file_backed_ = false;
|
bool is_file_backed_ = false;
|
||||||
|
bool is_virtual_ = false;
|
||||||
|
std::uint64_t id_ = NextBufferId();
|
||||||
|
|
||||||
|
static std::uint64_t NextBufferId()
|
||||||
|
{
|
||||||
|
static std::atomic<std::uint64_t> next{1};
|
||||||
|
return next.fetch_add(1);
|
||||||
|
}
|
||||||
bool dirty_ = false;
|
bool dirty_ = false;
|
||||||
bool read_only_ = false;
|
bool read_only_ = false;
|
||||||
bool mark_set_ = false;
|
bool mark_set_ = false;
|
||||||
@@ -660,14 +618,23 @@ private:
|
|||||||
|
|
||||||
// Edit mode (code vs writing)
|
// Edit mode (code vs writing)
|
||||||
EditMode edit_mode_ = EditMode::Code;
|
EditMode edit_mode_ = EditMode::Code;
|
||||||
|
bool edit_mode_detected_ = false; // true after initial auto-detection
|
||||||
|
|
||||||
// Syntax/highlighting state
|
// Syntax/highlighting state
|
||||||
std::uint64_t version_ = 0; // increment on edits
|
std::uint64_t version_ = 0; // increment on edits
|
||||||
|
|
||||||
|
// Record a content change starting at `row`: bump the version and let the
|
||||||
|
// highlighter drop cached rows from `row` down (rows above are unaffected).
|
||||||
|
void edited_at_(int row)
|
||||||
|
{
|
||||||
|
++version_;
|
||||||
|
if (highlighter_)
|
||||||
|
highlighter_->OnEdit(row < 0 ? 0 : row, version_);
|
||||||
|
}
|
||||||
bool syntax_enabled_ = true;
|
bool syntax_enabled_ = true;
|
||||||
|
bool syntax_user_override_ = false; // true once user explicitly set syntax state via a command
|
||||||
std::string filetype_;
|
std::string filetype_;
|
||||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||||
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
||||||
kte::SwapRecorder *swap_rec_ = nullptr;
|
kte::SwapRecorder *swap_rec_ = nullptr;
|
||||||
|
|
||||||
mutable std::mutex buffer_mutex_;
|
|
||||||
};
|
};
|
||||||
+90
-36
@@ -4,17 +4,17 @@ project(kte)
|
|||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(KTE_VERSION "1.10.0")
|
set(KTE_VERSION "1.13.0")
|
||||||
|
|
||||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||||
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
||||||
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
|
|
||||||
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
||||||
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
||||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
||||||
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
||||||
option(KTE_STATIC_LINK "Enable static linking on Linux" ON)
|
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,6 +22,8 @@ option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
|
|||||||
if (ENABLE_ASAN)
|
if (ENABLE_ASAN)
|
||||||
message(STATUS "ASan enabled")
|
message(STATUS "ASan enabled")
|
||||||
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
|
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
|
||||||
|
# The project is C++; without this only C sources were instrumented.
|
||||||
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
|
||||||
# Ensure the sanitizer is linked too (especially important on some platforms)
|
# Ensure the sanitizer is linked too (especially important on some platforms)
|
||||||
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
|
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
|
||||||
endif ()
|
endif ()
|
||||||
@@ -51,6 +53,7 @@ else ()
|
|||||||
)
|
)
|
||||||
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
||||||
add_compile_options("-stdlib=libc++")
|
add_compile_options("-stdlib=libc++")
|
||||||
|
add_link_options("-stdlib=libc++")
|
||||||
else ()
|
else ()
|
||||||
# nothing special for gcc at the moment
|
# nothing special for gcc at the moment
|
||||||
endif ()
|
endif ()
|
||||||
@@ -63,6 +66,26 @@ 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 ()
|
||||||
@@ -70,8 +93,34 @@ 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
|
||||||
@@ -113,17 +162,6 @@ if (BUILD_GUI)
|
|||||||
set(GUI_SOURCES
|
set(GUI_SOURCES
|
||||||
GUIConfig.cc
|
GUIConfig.cc
|
||||||
)
|
)
|
||||||
if (KTE_USE_QT)
|
|
||||||
find_package(Qt6 COMPONENTS Widgets REQUIRED)
|
|
||||||
set(GUI_SOURCES
|
|
||||||
${GUI_SOURCES}
|
|
||||||
QtFrontend.cc
|
|
||||||
QtInputHandler.cc
|
|
||||||
QtRenderer.cc
|
|
||||||
)
|
|
||||||
# Expose preprocessor switch so sources can exclude ImGui-specific code
|
|
||||||
add_compile_definitions(KTE_USE_QT)
|
|
||||||
else ()
|
|
||||||
set(GUI_SOURCES
|
set(GUI_SOURCES
|
||||||
${GUI_SOURCES}
|
${GUI_SOURCES}
|
||||||
${FONT_SOURCES}
|
${FONT_SOURCES}
|
||||||
@@ -132,7 +170,6 @@ if (BUILD_GUI)
|
|||||||
ImGuiRenderer.cc
|
ImGuiRenderer.cc
|
||||||
)
|
)
|
||||||
endif ()
|
endif ()
|
||||||
endif ()
|
|
||||||
|
|
||||||
set(COMMON_SOURCES
|
set(COMMON_SOURCES
|
||||||
PieceTable.cc
|
PieceTable.cc
|
||||||
@@ -145,6 +182,9 @@ 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
|
||||||
@@ -242,6 +282,9 @@ set(COMMON_HEADERS
|
|||||||
TestInputHandler.h
|
TestInputHandler.h
|
||||||
TestRenderer.h
|
TestRenderer.h
|
||||||
TestFrontend.h
|
TestFrontend.h
|
||||||
|
TextSpan.h
|
||||||
|
RegexEngine.h
|
||||||
|
SearchHighlight.h
|
||||||
UndoNode.h
|
UndoNode.h
|
||||||
UndoTree.h
|
UndoTree.h
|
||||||
UndoSystem.h
|
UndoSystem.h
|
||||||
@@ -255,14 +298,6 @@ if (BUILD_GUI)
|
|||||||
GUIConfig.h
|
GUIConfig.h
|
||||||
)
|
)
|
||||||
|
|
||||||
if (KTE_USE_QT)
|
|
||||||
set(GUI_HEADERS
|
|
||||||
${GUI_HEADERS}
|
|
||||||
QtFrontend.h
|
|
||||||
QtInputHandler.h
|
|
||||||
QtRenderer.h
|
|
||||||
)
|
|
||||||
else ()
|
|
||||||
set(GUI_HEADERS
|
set(GUI_HEADERS
|
||||||
${GUI_HEADERS}
|
${GUI_HEADERS}
|
||||||
${THEME_HEADERS}
|
${THEME_HEADERS}
|
||||||
@@ -273,7 +308,6 @@ if (BUILD_GUI)
|
|||||||
fonts/BerkeleyMono.h
|
fonts/BerkeleyMono.h
|
||||||
)
|
)
|
||||||
endif ()
|
endif ()
|
||||||
endif ()
|
|
||||||
|
|
||||||
# kte (terminal-first) executable
|
# kte (terminal-first) executable
|
||||||
add_executable(kte
|
add_executable(kte
|
||||||
@@ -333,7 +367,6 @@ 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
|
||||||
@@ -343,6 +376,9 @@ 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_audit_regressions.cc
|
||||||
|
|
||||||
# minimal engine sources required by Buffer
|
# minimal engine sources required by Buffer
|
||||||
PieceTable.cc
|
PieceTable.cc
|
||||||
@@ -354,9 +390,11 @@ 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
|
||||||
@@ -383,6 +421,9 @@ 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)
|
||||||
@@ -410,17 +451,10 @@ if (BUILD_GUI)
|
|||||||
|
|
||||||
)
|
)
|
||||||
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
|
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
|
||||||
if (KTE_USE_QT)
|
|
||||||
target_compile_definitions(kge PRIVATE KTE_USE_QT=1)
|
|
||||||
endif ()
|
|
||||||
if (KTE_UNDO_DEBUG)
|
if (KTE_UNDO_DEBUG)
|
||||||
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
|
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
|
||||||
endif ()
|
endif ()
|
||||||
if (KTE_USE_QT)
|
|
||||||
target_link_libraries(kge ${CURSES_LIBRARIES} Qt6::Widgets)
|
|
||||||
else ()
|
|
||||||
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
||||||
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)
|
||||||
@@ -480,10 +514,30 @@ if (BUILD_GUI)
|
|||||||
# Install kge man page only when GUI is built
|
# Install kge man page only when GUI is built
|
||||||
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
||||||
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
|
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
|
||||||
|
if (UNIX AND NOT APPLE)
|
||||||
|
install(FILES kge.desktop DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/applications)
|
||||||
|
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/pixmaps)
|
||||||
|
install(FILES kge.iconset/icon_16x16.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/16x16/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
install(FILES kge.iconset/icon_32x32.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/32x32/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
install(FILES kge.iconset/icon_128x128.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/128x128/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
install(FILES kge.iconset/icon_256x256.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/256x256/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
install(FILES kge.iconset/icon_512x512.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/512x512/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
endif ()
|
||||||
|
|
||||||
# Optional post-build bundle fixup (can also be run from scripts).
|
# Optional post-build bundle fixup (can also be run from scripts).
|
||||||
# This provides a CMake target to run BundleUtilities' fixup_bundle on the
|
# This provides a CMake target to run BundleUtilities' fixup_bundle on the
|
||||||
# built app, useful after macdeployqt to ensure non-Qt dylibs are internalized.
|
# built app, copying non-system dylibs (e.g. Homebrew SDL2/SDL3, freetype,
|
||||||
|
# pcre2) into the bundle so it runs on machines without them installed.
|
||||||
if (APPLE AND TARGET kge)
|
if (APPLE AND TARGET kge)
|
||||||
get_target_property(IS_BUNDLE kge MACOSX_BUNDLE)
|
get_target_property(IS_BUNDLE kge MACOSX_BUNDLE)
|
||||||
if (IS_BUNDLE)
|
if (IS_BUNDLE)
|
||||||
@@ -491,7 +545,7 @@ if (BUILD_GUI)
|
|||||||
COMMAND ${CMAKE_COMMAND}
|
COMMAND ${CMAKE_COMMAND}
|
||||||
-DAPP_BUNDLE=${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_PROPERTY:kge,MACOSX_BUNDLE_BUNDLE_NAME>.app
|
-DAPP_BUNDLE=${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_PROPERTY:kge,MACOSX_BUNDLE_BUNDLE_NAME>.app
|
||||||
-P ${CMAKE_CURRENT_LIST_DIR}/cmake/fix_bundle.cmake
|
-P ${CMAKE_CURRENT_LIST_DIR}/cmake/fix_bundle.cmake
|
||||||
COMMENT "Running fixup_bundle on kge.app to internalize non-Qt dylibs"
|
COMMENT "Running fixup_bundle on kge.app to internalize non-system dylibs"
|
||||||
VERBATIM)
|
VERBATIM)
|
||||||
add_dependencies(kge_fixup_bundle kge)
|
add_dependencies(kge_fixup_bundle kge)
|
||||||
endif ()
|
endif ()
|
||||||
|
|||||||
+1596
-1040
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@ enum class CommandId {
|
|||||||
OpenFileStart, // begin open-file prompt
|
OpenFileStart, // begin open-file prompt
|
||||||
VisualFilePickerToggle,
|
VisualFilePickerToggle,
|
||||||
// GUI-only: toggle/show a visual font selector dialog
|
// GUI-only: toggle/show a visual font selector dialog
|
||||||
VisualFontPickerToggle,
|
|
||||||
// Buffers
|
// Buffers
|
||||||
BufferSwitchStart, // begin buffer switch prompt
|
BufferSwitchStart, // begin buffer switch prompt
|
||||||
BufferNew, // create a new empty, unnamed buffer (C-k i)
|
BufferNew, // create a new empty, unnamed buffer (C-k i)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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 \
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "ErrorHandler.h"
|
||||||
#include "syntax/CppHighlighter.h"
|
#include "syntax/CppHighlighter.h"
|
||||||
#include "syntax/NullHighlighter.h"
|
|
||||||
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
static std::string
|
// Universal-argument repeat counts are capped here (well below INT_MAX).
|
||||||
buffer_bytes_via_views(const Buffer &b)
|
constexpr int kMaxUArgCount = 1000000;
|
||||||
{
|
} // namespace
|
||||||
const 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)
|
||||||
{
|
{
|
||||||
@@ -44,6 +37,46 @@ apply_pending_line(Editor &ed, const std::size_t line1)
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Same normalization as Buffer::OpenFromFile (expand "~", canonical path if
|
||||||
|
// the file exists, else absolute), so a path can be compared with the
|
||||||
|
// Filename() of open buffers.
|
||||||
|
std::string
|
||||||
|
normalize_open_path(const std::string &in)
|
||||||
|
{
|
||||||
|
std::string expanded = in;
|
||||||
|
if (!expanded.empty() && expanded[0] == '~') {
|
||||||
|
const char *home = std::getenv("HOME");
|
||||||
|
if (home && expanded.size() >= 2 && (expanded[1] == '/' || expanded[1] == '\\'))
|
||||||
|
expanded = std::string(home) + expanded.substr(1);
|
||||||
|
else if (home && expanded.size() == 1)
|
||||||
|
expanded = std::string(home);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(expanded);
|
||||||
|
if (std::filesystem::exists(p))
|
||||||
|
return std::filesystem::canonical(p).string();
|
||||||
|
return std::filesystem::absolute(p).string();
|
||||||
|
} catch (...) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
Editor::FindOpenBuffer(const std::string &path) const
|
||||||
|
{
|
||||||
|
const std::string norm = normalize_open_path(path);
|
||||||
|
const auto &bufs = Buffers();
|
||||||
|
for (std::size_t i = 0; i < bufs.size(); ++i) {
|
||||||
|
if (!bufs[i].Filename().empty() && bufs[i].Filename() == norm)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return static_cast<std::size_t>(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Editor::Editor()
|
Editor::Editor()
|
||||||
{
|
{
|
||||||
swap_ = std::make_unique<kte::SwapManager>();
|
swap_ = std::make_unique<kte::SwapManager>();
|
||||||
@@ -165,11 +198,25 @@ 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);
|
||||||
// Attach swap recorder
|
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;
|
||||||
@@ -182,10 +229,21 @@ 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()) {
|
||||||
swap_->Attach(&bufs.back());
|
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
|
||||||
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
|
Buffer *new_addr = &bufs[i];
|
||||||
|
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;
|
||||||
@@ -197,6 +255,14 @@ 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();
|
||||||
@@ -212,32 +278,13 @@ 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;
|
||||||
@@ -250,31 +297,12 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
}
|
}
|
||||||
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
||||||
// final location (vector) because swap manager keys off Buffer*.
|
// final location (vector) because swap manager keys off Buffer*.
|
||||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
// Highlighting by extension or shebang
|
||||||
b.EnsureHighlighter();
|
b.ApplyDetectedFiletype();
|
||||||
std::string first = "";
|
|
||||||
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
|
||||||
@@ -314,6 +342,10 @@ Editor::CancelRecoveryPrompt()
|
|||||||
pending_recovery_open_ = PendingOpen{};
|
pending_recovery_open_ = PendingOpen{};
|
||||||
pending_recovery_swap_path_.clear();
|
pending_recovery_swap_path_.clear();
|
||||||
pending_recovery_replay_err_.clear();
|
pending_recovery_replay_err_.clear();
|
||||||
|
std::string().swap(pending_recovery_content_);
|
||||||
|
pending_recovery_valid_bytes_ = 0;
|
||||||
|
pending_recovery_swap_size_ = 0;
|
||||||
|
pending_recovery_have_content_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -326,6 +358,10 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
const PendingOpen req = pending_recovery_open_;
|
const PendingOpen req = pending_recovery_open_;
|
||||||
const std::string swp = pending_recovery_swap_path_;
|
const std::string swp = pending_recovery_swap_path_;
|
||||||
const std::string rerr_s = pending_recovery_replay_err_;
|
const std::string rerr_s = pending_recovery_replay_err_;
|
||||||
|
std::string content = std::move(pending_recovery_content_);
|
||||||
|
const bool have_content = pending_recovery_have_content_;
|
||||||
|
std::uint64_t valid_bytes = pending_recovery_valid_bytes_;
|
||||||
|
const std::uint64_t swap_size_then = pending_recovery_swap_size_;
|
||||||
CancelRecoveryPrompt();
|
CancelRecoveryPrompt();
|
||||||
|
|
||||||
std::string err;
|
std::string err;
|
||||||
@@ -340,11 +376,35 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
SetStatus("Recovery failed: no buffer");
|
SetStatus("Recovery failed: no buffer");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
std::uint64_t swap_size_now = 0;
|
||||||
|
try {
|
||||||
|
swap_size_now = std::filesystem::file_size(swp);
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
if (have_content && swap_size_now == swap_size_then) {
|
||||||
|
// The prompt already replayed the journal (unchanged since); install that.
|
||||||
|
b->replace_all_bytes(content);
|
||||||
|
std::string().swap(content);
|
||||||
|
} else {
|
||||||
std::string rerr;
|
std::string rerr;
|
||||||
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
|
if (!kte::SwapManager::ReplayFile(*b, swp, rerr, &valid_bytes)) {
|
||||||
SetStatus("Swap recovery failed: " + rerr);
|
SetStatus("Swap recovery failed: " + rerr);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// This session keeps appending to the same journal. Drop a torn
|
||||||
|
// final record first, or new records would land behind it and the
|
||||||
|
// journal could not be replayed after another crash.
|
||||||
|
try {
|
||||||
|
if (std::filesystem::file_size(swp) > valid_bytes)
|
||||||
|
std::filesystem::resize_file(swp, valid_bytes);
|
||||||
|
} catch (...) {
|
||||||
|
// Best effort; the replay itself succeeded.
|
||||||
|
}
|
||||||
|
// Nothing in the undo history matches the file on disk now: keep
|
||||||
|
// the buffer dirty even after undoing back to the loaded state.
|
||||||
|
if (auto *u = b->Undo())
|
||||||
|
u->mark_base_unsaved();
|
||||||
b->SetDirty(true);
|
b->SetDirty(true);
|
||||||
apply_pending_line(*this, req.line1);
|
apply_pending_line(*this, req.line1);
|
||||||
SetStatus("Recovered " + req.path);
|
SetStatus("Recovered " + req.path);
|
||||||
@@ -361,8 +421,16 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
||||||
|
std::string kept_as;
|
||||||
if (yes) {
|
if (yes) {
|
||||||
(void) std::remove(swp.c_str());
|
(void) std::remove(swp.c_str());
|
||||||
|
} else {
|
||||||
|
// Keep the unreadable journal for inspection, but out of the way:
|
||||||
|
// left in place, the new session's records would be appended
|
||||||
|
// behind the corrupt data and could never be replayed.
|
||||||
|
kept_as = swp + ".corrupt";
|
||||||
|
if (std::rename(swp.c_str(), kept_as.c_str()) != 0)
|
||||||
|
kept_as.clear();
|
||||||
}
|
}
|
||||||
if (!OpenFile(req.path, err)) {
|
if (!OpenFile(req.path, err)) {
|
||||||
SetStatus(err);
|
SetStatus(err);
|
||||||
@@ -370,7 +438,9 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
}
|
}
|
||||||
apply_pending_line(*this, req.line1);
|
apply_pending_line(*this, req.line1);
|
||||||
// Include a short hint that the swap was corrupt.
|
// Include a short hint that the swap was corrupt.
|
||||||
if (!rerr_s.empty()) {
|
if (!kept_as.empty()) {
|
||||||
|
SetStatus("Opened " + req.path + " (unreadable swap kept as " + kept_as + ")");
|
||||||
|
} else if (!rerr_s.empty()) {
|
||||||
SetStatus("Opened " + req.path + " (swap unreadable)");
|
SetStatus("Opened " + req.path + " (swap unreadable)");
|
||||||
} else {
|
} else {
|
||||||
SetStatus("Opened " + req.path);
|
SetStatus("Opened " + req.path);
|
||||||
@@ -383,6 +453,39 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
|
|
||||||
bool
|
bool
|
||||||
Editor::ProcessPendingOpens()
|
Editor::ProcessPendingOpens()
|
||||||
|
{
|
||||||
|
bool opened = false;
|
||||||
|
// Opening can throw (bad_alloc on a huge file, filesystem errors), and so
|
||||||
|
// can the checkpoint snapshot below. This runs from every frontend's
|
||||||
|
// loop, outside command dispatch: report the failure instead of letting
|
||||||
|
// it end the editor.
|
||||||
|
try {
|
||||||
|
// Also the once-per-frame hook for the journal: retry the
|
||||||
|
// checkpoint of a journal that lost a record.
|
||||||
|
if (kte::SwapManager *sm = Swap())
|
||||||
|
sm->RetryGapCheckpoints();
|
||||||
|
opened = process_pending_opens_();
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
kte::ErrorHandler::Instance().Error("Editor", std::string("open failed: ") + e.what(), "");
|
||||||
|
SetStatus(std::string("Open failed: ") + e.what());
|
||||||
|
} catch (...) {
|
||||||
|
SetStatus("Open failed");
|
||||||
|
}
|
||||||
|
// Journal problems for the user, after the open's own status so it does
|
||||||
|
// not hide them; all pending ones at once (one per frame flashed by).
|
||||||
|
if (kte::SwapManager *sm = Swap()) {
|
||||||
|
std::string notices;
|
||||||
|
for (std::string n = sm->TakeUserNotice(); !n.empty(); n = sm->TakeUserNotice())
|
||||||
|
notices += (notices.empty() ? "" : "; ") + n;
|
||||||
|
if (!notices.empty())
|
||||||
|
SetStatus(notices);
|
||||||
|
}
|
||||||
|
return opened;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::process_pending_opens_()
|
||||||
{
|
{
|
||||||
if (PromptActive())
|
if (PromptActive())
|
||||||
return false;
|
return false;
|
||||||
@@ -395,6 +498,15 @@ Editor::ProcessPendingOpens()
|
|||||||
pending_open_.pop_front();
|
pending_open_.pop_front();
|
||||||
if (req.path.empty())
|
if (req.path.empty())
|
||||||
continue;
|
continue;
|
||||||
|
// Already open: switch to it. Its swap file is this session's live
|
||||||
|
// journal, not something to recover.
|
||||||
|
if (const std::size_t open_idx = FindOpenBuffer(req.path); open_idx != static_cast<std::size_t>(-1)) {
|
||||||
|
SwitchTo(open_idx);
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Switched to " + Buffers()[open_idx].Filename());
|
||||||
|
opened_any = true;
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
|
|
||||||
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
|
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
|
||||||
bool swp_exists = false;
|
bool swp_exists = false;
|
||||||
@@ -403,20 +515,59 @@ Editor::ProcessPendingOpens()
|
|||||||
} catch (...) {
|
} catch (...) {
|
||||||
swp_exists = false;
|
swp_exists = false;
|
||||||
}
|
}
|
||||||
|
if (swp_exists && kte::SwapManager::JournalInUse(swp)) {
|
||||||
|
// Another running kte is editing this file: its journal is live.
|
||||||
|
// Open without recovery and leave the journal alone; this
|
||||||
|
// session will not journal the file.
|
||||||
|
std::string err;
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus(req.path + " is open in another kte; this copy has no crash recovery");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (swp_exists && !kte::SwapManager::JournalMatchesFile(swp, req.path)) {
|
||||||
|
// The file changed (or was removed) after the journal started, so
|
||||||
|
// its position-based edits no longer apply.
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
|
||||||
|
pending_recovery_open_ = req;
|
||||||
|
pending_recovery_swap_path_ = swp;
|
||||||
|
pending_recovery_replay_err_ = "stale";
|
||||||
|
StartPrompt(PromptKind::Confirm, "Swap", "");
|
||||||
|
SetStatus("Swap file for " + req.path +
|
||||||
|
" predates changes to the file on disk and cannot be applied. Delete it? (y/N, C-g cancel)");
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
if (swp_exists) {
|
if (swp_exists) {
|
||||||
Buffer tmp;
|
Buffer tmp;
|
||||||
std::string oerr;
|
std::string oerr;
|
||||||
if (tmp.OpenFromFile(req.path, oerr)) {
|
if (tmp.OpenFromFile(req.path, oerr)) {
|
||||||
const std::string orig = buffer_bytes_via_views(tmp);
|
const std::string orig = tmp.Bytes();
|
||||||
std::string rerr;
|
std::string rerr;
|
||||||
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
|
std::uint64_t valid = 0;
|
||||||
const std::string rec = buffer_bytes_via_views(tmp);
|
if (kte::SwapManager::ReplayFile(tmp, swp, rerr, &valid)) {
|
||||||
if (rec != orig) {
|
std::string rec = tmp.Bytes();
|
||||||
|
if (rec == orig) {
|
||||||
|
// Nothing to recover. Remove the journal rather than
|
||||||
|
// appending this session's records to it (it may end in a
|
||||||
|
// torn record, which would make them unreplayable).
|
||||||
|
(void) std::remove(swp.c_str());
|
||||||
|
} else {
|
||||||
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
|
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
|
||||||
pending_recovery_open_ = req;
|
pending_recovery_open_ = req;
|
||||||
pending_recovery_swap_path_ = swp;
|
pending_recovery_swap_path_ = swp;
|
||||||
|
pending_recovery_content_ = std::move(rec);
|
||||||
|
pending_recovery_valid_bytes_ = valid;
|
||||||
|
pending_recovery_have_content_ = true;
|
||||||
|
try {
|
||||||
|
pending_recovery_swap_size_ = std::filesystem::file_size(swp);
|
||||||
|
} catch (...) {
|
||||||
|
pending_recovery_have_content_ = false; // replay on "y"
|
||||||
|
}
|
||||||
StartPrompt(PromptKind::Confirm, "Recover", "");
|
StartPrompt(PromptKind::Confirm, "Recover", "");
|
||||||
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)");
|
SetStatus("Recover swap edits for " + req.path + "? (y/n, C-g cancel)");
|
||||||
return opened_any;
|
return opened_any;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -459,25 +610,8 @@ 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()) {
|
if (b.SyntaxEnabled() && !(b.Highlighter() && b.Highlighter()->HasHighlighter()))
|
||||||
b.EnsureHighlighter();
|
b.InstallFiletypeHighlighter();
|
||||||
if (auto *eng = b.Highlighter()) {
|
|
||||||
if (!eng->HasHighlighter()) {
|
|
||||||
// Try to set based on existing filetype; fall back to NullHighlighter
|
|
||||||
if (!b.Filetype().empty()) {
|
|
||||||
auto hl = kte::HighlighterRegistry::CreateFor(b.Filetype());
|
|
||||||
if (hl) {
|
|
||||||
eng->SetHighlighter(std::move(hl));
|
|
||||||
} else {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,14 +623,28 @@ 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()) {
|
||||||
@@ -510,24 +658,20 @@ 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;
|
||||||
@@ -545,7 +689,7 @@ Editor::UArgStart()
|
|||||||
if (ucount_ == 0) {
|
if (ucount_ == 0) {
|
||||||
ucount_ = 1;
|
ucount_ = 1;
|
||||||
}
|
}
|
||||||
ucount_ *= 4;
|
ucount_ = std::min(ucount_ * 4, kMaxUArgCount); // no int overflow
|
||||||
}
|
}
|
||||||
uarg_ = 1;
|
uarg_ = 1;
|
||||||
char buf[64];
|
char buf[64];
|
||||||
@@ -565,7 +709,7 @@ Editor::UArgDigit(int d)
|
|||||||
uarg_ = 1;
|
uarg_ = 1;
|
||||||
ucount_ = 0;
|
ucount_ = 0;
|
||||||
}
|
}
|
||||||
ucount_ = ucount_ * 10 + d;
|
ucount_ = (ucount_ > (kMaxUArgCount - d) / 10) ? kMaxUArgCount : ucount_ * 10 + d;
|
||||||
char buf[64];
|
char buf[64];
|
||||||
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
||||||
SetStatus(buf);
|
SetStatus(buf);
|
||||||
|
|||||||
@@ -79,19 +79,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Mode and flags (mirroring legacy fields)
|
|
||||||
void SetMode(int m)
|
|
||||||
{
|
|
||||||
mode_ = m;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int Mode() const
|
|
||||||
{
|
|
||||||
return mode_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetKillChain(bool on)
|
void SetKillChain(bool on)
|
||||||
{
|
{
|
||||||
kill_ = on ? 1 : 0;
|
kill_ = on ? 1 : 0;
|
||||||
@@ -104,18 +91,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetNoKill(bool on)
|
|
||||||
{
|
|
||||||
no_kill_ = on ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool NoKill() const
|
|
||||||
{
|
|
||||||
return no_kill_ != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Kill ring API
|
// Kill ring API
|
||||||
void KillRingClear()
|
void KillRingClear()
|
||||||
{
|
{
|
||||||
@@ -164,18 +139,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetDirtyEx(int d)
|
|
||||||
{
|
|
||||||
dirtyex_ = d;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int DirtyEx() const
|
|
||||||
{
|
|
||||||
return dirtyex_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetUniversalArg(int uarg, int ucount)
|
void SetUniversalArg(int uarg, int ucount)
|
||||||
{
|
{
|
||||||
uarg_ = uarg;
|
uarg_ = uarg;
|
||||||
@@ -189,12 +152,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int UCount() const
|
|
||||||
{
|
|
||||||
return ucount_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Universal argument control (C-u) ---
|
// --- Universal argument control (C-u) ---
|
||||||
// Begin or extend a universal argument (like ke's uarg_start)
|
// Begin or extend a universal argument (like ke's uarg_start)
|
||||||
void UArgStart();
|
void UArgStart();
|
||||||
@@ -209,19 +166,6 @@ public:
|
|||||||
// If no universal argument active, returns 1.
|
// If no universal argument active, returns 1.
|
||||||
int UArgGet();
|
int UArgGet();
|
||||||
|
|
||||||
// Repeatable command flag: input layer can mark the next command as repeatable
|
|
||||||
void SetRepeatable(bool on)
|
|
||||||
{
|
|
||||||
repeatable_ = on;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool Repeatable() const
|
|
||||||
{
|
|
||||||
return repeatable_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Status message storage. Rendering is renderer-dependent; the editor
|
// Status message storage. Rendering is renderer-dependent; the editor
|
||||||
// merely stores the current message and its timestamp.
|
// merely stores the current message and its timestamp.
|
||||||
void SetStatus(const std::string &message);
|
void SetStatus(const std::string &message);
|
||||||
@@ -568,6 +512,11 @@ public:
|
|||||||
// Returns true if a file was opened during this call.
|
// Returns true if a file was opened during this call.
|
||||||
bool ProcessPendingOpens();
|
bool ProcessPendingOpens();
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool process_pending_opens_();
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
[[nodiscard]] bool HasPendingOpens() const;
|
[[nodiscard]] bool HasPendingOpens() const;
|
||||||
|
|
||||||
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
||||||
@@ -607,9 +556,18 @@ 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.
|
||||||
void SetSharedBuffers(std::vector<Buffer> *shared)
|
// Buffers hold recorders owned by the SwapManager that attached them, so
|
||||||
|
// editors sharing buffers must also share that manager (shared_swap);
|
||||||
|
// otherwise closing one window destroys recorders the other still uses.
|
||||||
|
// Index of the open buffer whose file is `path` (after the same
|
||||||
|
// normalization OpenFromFile applies), or size_t(-1) if none.
|
||||||
|
[[nodiscard]] std::size_t FindOpenBuffer(const std::string &path) const;
|
||||||
|
|
||||||
|
|
||||||
|
void SetSharedBuffers(std::vector<Buffer> *shared, kte::SwapManager *shared_swap = nullptr)
|
||||||
{
|
{
|
||||||
shared_buffers_ = shared;
|
shared_buffers_ = shared;
|
||||||
|
shared_swap_ = shared ? shared_swap : nullptr;
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,7 +575,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 swap_.get();
|
return shared_swap_ ? shared_swap_ : swap_.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -652,14 +610,10 @@ 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_
|
||||||
@@ -667,6 +621,7 @@ 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_;
|
||||||
@@ -702,6 +657,12 @@ private:
|
|||||||
PendingOpen pending_recovery_open_{};
|
PendingOpen pending_recovery_open_{};
|
||||||
std::string pending_recovery_swap_path_;
|
std::string pending_recovery_swap_path_;
|
||||||
std::string pending_recovery_replay_err_;
|
std::string pending_recovery_replay_err_;
|
||||||
|
// The recovered content the prompt offers, and how much of the journal
|
||||||
|
// produced it: "y" installs it instead of replaying the journal again.
|
||||||
|
std::string pending_recovery_content_;
|
||||||
|
std::uint64_t pending_recovery_valid_bytes_ = 0;
|
||||||
|
std::uint64_t pending_recovery_swap_size_ = 0; // the journal's size then
|
||||||
|
bool pending_recovery_have_content_ = false;
|
||||||
|
|
||||||
// GUI-only state (safe no-op in terminal builds)
|
// GUI-only state (safe no-op in terminal builds)
|
||||||
bool file_picker_visible_ = false;
|
bool file_picker_visible_ = false;
|
||||||
|
|||||||
+5
-3
@@ -73,13 +73,15 @@ CircuitBreaker::RecordFailure()
|
|||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lg(mtx_);
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
const auto now = std::chrono::steady_clock::now();
|
// The window is measured from the previous failure, so check it before
|
||||||
last_failure_time_ = now;
|
// recording this one.
|
||||||
|
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 (IsWindowExpired()) {
|
if (window_expired) {
|
||||||
failure_count_ = 0;
|
failure_count_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,11 +52,6 @@ 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
|
||||||
|
|||||||
+51
-8
@@ -1,3 +1,4 @@
|
|||||||
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
|
|
||||||
#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__)
|
||||||
@@ -27,6 +29,22 @@
|
|||||||
#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)
|
||||||
{
|
{
|
||||||
@@ -91,24 +109,30 @@ 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 (*v > 0) columns = static_cast<int>(*v);
|
if (valid_dimension(*v)) columns = static_cast<int>(*v);
|
||||||
}
|
}
|
||||||
if (auto v = (*win)["rows"].value<int64_t>()) {
|
if (auto v = (*win)["rows"].value<int64_t>()) {
|
||||||
if (*v > 0) rows = static_cast<int>(*v);
|
if (valid_dimension(*v)) rows = static_cast<int>(*v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [font]
|
// [font]
|
||||||
|
bool explicit_code_font = false;
|
||||||
|
bool explicit_writing_font = false;
|
||||||
if (auto sec = tbl["font"].as_table()) {
|
if (auto sec = tbl["font"].as_table()) {
|
||||||
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 (*v > 0.0) font_size = static_cast<float>(*v);
|
if (valid_font_size(*v)) 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;
|
||||||
if (auto v = (*sec)["writing"].value<std::string>())
|
explicit_code_font = true;
|
||||||
|
}
|
||||||
|
if (auto v = (*sec)["writing"].value<std::string>()) {
|
||||||
writing_font = *v;
|
writing_font = *v;
|
||||||
|
explicit_writing_font = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [appearance]
|
// [appearance]
|
||||||
@@ -131,6 +155,12 @@ GUIConfig::LoadFromTOML(const std::string &path)
|
|||||||
syntax = *v;
|
syntax = *v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default code_font to the main font if not explicitly set
|
||||||
|
if (!explicit_code_font)
|
||||||
|
code_font = font;
|
||||||
|
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
|
||||||
|
writing_font = font;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +172,9 @@ GUIConfig::LoadFromINI(const std::string &path)
|
|||||||
if (!in.good())
|
if (!in.good())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
bool explicit_code_font = false;
|
||||||
|
bool explicit_writing_font = false;
|
||||||
|
|
||||||
std::string line;
|
std::string line;
|
||||||
while (std::getline(in, line)) {
|
while (std::getline(in, line)) {
|
||||||
// Remove comments starting with '#' or ';'
|
// Remove comments starting with '#' or ';'
|
||||||
@@ -177,29 +210,31 @@ GUIConfig::LoadFromINI(const std::string &path)
|
|||||||
try {
|
try {
|
||||||
v = std::stoi(val);
|
v = std::stoi(val);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
if (v > 0)
|
if (valid_dimension(v))
|
||||||
columns = v;
|
columns = v;
|
||||||
} else if (key == "rows") {
|
} else if (key == "rows") {
|
||||||
int v = rows;
|
int v = rows;
|
||||||
try {
|
try {
|
||||||
v = std::stoi(val);
|
v = std::stoi(val);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
if (v > 0)
|
if (valid_dimension(v))
|
||||||
rows = v;
|
rows = v;
|
||||||
} else if (key == "font_size" || key == "fontsize") {
|
} else if (key == "font_size" || key == "fontsize") {
|
||||||
float v = font_size;
|
float v = font_size;
|
||||||
try {
|
try {
|
||||||
v = std::stof(val);
|
v = std::stof(val);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
if (v > 0.0f) {
|
if (valid_font_size(v)) {
|
||||||
font_size = v;
|
font_size = v;
|
||||||
}
|
}
|
||||||
} else if (key == "font") {
|
} else if (key == "font") {
|
||||||
font = val;
|
font = val;
|
||||||
} else if (key == "code_font") {
|
} else if (key == "code_font") {
|
||||||
code_font = val;
|
code_font = val;
|
||||||
|
explicit_code_font = true;
|
||||||
} else if (key == "writing_font") {
|
} else if (key == "writing_font") {
|
||||||
writing_font = val;
|
writing_font = val;
|
||||||
|
explicit_writing_font = true;
|
||||||
} else if (key == "theme") {
|
} else if (key == "theme") {
|
||||||
theme = val;
|
theme = val;
|
||||||
} else if (key == "background" || key == "bg") {
|
} else if (key == "background" || key == "bg") {
|
||||||
@@ -222,5 +257,13 @@ GUIConfig::LoadFromINI(const std::string &path)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If code_font was not explicitly set, default it to the main font
|
||||||
|
// so that the edit-mode font switcher doesn't immediately switch away
|
||||||
|
// from the font loaded during Init.
|
||||||
|
if (!explicit_code_font)
|
||||||
|
code_font = font;
|
||||||
|
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
|
||||||
|
writing_font = font;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
-292
@@ -8,296 +8,6 @@
|
|||||||
|
|
||||||
#include "Highlight.h"
|
#include "Highlight.h"
|
||||||
|
|
||||||
// Cross-frontend theme change request hook: declared here, defined in Command.cc
|
|
||||||
namespace kte {
|
|
||||||
extern bool gThemeChangePending;
|
|
||||||
extern std::string gThemeChangeRequest; // raw user-provided name
|
|
||||||
// Qt GUI: cross-frontend font change hooks and current font state
|
|
||||||
extern bool gFontChangePending;
|
|
||||||
extern std::string gFontFamilyRequest; // requested family (case-insensitive)
|
|
||||||
extern float gFontSizeRequest; // <= 0 means keep size
|
|
||||||
extern std::string gCurrentFontFamily; // last applied family (Qt)
|
|
||||||
extern float gCurrentFontSize; // last applied size (Qt)
|
|
||||||
// Qt GUI: request to show a visual font dialog (set by command handler)
|
|
||||||
extern bool gFontDialogRequested;
|
|
||||||
}
|
|
||||||
|
|
||||||
#if defined(KTE_USE_QT)
|
|
||||||
// Qt build: avoid hard dependency on ImGui headers/types.
|
|
||||||
// Provide a lightweight color vector matching ImVec4 fields used by renderers.
|
|
||||||
struct KteColor {
|
|
||||||
float x{0}, y{0}, z{0}, w{1};
|
|
||||||
};
|
|
||||||
|
|
||||||
static inline KteColor
|
|
||||||
RGBA(unsigned int rgb, float a = 1.0f)
|
|
||||||
{
|
|
||||||
const float r = static_cast<float>((rgb >> 16) & 0xFF) / 255.0f;
|
|
||||||
const float g = static_cast<float>((rgb >> 8) & 0xFF) / 255.0f;
|
|
||||||
const float b = static_cast<float>(rgb & 0xFF) / 255.0f;
|
|
||||||
return {r, g, b, a};
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
// Background mode selection for light/dark palettes
|
|
||||||
enum class BackgroundMode { Light, Dark };
|
|
||||||
|
|
||||||
// Global background mode; default to Dark to match prior defaults
|
|
||||||
static inline auto gBackgroundMode = BackgroundMode::Dark;
|
|
||||||
|
|
||||||
|
|
||||||
static inline void
|
|
||||||
SetBackgroundMode(const BackgroundMode m)
|
|
||||||
{
|
|
||||||
gBackgroundMode = m;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline BackgroundMode
|
|
||||||
GetBackgroundMode()
|
|
||||||
{
|
|
||||||
return gBackgroundMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Minimal GUI palette for Qt builds. This mirrors the defaults used in the ImGui
|
|
||||||
// frontend (Nord-ish) and switches for light/dark background mode.
|
|
||||||
struct Palette {
|
|
||||||
KteColor bg; // editor background
|
|
||||||
KteColor fg; // default foreground text
|
|
||||||
KteColor sel_bg; // selection background
|
|
||||||
KteColor cur_bg; // cursor cell background
|
|
||||||
KteColor status_bg; // status bar background
|
|
||||||
KteColor status_fg; // status bar foreground
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional theme override (Qt): when set, GetPalette() will return this instead
|
|
||||||
// of the generic light/dark defaults. This allows honoring theme names in kge.ini.
|
|
||||||
static inline bool gPaletteOverride = false;
|
|
||||||
static inline Palette gOverridePalette{};
|
|
||||||
static inline std::string gOverrideThemeName = ""; // lowercased name
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
GetPalette()
|
|
||||||
{
|
|
||||||
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
|
|
||||||
if (gPaletteOverride) {
|
|
||||||
return gOverridePalette;
|
|
||||||
}
|
|
||||||
if (dark) {
|
|
||||||
return Palette{
|
|
||||||
/*bg*/ RGBA(0x1C1C1E),
|
|
||||||
/*fg*/ RGBA(0xDCDCDC),
|
|
||||||
/*sel_bg*/ RGBA(0xC8C800, 0.35f),
|
|
||||||
/*cur_bg*/ RGBA(0xC8C8FF, 0.50f),
|
|
||||||
/*status_bg*/ RGBA(0x28282C),
|
|
||||||
/*status_fg*/ RGBA(0xB4B48C)
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Light palette tuned for readability
|
|
||||||
return Palette{
|
|
||||||
/*bg*/ RGBA(0xFBFBFC),
|
|
||||||
/*fg*/ RGBA(0x30343A),
|
|
||||||
/*sel_bg*/ RGBA(0x268BD2, 0.22f),
|
|
||||||
/*cur_bg*/ RGBA(0x000000, 0.15f),
|
|
||||||
/*status_bg*/ RGBA(0xE6E8EA),
|
|
||||||
/*status_fg*/ RGBA(0x50555A)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// A few named palettes to provide visible differences between themes in Qt.
|
|
||||||
// These are approximate and palette-based (no widget style changes like ImGuiStyle).
|
|
||||||
static inline Palette
|
|
||||||
NordDark()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0x2E3440), /*fg*/RGBA(0xD8DEE9), /*sel_bg*/RGBA(0x88C0D0, 0.25f),
|
|
||||||
/*cur_bg*/RGBA(0x81A1C1, 0.35f), /*status_bg*/RGBA(0x3B4252), /*status_fg*/RGBA(0xE5E9F0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
NordLight()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0xECEFF4), /*fg*/RGBA(0x2E3440), /*sel_bg*/RGBA(0x5E81AC, 0.22f),
|
|
||||||
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0xE5E9F0), /*status_fg*/RGBA(0x4C566A)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
SolarizedDark()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0x002b36), /*fg*/RGBA(0x93a1a1), /*sel_bg*/RGBA(0x586e75, 0.40f),
|
|
||||||
/*cur_bg*/RGBA(0x657b83, 0.35f), /*status_bg*/RGBA(0x073642), /*status_fg*/RGBA(0xeee8d5)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
SolarizedLight()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0xfdf6e3), /*fg*/RGBA(0x586e75), /*sel_bg*/RGBA(0x268bd2, 0.25f),
|
|
||||||
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xeee8d5), /*status_fg*/RGBA(0x657b83)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
GruvboxDark()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0x282828), /*fg*/RGBA(0xebdbb2), /*sel_bg*/RGBA(0xd79921, 0.35f),
|
|
||||||
/*cur_bg*/RGBA(0x458588, 0.40f), /*status_bg*/RGBA(0x3c3836), /*status_fg*/RGBA(0xd5c4a1)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
GruvboxLight()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0xfbf1c7), /*fg*/RGBA(0x3c3836), /*sel_bg*/RGBA(0x076678, 0.22f),
|
|
||||||
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xebdbb2), /*status_fg*/RGBA(0x504945)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
EInk()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0xffffff), /*fg*/RGBA(0x000000), /*sel_bg*/RGBA(0x000000, 0.10f),
|
|
||||||
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0x000000), /*status_fg*/RGBA(0xffffff)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Apply a Qt theme by name. Returns true on success. Name matching is case-insensitive and
|
|
||||||
// supports common aliases (e.g., "solarized-light" or "solarized light"). If the name conveys
|
|
||||||
// a background (light/dark), BackgroundMode is updated to keep SyntaxInk consistent.
|
|
||||||
static inline bool
|
|
||||||
ApplyQtThemeByName(std::string name)
|
|
||||||
{
|
|
||||||
// normalize
|
|
||||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) {
|
|
||||||
return (char) std::tolower(c);
|
|
||||||
});
|
|
||||||
auto has = [&](const std::string &s) {
|
|
||||||
return name.find(s) != std::string::npos;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (name.empty() || name == "default" || name == "nord") {
|
|
||||||
// Choose variant by current background mode
|
|
||||||
if (GetBackgroundMode() == BackgroundMode::Dark) {
|
|
||||||
gOverridePalette = NordDark();
|
|
||||||
} else {
|
|
||||||
gOverridePalette = NordLight();
|
|
||||||
}
|
|
||||||
gPaletteOverride = true;
|
|
||||||
gOverrideThemeName = "nord";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (has("solarized")) {
|
|
||||||
if (has("light")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Light);
|
|
||||||
gOverridePalette = SolarizedLight();
|
|
||||||
} else if (has("dark")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Dark);
|
|
||||||
gOverridePalette = SolarizedDark();
|
|
||||||
} else {
|
|
||||||
// pick from current background
|
|
||||||
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
|
|
||||||
? SolarizedDark()
|
|
||||||
: SolarizedLight();
|
|
||||||
}
|
|
||||||
gPaletteOverride = true;
|
|
||||||
gOverrideThemeName = "solarized";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (has("gruvbox")) {
|
|
||||||
if (has("light")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Light);
|
|
||||||
gOverridePalette = GruvboxLight();
|
|
||||||
} else if (has("dark")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Dark);
|
|
||||||
gOverridePalette = GruvboxDark();
|
|
||||||
} else {
|
|
||||||
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
|
|
||||||
? GruvboxDark()
|
|
||||||
: GruvboxLight();
|
|
||||||
}
|
|
||||||
gPaletteOverride = true;
|
|
||||||
gOverrideThemeName = "gruvbox";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (has("eink") || has("e-ink") || has("paper")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Light);
|
|
||||||
gOverridePalette = EInk();
|
|
||||||
gPaletteOverride = true;
|
|
||||||
gOverrideThemeName = "eink";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown -> clear override so default light/dark applies; return false.
|
|
||||||
gPaletteOverride = false;
|
|
||||||
gOverrideThemeName.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Minimal SyntaxInk mapping for Qt builds, returning KteColor
|
|
||||||
[[maybe_unused]] static KteColor
|
|
||||||
SyntaxInk(const TokenKind k)
|
|
||||||
{
|
|
||||||
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
|
|
||||||
const KteColor def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
|
|
||||||
switch (k) {
|
|
||||||
case TokenKind::Keyword:
|
|
||||||
return dark ? RGBA(0x81A1C1) : RGBA(0x5E81AC);
|
|
||||||
case TokenKind::Type:
|
|
||||||
return dark ? RGBA(0x8FBCBB) : RGBA(0x4C566A);
|
|
||||||
case TokenKind::String:
|
|
||||||
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
|
|
||||||
case TokenKind::Char:
|
|
||||||
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
|
|
||||||
case TokenKind::Comment:
|
|
||||||
return dark ? RGBA(0x616E88) : RGBA(0x7A869A);
|
|
||||||
case TokenKind::Number:
|
|
||||||
return dark ? RGBA(0xEBCB8B) : RGBA(0xB58900);
|
|
||||||
case TokenKind::Preproc:
|
|
||||||
return dark ? RGBA(0xD08770) : RGBA(0xAF3A03);
|
|
||||||
case TokenKind::Constant:
|
|
||||||
return dark ? RGBA(0xB48EAD) : RGBA(0x7B4B7F);
|
|
||||||
case TokenKind::Function:
|
|
||||||
return dark ? RGBA(0x88C0D0) : RGBA(0x3465A4);
|
|
||||||
case TokenKind::Operator:
|
|
||||||
return dark ? RGBA(0x2E3440) : RGBA(0x2E3440);
|
|
||||||
case TokenKind::Punctuation:
|
|
||||||
return dark ? RGBA(0xECEFF4) : RGBA(0x2E3440);
|
|
||||||
case TokenKind::Identifier:
|
|
||||||
return def;
|
|
||||||
case TokenKind::Whitespace:
|
|
||||||
return def;
|
|
||||||
case TokenKind::Error:
|
|
||||||
return dark ? RGBA(0xBF616A) : RGBA(0xCC0000);
|
|
||||||
case TokenKind::Default: default:
|
|
||||||
return def;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // namespace kte
|
|
||||||
|
|
||||||
#else
|
|
||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -1109,5 +819,3 @@ SyntaxInk(const TokenKind k)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|
||||||
#endif // KTE_USE_QT
|
|
||||||
|
|||||||
+42
@@ -1,7 +1,10 @@
|
|||||||
// Highlight.h - core syntax highlighting types for kte
|
// Highlight.h - core syntax highlighting types for kte
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
@@ -34,4 +37,43 @@ struct LineHighlight {
|
|||||||
std::vector<HighlightSpan> spans;
|
std::vector<HighlightSpan> spans;
|
||||||
std::uint64_t version{0}; // buffer version used for this line
|
std::uint64_t version{0}; // buffer version used for this line
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// A line's spans ready to draw: clamped to the line, ordered by start, not
|
||||||
|
// overlapping (the earlier span wins), and widened to whole UTF-8
|
||||||
|
// characters. Highlighters may emit one span per byte of a non-ASCII
|
||||||
|
// character; drawn separately, each byte showed as a replacement glyph.
|
||||||
|
inline void
|
||||||
|
SanitizeSpans(std::string_view line, const std::vector<HighlightSpan> &in, std::vector<HighlightSpan> &out)
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
const long len = static_cast<long>(line.size());
|
||||||
|
for (const HighlightSpan &sp: in) {
|
||||||
|
long s = std::clamp<long>(std::min(sp.col_start, sp.col_end), 0, len);
|
||||||
|
long e = std::clamp<long>(std::max(sp.col_start, sp.col_end), 0, len);
|
||||||
|
if (e > s)
|
||||||
|
out.push_back(HighlightSpan{static_cast<int>(s), static_cast<int>(e), sp.kind});
|
||||||
|
}
|
||||||
|
std::stable_sort(out.begin(), out.end(), [](const HighlightSpan &a, const HighlightSpan &b) {
|
||||||
|
return a.col_start < b.col_start;
|
||||||
|
});
|
||||||
|
auto continuation = [&](long i) {
|
||||||
|
return i < len && (static_cast<unsigned char>(line[static_cast<std::size_t>(i)]) & 0xC0) == 0x80;
|
||||||
|
};
|
||||||
|
std::size_t w = 0;
|
||||||
|
long prev_end = 0;
|
||||||
|
for (const HighlightSpan &sp: out) {
|
||||||
|
long s = sp.col_start, e = sp.col_end;
|
||||||
|
while (s > 0 && continuation(s))
|
||||||
|
--s;
|
||||||
|
while (e < len && continuation(e))
|
||||||
|
++e;
|
||||||
|
s = std::max(s, prev_end);
|
||||||
|
if (e <= s)
|
||||||
|
continue;
|
||||||
|
out[w++] = HighlightSpan{static_cast<int>(s), static_cast<int>(e), sp.kind};
|
||||||
|
prev_end = e;
|
||||||
|
}
|
||||||
|
out.resize(w);
|
||||||
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
+31
-30
@@ -19,8 +19,6 @@
|
|||||||
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
||||||
#include "fonts/FontRegistry.h"
|
#include "fonts/FontRegistry.h"
|
||||||
#include "fonts/IosevkaExtended.h"
|
#include "fonts/IosevkaExtended.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
|
||||||
#include "syntax/NullHighlighter.h"
|
|
||||||
|
|
||||||
|
|
||||||
#ifndef KTE_FONT_SIZE
|
#ifndef KTE_FONT_SIZE
|
||||||
@@ -39,32 +37,23 @@ apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
|
|||||||
if (!b)
|
if (!b)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Auto-detect edit mode from file extension
|
// Auto-detect edit mode from file extension once per buffer so that
|
||||||
if (!b->Filename().empty())
|
// manual toggles (C-k m / : mode) are not overridden every frame.
|
||||||
|
if (!b->EditModeDetected() && !b->Filename().empty())
|
||||||
b->SetEditMode(DetectEditMode(b->Filename()));
|
b->SetEditMode(DetectEditMode(b->Filename()));
|
||||||
|
|
||||||
if (cfg.syntax) {
|
// If the user explicitly set syntax state via a command (:syntax on/off,
|
||||||
|
// :set filetype=...), leave it alone - otherwise this runs every frame
|
||||||
|
// and silently undoes a manual ":syntax off" on the very next frame.
|
||||||
|
if (b->SyntaxUserOverride())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Writing mode disables syntax; otherwise follow the global config.
|
||||||
|
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
|
||||||
b->SetSyntaxEnabled(true);
|
b->SetSyntaxEnabled(true);
|
||||||
b->EnsureHighlighter();
|
b->EnsureHighlighter();
|
||||||
if (auto *eng = b->Highlighter()) {
|
if (!b->Highlighter()->HasHighlighter())
|
||||||
if (!eng->HasHighlighter()) {
|
b->ApplyDetectedFiletype();
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -76,7 +65,9 @@ static void
|
|||||||
update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
|
update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
|
||||||
{
|
{
|
||||||
float row_h = ImGui::GetTextLineHeightWithSpacing();
|
float row_h = ImGui::GetTextLineHeightWithSpacing();
|
||||||
float ch_w = ImGui::CalcTextSize("M").x;
|
// Use average character width rather than "M" (the widest character)
|
||||||
|
// so that column count is reasonable for proportional fonts too.
|
||||||
|
float ch_w = ImGui::CalcTextSize("abcdefghijklmnopqrstuvwxyz").x / 26.0f;
|
||||||
if (row_h <= 0.0f)
|
if (row_h <= 0.0f)
|
||||||
row_h = 16.0f;
|
row_h = 16.0f;
|
||||||
if (ch_w <= 0.0f)
|
if (ch_w <= 0.0f)
|
||||||
@@ -368,7 +359,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());
|
ws->editor.SetSharedBuffers(&primary.Buffers(), primary.Swap());
|
||||||
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.
|
||||||
@@ -465,9 +456,21 @@ 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;
|
||||||
|
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);
|
target->input.ProcessSDLEvent(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!running)
|
if (!running)
|
||||||
return;
|
return;
|
||||||
@@ -565,7 +568,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch font based on current buffer's edit mode
|
// Switch font based on current buffer's edit mode (deferred to next frame)
|
||||||
{
|
{
|
||||||
Buffer *cur = wed.CurrentBuffer();
|
Buffer *cur = wed.CurrentBuffer();
|
||||||
if (cur) {
|
if (cur) {
|
||||||
@@ -577,9 +580,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
if (fr.CurrentFontName() != expected && fr.HasFont(expected)) {
|
if (fr.CurrentFontName() != expected && fr.HasFont(expected)) {
|
||||||
float sz = fr.CurrentFontSize();
|
float sz = fr.CurrentFontSize();
|
||||||
if (sz <= 0.0f) sz = config_.font_size;
|
if (sz <= 0.0f) sz = config_.font_size;
|
||||||
fr.LoadFont(expected, sz);
|
fr.RequestLoadFont(expected, sz);
|
||||||
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
|
||||||
ImGui_ImplOpenGL3_CreateFontsTexture();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-30
@@ -8,6 +8,20 @@
|
|||||||
#include "ImGuiInputHandler.h"
|
#include "ImGuiInputHandler.h"
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
|
#include "PasteSplit.h"
|
||||||
|
|
||||||
|
// Verbose k-prefix suffix logging for debugging macOS/SDL key translation
|
||||||
|
// issues. Default to off; enable by defining IMGUI_IH_DEBUG=1 at compile
|
||||||
|
// time.
|
||||||
|
#ifndef IMGUI_IH_DEBUG
|
||||||
|
#define IMGUI_IH_DEBUG 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if IMGUI_IH_DEBUG
|
||||||
|
#define IH_LOGF(...) do { std::fprintf(stderr, __VA_ARGS__); std::fflush(stderr); } while (0)
|
||||||
|
#else
|
||||||
|
#define IH_LOGF(...) ((void) 0)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
static bool
|
static bool
|
||||||
@@ -143,6 +157,24 @@ map_key(const SDL_Keycode key,
|
|||||||
|
|
||||||
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
|
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
|
||||||
if (k_prefix) {
|
if (k_prefix) {
|
||||||
|
// A modifier pressed on its own (Shift for C-k C or C-k ^, Ctrl
|
||||||
|
// pressed again for C-k C-d) is not the suffix: keep waiting.
|
||||||
|
switch (key) {
|
||||||
|
case SDLK_LSHIFT:
|
||||||
|
case SDLK_RSHIFT:
|
||||||
|
case SDLK_LCTRL:
|
||||||
|
case SDLK_RCTRL:
|
||||||
|
case SDLK_LALT:
|
||||||
|
case SDLK_RALT:
|
||||||
|
case SDLK_LGUI:
|
||||||
|
case SDLK_RGUI:
|
||||||
|
case SDLK_CAPSLOCK:
|
||||||
|
case SDLK_MODE:
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
esc_meta = false;
|
esc_meta = false;
|
||||||
// Normalize to ASCII; preserve case for letters using Shift
|
// Normalize to ASCII; preserve case for letters using Shift
|
||||||
int ascii_key = 0;
|
int ascii_key = 0;
|
||||||
@@ -182,18 +214,19 @@ map_key(const SDL_Keycode key,
|
|||||||
k_ctrl_pending = false;
|
k_ctrl_pending = false;
|
||||||
CommandId id;
|
CommandId id;
|
||||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||||
|
#if IMGUI_IH_DEBUG
|
||||||
// Diagnostics for u/U
|
// Diagnostics for u/U
|
||||||
if (lower == 'u') {
|
if (lower == 'u') {
|
||||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||||
? static_cast<char>(ascii_key)
|
? static_cast<char>(ascii_key)
|
||||||
: '?';
|
: '?';
|
||||||
std::fprintf(stderr,
|
IH_LOGF(
|
||||||
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
||||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||||
mapped ? static_cast<int>(id) : -1);
|
mapped ? static_cast<int>(id) : -1);
|
||||||
std::fflush(stderr);
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
out = {true, id, "", 0};
|
out = {true, id, "", 0};
|
||||||
if (ed)
|
if (ed)
|
||||||
@@ -331,6 +364,11 @@ 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_;
|
||||||
@@ -376,32 +414,15 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
if (clip) {
|
if (clip) {
|
||||||
std::string text(clip);
|
std::string text(clip);
|
||||||
SDL_free(clip);
|
SDL_free(clip);
|
||||||
// Split on '\n' and enqueue as InsertText/Newline commands
|
// Turn line breaks (\n, \r\n, or bare \r) into Newline
|
||||||
|
// commands and the rest into InsertText; InsertText itself
|
||||||
|
// rejects any embedded '\r'/'\n'.
|
||||||
|
// Into a prompt, line breaks must not become Newline: that
|
||||||
|
// would accept the prompt and send the rest to the buffer.
|
||||||
|
const bool to_prompt = ed_ && ed_->PromptActive();
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
std::size_t start = 0;
|
for (const auto &cmd: to_prompt ? SplitPasteForPrompt(text) : SplitPasteIntoCommands(text))
|
||||||
while (start <= text.size()) {
|
q_.push(cmd);
|
||||||
std::size_t pos = text.find('\n', start);
|
|
||||||
std::string_view segment;
|
|
||||||
bool has_nl = (pos != std::string::npos);
|
|
||||||
if (has_nl) {
|
|
||||||
segment = std::string_view(text).substr(start, pos - start);
|
|
||||||
} else {
|
|
||||||
segment = std::string_view(text).substr(start);
|
|
||||||
}
|
|
||||||
if (!segment.empty()) {
|
|
||||||
MappedInput ins{
|
|
||||||
true, CommandId::InsertText, std::string(segment), 0
|
|
||||||
};
|
|
||||||
q_.push(ins);
|
|
||||||
}
|
|
||||||
if (has_nl) {
|
|
||||||
MappedInput nl{true, CommandId::Newline, std::string(), 0};
|
|
||||||
q_.push(nl);
|
|
||||||
start = pos + 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Suppress the corresponding TEXTINPUT that may follow
|
// Suppress the corresponding TEXTINPUT that may follow
|
||||||
suppress_text_input_once_ = true;
|
suppress_text_input_once_ = true;
|
||||||
return true; // consumed
|
return true; // consumed
|
||||||
@@ -524,15 +545,17 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
bool pass_ctrl = k_ctrl_pending_;
|
bool pass_ctrl = k_ctrl_pending_;
|
||||||
k_ctrl_pending_ = false;
|
k_ctrl_pending_ = false;
|
||||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||||
|
#if IMGUI_IH_DEBUG
|
||||||
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
||||||
|
{
|
||||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||||
? static_cast<char>(ascii_key)
|
? static_cast<char>(ascii_key)
|
||||||
: '?';
|
: '?';
|
||||||
std::fprintf(stderr,
|
IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||||
"[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
|
||||||
ascii_key, disp, mapped ? 1 : 0,
|
ascii_key, disp, mapped ? 1 : 0,
|
||||||
mapped ? static_cast<int>(id) : -1);
|
mapped ? static_cast<int>(id) : -1);
|
||||||
std::fflush(stderr);
|
}
|
||||||
|
#endif
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
mi = {true, id, "", 0};
|
mi = {true, id, "", 0};
|
||||||
if (ed_)
|
if (ed_)
|
||||||
|
|||||||
+151
-153
@@ -7,9 +7,10 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
#include <regex>
|
|
||||||
|
|
||||||
#include "ImGuiRenderer.h"
|
#include "ImGuiRenderer.h"
|
||||||
|
#include "SearchHighlight.h"
|
||||||
|
#include "TermWidth.h"
|
||||||
#include "Highlight.h"
|
#include "Highlight.h"
|
||||||
#include "GUITheme.h"
|
#include "GUITheme.h"
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
@@ -66,7 +67,9 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
if (!buf) {
|
if (!buf) {
|
||||||
ImGui::TextUnformatted("[no buffer]");
|
ImGui::TextUnformatted("[no buffer]");
|
||||||
} else {
|
} else {
|
||||||
const auto &lines = buf->Rows();
|
// Per-row access only: Rows() rebuilds a string for every line of the
|
||||||
|
// file after each edit, which made every frame O(file size).
|
||||||
|
const std::size_t nlines = buf->Nrows();
|
||||||
std::size_t cy = buf->Cury();
|
std::size_t cy = buf->Cury();
|
||||||
std::size_t cx = buf->Curx();
|
std::size_t cx = buf->Curx();
|
||||||
const float line_h = ImGui::GetTextLineHeight();
|
const float line_h = ImGui::GetTextLineHeight();
|
||||||
@@ -85,11 +88,9 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
||||||
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
|
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
|
||||||
}
|
}
|
||||||
if (prev_buf_coloffs_ >= 0 && buf_coloffs != prev_buf_coloffs_) {
|
// Horizontal scroll is handled purely in pixel space (see
|
||||||
float target_x = static_cast<float>(buf_coloffs) * space_w;
|
// cursor-visibility block after the line loop) so we don't
|
||||||
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
// convert the character-based coloffs to an ImGui scroll here.
|
||||||
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reserve space for status bar at bottom.
|
// Reserve space for status bar at bottom.
|
||||||
// We calculate a height that is an exact multiple of the line height
|
// We calculate a height that is an exact multiple of the line height
|
||||||
@@ -108,32 +109,26 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImVec2 child_window_pos = ImGui::GetWindowPos();
|
ImVec2 child_window_pos = ImGui::GetWindowPos();
|
||||||
float scroll_y = ImGui::GetScrollY();
|
float scroll_y = ImGui::GetScrollY();
|
||||||
float scroll_x = ImGui::GetScrollX();
|
float scroll_x = ImGui::GetScrollX();
|
||||||
std::size_t rowoffs = 0; // we render from the first line; scrolling is handled by ImGui
|
|
||||||
|
|
||||||
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
|
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
|
||||||
bool forced_scroll = false;
|
bool forced_scroll = false;
|
||||||
{
|
{
|
||||||
const long scroll_top = static_cast<long>(scroll_y / row_h);
|
const long scroll_top = static_cast<long>(scroll_y / row_h);
|
||||||
const long scroll_left = static_cast<long>(scroll_x / space_w);
|
|
||||||
|
|
||||||
// Check if rowoffs was programmatically changed this frame
|
// Check if rowoffs was programmatically changed this frame
|
||||||
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
|
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
|
||||||
forced_scroll = true;
|
forced_scroll = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user scrolled (not programmatic), update buffer offsets accordingly
|
// If user scrolled vertically (not programmatic), update buffer row offset
|
||||||
if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
|
if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
|
||||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||||
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
|
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
|
||||||
mbuf->Coloffs());
|
mbuf->Coloffs());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (prev_scroll_x_ >= 0.0f && scroll_x != prev_scroll_x_ && !forced_scroll) {
|
// Horizontal scroll is pixel-based and managed by the cursor
|
||||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
// visibility block below; we don't sync it back to coloffs.
|
||||||
mbuf->SetOffsets(mbuf->Rowoffs(),
|
|
||||||
static_cast<std::size_t>(std::max(0L, scroll_left)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update trackers for next frame
|
// Update trackers for next frame
|
||||||
prev_scroll_y_ = scroll_y;
|
prev_scroll_y_ = scroll_y;
|
||||||
@@ -141,8 +136,54 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
prev_buf_rowoffs_ = buf_rowoffs;
|
prev_buf_rowoffs_ = buf_rowoffs;
|
||||||
prev_buf_coloffs_ = buf_coloffs;
|
prev_buf_coloffs_ = buf_coloffs;
|
||||||
// Cache current horizontal offset in rendered columns for click handling
|
|
||||||
const std::size_t coloffs_now = buf->Coloffs();
|
// Max-line-width cache: reset when the buffer or font changes. We only
|
||||||
|
// measure visible lines per frame and maintain a running max, so the
|
||||||
|
// scrollbar may be slightly conservative on first view of a file until
|
||||||
|
// the user scrolls, but idle CPU drops dramatically on large files.
|
||||||
|
// Edits bump the buffer version but we intentionally do NOT reset the
|
||||||
|
// max on version changes — keeping a conservative (possibly too-wide)
|
||||||
|
// scrollbar is preferable to per-keystroke jitter.
|
||||||
|
{
|
||||||
|
ImFont *cur_font = ImGui::GetFont();
|
||||||
|
float cur_fsize = ImGui::GetFontSize();
|
||||||
|
if (buf != max_width_buf_
|
||||||
|
|| cur_font != max_width_font_
|
||||||
|
|| cur_fsize != max_width_font_size_) {
|
||||||
|
max_width_buf_ = buf;
|
||||||
|
max_width_font_ = cur_font;
|
||||||
|
max_width_font_size_ = cur_fsize;
|
||||||
|
max_width_px_ = 0.0f;
|
||||||
|
}
|
||||||
|
max_width_version_ = buf->Version();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search matches: the pattern is compiled once per frame (compiling it
|
||||||
|
// per line per frame was a large source of idle CPU on macOS).
|
||||||
|
const kte::SearchHighlight search_hl(ed);
|
||||||
|
const bool search_mode = search_hl.Active();
|
||||||
|
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||||
|
|
||||||
|
// Compute the visible row range and skip rendering work for off-screen
|
||||||
|
// lines. ImGui clips drawing, but string allocation, tab expansion,
|
||||||
|
// syntax-highlight lookups, and width measurement are not free.
|
||||||
|
const std::size_t total_rows = nlines;
|
||||||
|
std::size_t first_vis = 0;
|
||||||
|
std::size_t last_vis = total_rows; // exclusive
|
||||||
|
if (row_h > 0.0f && total_rows > 0) {
|
||||||
|
const long margin = 4; // render a few extra rows above/below for smoother scrolling
|
||||||
|
long fv = static_cast<long>(std::floor(scroll_y / row_h)) - margin;
|
||||||
|
long lv = static_cast<long>(
|
||||||
|
std::ceil((scroll_y + child_h_plan) / row_h)) + margin;
|
||||||
|
if (fv < 0) fv = 0;
|
||||||
|
if (lv < 0) lv = 0;
|
||||||
|
if (static_cast<std::size_t>(fv) > total_rows)
|
||||||
|
fv = static_cast<long>(total_rows);
|
||||||
|
if (static_cast<std::size_t>(lv) > total_rows)
|
||||||
|
lv = static_cast<long>(total_rows);
|
||||||
|
first_vis = static_cast<std::size_t>(fv);
|
||||||
|
last_vis = static_cast<std::size_t>(lv);
|
||||||
|
}
|
||||||
|
|
||||||
// Mark selection state (mark -> cursor), in source coordinates
|
// Mark selection state (mark -> cursor), in source coordinates
|
||||||
bool sel_active = false;
|
bool sel_active = false;
|
||||||
@@ -172,15 +213,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 >= lines.size())
|
if (by >= nlines)
|
||||||
by = lines.empty() ? 0 : (lines.size() - 1);
|
by = (nlines == 0) ? 0 : (nlines - 1);
|
||||||
|
|
||||||
if (lines.empty())
|
if (nlines == 0)
|
||||||
return {0, 0};
|
return {0, 0};
|
||||||
|
|
||||||
// Expand tabs for the clicked line
|
// Expand tabs for the clicked line
|
||||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
std::string line_clicked = buf->GetLineString(by);
|
||||||
const std::size_t tabw = 8;
|
const std::size_t tabw = kte::kTabWidth;
|
||||||
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;
|
||||||
@@ -264,83 +305,63 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
||||||
mouse_selecting_ = false;
|
mouse_selecting_ = false;
|
||||||
}
|
}
|
||||||
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
// Advance the cursor Y so the first visible line draws at its correct
|
||||||
|
// scroll position. Skipped rows simply leave the layout cursor untouched.
|
||||||
|
if (first_vis > 0) {
|
||||||
|
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
|
||||||
|
static_cast<float>(first_vis) * row_h);
|
||||||
|
}
|
||||||
|
for (std::size_t i = first_vis; i < last_vis; ++i) {
|
||||||
// Capture the screen position before drawing the line
|
// Capture the screen position before drawing the line
|
||||||
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
||||||
std::string line = static_cast<std::string>(lines[i]);
|
std::string line = buf->GetLineString(i);
|
||||||
|
|
||||||
// Expand tabs to spaces with width=8
|
// Expand tabs to spaces with width=8, recording each source
|
||||||
const std::size_t tabw = 8;
|
// byte's rendered column (rx_of[line.size()] is the line's width).
|
||||||
|
const std::size_t tabw = kte::kTabWidth;
|
||||||
std::string expanded;
|
std::string expanded;
|
||||||
expanded.reserve(line.size() + 16);
|
expanded.reserve(line.size() + 16);
|
||||||
std::size_t rx_abs_draw = 0;
|
std::vector<std::size_t> rx_of(line.size() + 1);
|
||||||
for (std::size_t src = 0; src < line.size(); ++src) {
|
for (std::size_t src = 0; src < line.size(); ++src) {
|
||||||
char c = line[src];
|
rx_of[src] = expanded.size();
|
||||||
if (c == '\t') {
|
if (line[src] == '\t')
|
||||||
std::size_t adv = (tabw - (rx_abs_draw % tabw));
|
expanded.append(tabw - (expanded.size() % tabw), ' ');
|
||||||
expanded.append(adv, ' ');
|
|
||||||
rx_abs_draw += adv;
|
|
||||||
} else {
|
|
||||||
expanded.push_back(c);
|
|
||||||
rx_abs_draw += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: convert a rendered column position to pixel x offset
|
|
||||||
// relative to the visible line start, using actual text measurement
|
|
||||||
// so proportional fonts render correctly.
|
|
||||||
auto rx_to_px = [&](std::size_t rx_col) -> float {
|
|
||||||
if (rx_col <= coloffs_now)
|
|
||||||
return 0.0f;
|
|
||||||
std::size_t start = coloffs_now;
|
|
||||||
std::size_t end = std::min(expanded.size(), rx_col);
|
|
||||||
if (start >= expanded.size() || end <= start)
|
|
||||||
return 0.0f;
|
|
||||||
return ImGui::CalcTextSize(expanded.c_str() + start,
|
|
||||||
expanded.c_str() + end).x;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compute search highlight ranges for this line in source indices
|
|
||||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
|
||||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
|
||||||
if (search_mode) {
|
|
||||||
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
|
|
||||||
if (ed.PromptActive() && (
|
|
||||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
|
||||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
|
||||||
try {
|
|
||||||
std::regex rx(ed.SearchQuery());
|
|
||||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
|
|
||||||
it != std::sregex_iterator(); ++it) {
|
|
||||||
const auto &m = *it;
|
|
||||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
|
||||||
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
|
||||||
hl_src_ranges.emplace_back(sx, ex);
|
|
||||||
}
|
|
||||||
} catch (const std::regex_error &) {
|
|
||||||
// ignore invalid patterns here; status line already shows the error
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const std::string &q = ed.SearchQuery();
|
|
||||||
std::size_t pos = 0;
|
|
||||||
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
|
|
||||||
hl_src_ranges.emplace_back(pos, pos + q.size());
|
|
||||||
pos += q.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
|
|
||||||
std::size_t rx = 0;
|
|
||||||
std::size_t s = 0;
|
|
||||||
while (s < upto_src_exclusive && s < line.size()) {
|
|
||||||
if (line[s] == '\t')
|
|
||||||
rx += (tabw - (rx % tabw));
|
|
||||||
else
|
else
|
||||||
rx += 1;
|
expanded.push_back(line[src]);
|
||||||
++s;
|
|
||||||
}
|
}
|
||||||
return rx;
|
rx_of[line.size()] = expanded.size();
|
||||||
|
auto src_to_rx = [&](std::size_t src) -> std::size_t {
|
||||||
|
return rx_of[std::min(src, line.size())];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rendered column -> pixel offset from the start of the line.
|
||||||
|
// Measuring from column 0 for every span was quadratic in line
|
||||||
|
// length; measure on from the nearest earlier position already
|
||||||
|
// measured (only character boundaries are kept, so a multi-byte
|
||||||
|
// character is never measured in pieces).
|
||||||
|
std::vector<std::pair<std::size_t, float> > px_marks{{0, 0.0f}}; // unrounded
|
||||||
|
auto rx_to_px = [&](std::size_t rx_col) -> float {
|
||||||
|
const std::size_t end = std::min(expanded.size(), rx_col);
|
||||||
|
auto it = std::upper_bound(px_marks.begin(), px_marks.end(), end,
|
||||||
|
[](std::size_t v, const std::pair<std::size_t, float> &m) {
|
||||||
|
return v < m.first;
|
||||||
|
});
|
||||||
|
--it; // px_marks[0] is column 0
|
||||||
|
float px = it->second;
|
||||||
|
if (it->first != end) {
|
||||||
|
px += ImGui::GetFont()->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f,
|
||||||
|
expanded.c_str() + it->first,
|
||||||
|
expanded.c_str() + end).x;
|
||||||
|
if (end == expanded.size() || (static_cast<unsigned char>(expanded[end]) & 0xC0) != 0x80)
|
||||||
|
px_marks.insert(it + 1, {end, px});
|
||||||
|
}
|
||||||
|
// Rounded up as ImGui::CalcTextSize does (sums of rounded
|
||||||
|
// pieces would drift).
|
||||||
|
return end == 0 ? 0.0f : static_cast<float>(static_cast<int>(px + 0.99999f));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search highlight ranges for this line in source indices
|
||||||
|
search_hl.Ranges(line, hl_src_ranges);
|
||||||
// Draw background highlights (under text)
|
// Draw background highlights (under text)
|
||||||
if (search_mode && !hl_src_ranges.empty()) {
|
if (search_mode && !hl_src_ranges.empty()) {
|
||||||
// Current match emphasis
|
// Current match emphasis
|
||||||
@@ -351,9 +372,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
std::size_t sx = rg.first, ex = rg.second;
|
std::size_t sx = rg.first, ex = rg.second;
|
||||||
std::size_t rx_start = src_to_rx(sx);
|
std::size_t rx_start = src_to_rx(sx);
|
||||||
std::size_t rx_end = src_to_rx(ex);
|
std::size_t rx_end = src_to_rx(ex);
|
||||||
// Apply horizontal scroll offset
|
|
||||||
if (rx_end <= coloffs_now)
|
|
||||||
continue; // fully left of view
|
|
||||||
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y);
|
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y);
|
||||||
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||||
line_pos.y + line_h);
|
line_pos.y + line_h);
|
||||||
@@ -392,7 +410,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
if (line_has) {
|
if (line_has) {
|
||||||
std::size_t rx_start = src_to_rx(sx);
|
std::size_t rx_start = src_to_rx(sx);
|
||||||
std::size_t rx_end = src_to_rx(ex);
|
std::size_t rx_end = src_to_rx(ex);
|
||||||
if (rx_end > coloffs_now) {
|
|
||||||
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
||||||
line_pos.y);
|
line_pos.y);
|
||||||
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||||
@@ -401,7 +418,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
||||||
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
|
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
|
||||||
const std::size_t spot_sx = std::min(buf->Curx(), line.size());
|
const std::size_t spot_sx = std::min(buf->Curx(), line.size());
|
||||||
@@ -413,7 +429,6 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
// EOL spot: draw a 1-cell highlight just past the last character.
|
// EOL spot: draw a 1-cell highlight just past the last character.
|
||||||
rx_end = rx_start + 1;
|
rx_end = rx_start + 1;
|
||||||
}
|
}
|
||||||
if (rx_end > coloffs_now) {
|
|
||||||
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
||||||
line_pos.y);
|
line_pos.y);
|
||||||
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||||
@@ -421,60 +436,23 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Draw syntax-colored runs (text above background highlights)
|
// Draw syntax-colored runs (text above background highlights)
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||||
kte::LineHighlight lh = buf->Highlighter()->GetLine(
|
const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
|
||||||
*buf, static_cast<int>(i), buf->Version());
|
*buf, static_cast<int>(i), buf->Version());
|
||||||
// Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties
|
std::vector<kte::HighlightSpan> spans; // clamped, ordered, whole characters
|
||||||
struct SSpan {
|
kte::SanitizeSpans(line, lh.spans, spans);
|
||||||
std::size_t s;
|
|
||||||
std::size_t e;
|
|
||||||
kte::TokenKind k;
|
|
||||||
};
|
|
||||||
std::vector<SSpan> spans;
|
|
||||||
spans.reserve(lh.spans.size());
|
|
||||||
const std::size_t line_len = line.size();
|
|
||||||
for (const auto &sp: lh.spans) {
|
|
||||||
int s_raw = sp.col_start;
|
|
||||||
int e_raw = sp.col_end;
|
|
||||||
if (e_raw < s_raw)
|
|
||||||
std::swap(e_raw, s_raw);
|
|
||||||
std::size_t s = static_cast<std::size_t>(std::max(
|
|
||||||
0, std::min(s_raw, static_cast<int>(line_len))));
|
|
||||||
std::size_t e = static_cast<std::size_t>(std::max(
|
|
||||||
static_cast<int>(s), std::min(e_raw, static_cast<int>(line_len))));
|
|
||||||
if (e <= s)
|
|
||||||
continue;
|
|
||||||
spans.push_back(SSpan{s, e, sp.kind});
|
|
||||||
}
|
|
||||||
std::sort(spans.begin(), spans.end(), [](const SSpan &a, const SSpan &b) {
|
|
||||||
return a.s < b.s;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to convert a src column to expanded rx position
|
|
||||||
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
|
|
||||||
std::size_t rx = 0;
|
|
||||||
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
|
|
||||||
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
|
|
||||||
}
|
|
||||||
return rx;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const auto &sp: spans) {
|
for (const auto &sp: spans) {
|
||||||
std::size_t rx_s = src_to_rx_full(sp.s);
|
std::size_t rx_s = src_to_rx(static_cast<std::size_t>(sp.col_start));
|
||||||
std::size_t rx_e = src_to_rx_full(sp.e);
|
std::size_t rx_e = src_to_rx(static_cast<std::size_t>(sp.col_end));
|
||||||
if (rx_e <= coloffs_now)
|
std::size_t draw_start = rx_s;
|
||||||
continue; // fully left of viewport
|
|
||||||
// Clamp to visible portion and expanded length
|
|
||||||
std::size_t draw_start = (rx_s > coloffs_now) ? rx_s : coloffs_now;
|
|
||||||
if (draw_start >= expanded.size())
|
if (draw_start >= expanded.size())
|
||||||
continue; // fully right of expanded text
|
continue;
|
||||||
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
|
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
|
||||||
if (draw_end <= draw_start)
|
if (draw_end <= draw_start)
|
||||||
continue;
|
continue;
|
||||||
// Screen position via actual text measurement
|
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(
|
||||||
@@ -484,17 +462,14 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
// Use row_h (with spacing) to match click calculation and ensure consistent line positions.
|
// Use row_h (with spacing) to match click calculation and ensure consistent line positions.
|
||||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||||
} else {
|
} else {
|
||||||
// No syntax: draw as one run, accounting for horizontal scroll offset
|
// No syntax: draw the full line; ImGui scroll handles clipping.
|
||||||
if (coloffs_now < expanded.size()) {
|
if (!expanded.empty()) {
|
||||||
ImVec2 p = ImVec2(line_pos.x, line_pos.y);
|
ImVec2 p = ImVec2(line_pos.x, line_pos.y);
|
||||||
ImGui::GetWindowDrawList()->AddText(
|
ImGui::GetWindowDrawList()->AddText(
|
||||||
p, ImGui::GetColorU32(ImGuiCol_Text),
|
p, ImGui::GetColorU32(ImGuiCol_Text),
|
||||||
expanded.c_str() + coloffs_now);
|
expanded.c_str());
|
||||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
|
||||||
} else {
|
|
||||||
// Line is fully scrolled out of view horizontally
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
|
||||||
}
|
}
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw a visible cursor indicator on the current line
|
// Draw a visible cursor indicator on the current line
|
||||||
@@ -506,7 +481,30 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
|
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
|
||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track widest line for content width reporting. We only measure
|
||||||
|
// visible lines and fold the result into a monotonic max cached on
|
||||||
|
// the renderer (see max_width_* members). Off-screen lines are not
|
||||||
|
// measured per frame; if the user scrolls or edits, the cache is
|
||||||
|
// refreshed accordingly.
|
||||||
|
if (!expanded.empty()) {
|
||||||
|
const float line_w = rx_to_px(expanded.size());
|
||||||
|
if (line_w > max_width_px_)
|
||||||
|
max_width_px_ = line_w;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// After the visible-range loop, advance the layout cursor to the end of
|
||||||
|
// the (virtual) content so ImGui sees the correct total size. Vertical
|
||||||
|
// height comes from total_rows; horizontal width comes from the cached
|
||||||
|
// max line width. A Dummy at the final position records both.
|
||||||
|
if (total_rows > last_vis) {
|
||||||
|
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
|
||||||
|
static_cast<float>(total_rows - last_vis) * row_h);
|
||||||
|
}
|
||||||
|
if (max_width_px_ > 0.0f) {
|
||||||
|
ImGui::SetCursorPosX(max_width_px_);
|
||||||
|
}
|
||||||
|
ImGui::Dummy(ImVec2(0, 0));
|
||||||
// Synchronize cursor and scrolling after rendering all lines so content size is known.
|
// Synchronize cursor and scrolling after rendering all lines so content size is known.
|
||||||
{
|
{
|
||||||
float child_h_actual = ImGui::GetWindowHeight();
|
float child_h_actual = ImGui::GetWindowHeight();
|
||||||
@@ -548,9 +546,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 < lines.size()) {
|
if (cy < nlines) {
|
||||||
std::string cur_line = static_cast<std::string>(lines[cy]);
|
std::string cur_line = buf->GetLineString(cy);
|
||||||
const std::size_t tabw = 8;
|
const std::size_t tabw = kte::kTabWidth;
|
||||||
// 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);
|
||||||
@@ -732,7 +730,7 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
left += " *";
|
left += " *";
|
||||||
// Append total line count as "<n>L"
|
// Append total line count as "<n>L"
|
||||||
{
|
{
|
||||||
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
|
unsigned long lcount = static_cast<unsigned long>(buf->Nrows());
|
||||||
left += " ";
|
left += " ";
|
||||||
left += std::to_string(lcount);
|
left += std::to_string(lcount);
|
||||||
left += "L";
|
left += "L";
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
#include "Renderer.h"
|
#include "Renderer.h"
|
||||||
|
|
||||||
|
struct ImFont;
|
||||||
|
class Buffer;
|
||||||
|
|
||||||
class ImGuiRenderer final : public Renderer {
|
class ImGuiRenderer final : public Renderer {
|
||||||
public:
|
public:
|
||||||
ImGuiRenderer() = default;
|
ImGuiRenderer() = default;
|
||||||
@@ -20,4 +25,13 @@ private:
|
|||||||
float prev_scroll_y_ = -1.0f;
|
float prev_scroll_y_ = -1.0f;
|
||||||
float prev_scroll_x_ = -1.0f;
|
float prev_scroll_x_ = -1.0f;
|
||||||
bool mouse_selecting_ = false;
|
bool mouse_selecting_ = false;
|
||||||
|
|
||||||
|
// Max-line-width cache for the horizontal scrollbar. Measuring every line
|
||||||
|
// every frame is prohibitively expensive on large files; we only update the
|
||||||
|
// running max from visible lines and reset when buffer/version/font changes.
|
||||||
|
const Buffer *max_width_buf_ = nullptr;
|
||||||
|
std::uint64_t max_width_version_ = 0;
|
||||||
|
ImFont *max_width_font_ = nullptr;
|
||||||
|
float max_width_font_size_ = 0.0f;
|
||||||
|
float max_width_px_ = 0.0f;
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-5
@@ -1,6 +1,4 @@
|
|||||||
#include <iostream>
|
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
#include <ostream>
|
|
||||||
|
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
|
|
||||||
@@ -40,6 +38,15 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (ascii_key == 'E') {
|
||||||
|
// Explicitly rejected and kept distinct from 'e' (OpenFileStart): the
|
||||||
|
// switch below operates on the lowercased key, so it can't otherwise
|
||||||
|
// tell 'E' and 'e' apart. Return false and let the caller show its
|
||||||
|
// normal "unknown k-command" status-line message - curses already
|
||||||
|
// owns the terminal here, so writing to stderr would corrupt the
|
||||||
|
// screen.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
switch (k_lower) {
|
switch (k_lower) {
|
||||||
case 'a':
|
case 'a':
|
||||||
@@ -66,9 +73,6 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'e':
|
case 'e':
|
||||||
out = CommandId::OpenFileStart;
|
out = CommandId::OpenFileStart;
|
||||||
return true;
|
return true;
|
||||||
case 'E':
|
|
||||||
std::cerr << "E is not a valid command" << std::endl;
|
|
||||||
return false;
|
|
||||||
case 'f':
|
case 'f':
|
||||||
out = CommandId::FlushKillRing;
|
out = CommandId::FlushKillRing;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.PHONY: configure build test gate
|
||||||
|
|
||||||
|
configure:
|
||||||
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON -DBUILD_TESTS=ON
|
||||||
|
|
||||||
|
build:
|
||||||
|
cmake --build build
|
||||||
|
|
||||||
|
test:
|
||||||
|
./build/kte_tests
|
||||||
|
|
||||||
|
gate: configure build test
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
#include "OptimizedSearch.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
|
|
||||||
void
|
|
||||||
OptimizedSearch::build_bad_char(const std::string &pattern)
|
|
||||||
{
|
|
||||||
if (pattern == last_pat_)
|
|
||||||
return;
|
|
||||||
last_pat_ = pattern;
|
|
||||||
std::fill(bad_char_.begin(), bad_char_.end(), -1);
|
|
||||||
for (std::size_t i = 0; i < pattern.size(); ++i) {
|
|
||||||
bad_char_[static_cast<unsigned char>(pattern[i])] = static_cast<int>(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
std::size_t
|
|
||||||
OptimizedSearch::find_first(const std::string &text, const std::string &pattern, std::size_t start)
|
|
||||||
{
|
|
||||||
const std::size_t n = text.size();
|
|
||||||
const std::size_t m = pattern.size();
|
|
||||||
if (m == 0)
|
|
||||||
return start <= n ? start : std::string::npos;
|
|
||||||
if (m > n || start >= n)
|
|
||||||
return std::string::npos;
|
|
||||||
build_bad_char(pattern);
|
|
||||||
std::size_t s = start;
|
|
||||||
while (s <= n - m) {
|
|
||||||
std::size_t j = m;
|
|
||||||
while (j > 0 && pattern[j - 1] == text[s + j - 1]) {
|
|
||||||
--j;
|
|
||||||
}
|
|
||||||
if (j == 0) {
|
|
||||||
return s; // match found
|
|
||||||
}
|
|
||||||
unsigned char badc = static_cast<unsigned char>(text[s + j - 1]);
|
|
||||||
int bcidx = bad_char_[badc];
|
|
||||||
std::size_t shift = (j - 1 > static_cast<std::size_t>(bcidx))
|
|
||||||
? (j - 1 - static_cast<std::size_t>(bcidx))
|
|
||||||
: 1;
|
|
||||||
s += shift;
|
|
||||||
}
|
|
||||||
return std::string::npos;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
std::vector<std::size_t>
|
|
||||||
OptimizedSearch::find_all(const std::string &text, const std::string &pattern, std::size_t start)
|
|
||||||
{
|
|
||||||
std::vector<std::size_t> res;
|
|
||||||
const std::size_t n = text.size();
|
|
||||||
const std::size_t m = pattern.size();
|
|
||||||
if (m == 0)
|
|
||||||
return res;
|
|
||||||
if (m > n || start >= n)
|
|
||||||
return res;
|
|
||||||
build_bad_char(pattern);
|
|
||||||
std::size_t s = start;
|
|
||||||
while (s <= n - m) {
|
|
||||||
std::size_t j = m;
|
|
||||||
while (j > 0 && pattern[j - 1] == text[s + j - 1]) {
|
|
||||||
--j;
|
|
||||||
}
|
|
||||||
if (j == 0) {
|
|
||||||
res.push_back(s);
|
|
||||||
s += m; // non-overlapping
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
unsigned char badc = static_cast<unsigned char>(text[s + j - 1]);
|
|
||||||
int bcidx = bad_char_[badc];
|
|
||||||
std::size_t shift = (j - 1 > static_cast<std::size_t>(bcidx))
|
|
||||||
? (j - 1 - static_cast<std::size_t>(bcidx))
|
|
||||||
: 1;
|
|
||||||
s += shift;
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// OptimizedSearch.h - Boyer–Moore (bad character) based substring search
|
|
||||||
#pragma once
|
|
||||||
#include <array>
|
|
||||||
#include <cstddef>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
class OptimizedSearch {
|
|
||||||
public:
|
|
||||||
OptimizedSearch() = default;
|
|
||||||
|
|
||||||
// Find first occurrence at or after start. Returns npos if not found.
|
|
||||||
std::size_t find_first(const std::string &text, const std::string &pattern, std::size_t start = 0);
|
|
||||||
|
|
||||||
// Find all non-overlapping matches at or after start. Returns starting indices.
|
|
||||||
std::vector<std::size_t> find_all(const std::string &text, const std::string &pattern, std::size_t start = 0);
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::array<int, 256> bad_char_{};
|
|
||||||
std::string last_pat_;
|
|
||||||
|
|
||||||
void build_bad_char(const std::string &pattern);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* PasteSplit.h - split pasted/clipboard text into editor commands
|
||||||
|
*
|
||||||
|
* Pure logic (no SDL/GUI dependencies) so it can be unit tested and shared
|
||||||
|
* across frontends. Line breaks in the pasted text must become Newline
|
||||||
|
* commands; InsertText itself rejects embedded '\r'/'\n' (see Command.cc).
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "InputHandler.h"
|
||||||
|
|
||||||
|
|
||||||
|
// Translate a block of pasted text into a sequence of editor commands.
|
||||||
|
//
|
||||||
|
// Any of "\n", "\r\n", or a bare "\r" is treated as a single line break and
|
||||||
|
// emitted as a Newline command; the text between breaks becomes InsertText
|
||||||
|
// commands. Empty text segments are skipped, but line breaks are always
|
||||||
|
// emitted so blank lines round-trip correctly.
|
||||||
|
inline std::vector<MappedInput> SplitPasteIntoCommands(const std::string &text)
|
||||||
|
{
|
||||||
|
std::vector<MappedInput> out;
|
||||||
|
std::string segment;
|
||||||
|
|
||||||
|
auto flush_segment = [&]() {
|
||||||
|
if (!segment.empty()) {
|
||||||
|
out.push_back(MappedInput{true, CommandId::InsertText, segment, 0});
|
||||||
|
segment.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||||
|
const char c = text[i];
|
||||||
|
if (c == '\n' || c == '\r') {
|
||||||
|
flush_segment();
|
||||||
|
out.push_back(MappedInput{true, CommandId::Newline, std::string(), 0});
|
||||||
|
// Collapse a "\r\n" pair into one line break.
|
||||||
|
if (c == '\r' && i + 1 < text.size() && text[i + 1] == '\n')
|
||||||
|
++i;
|
||||||
|
} else {
|
||||||
|
segment.push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush_segment();
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Pasted text destined for a prompt (search, file name, command). Newline
|
||||||
|
// would accept the prompt, so each line break (\n, \r\n or \r) becomes a
|
||||||
|
// single space and the whole paste is one InsertText.
|
||||||
|
inline std::vector<MappedInput> SplitPasteForPrompt(const std::string &text)
|
||||||
|
{
|
||||||
|
std::string flat;
|
||||||
|
flat.reserve(text.size());
|
||||||
|
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||||
|
const char c = text[i];
|
||||||
|
if (c == '\n' || c == '\r') {
|
||||||
|
flat.push_back(' ');
|
||||||
|
if (c == '\r' && i + 1 < text.size() && text[i + 1] == '\n')
|
||||||
|
++i;
|
||||||
|
} else {
|
||||||
|
flat.push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::vector<MappedInput> out;
|
||||||
|
if (!flat.empty())
|
||||||
|
out.push_back(MappedInput{true, CommandId::InsertText, flat, 0});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+286
-64
@@ -1,4 +1,5 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <ostream>
|
#include <ostream>
|
||||||
@@ -9,13 +10,6 @@
|
|||||||
PieceTable::PieceTable() = default;
|
PieceTable::PieceTable() = default;
|
||||||
|
|
||||||
|
|
||||||
PieceTable::PieceTable(const std::size_t initialCapacity)
|
|
||||||
{
|
|
||||||
add_.reserve(initialCapacity);
|
|
||||||
materialized_.reserve(initialCapacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
PieceTable::PieceTable(const std::size_t initialCapacity,
|
PieceTable::PieceTable(const std::size_t initialCapacity,
|
||||||
const std::size_t piece_limit,
|
const std::size_t piece_limit,
|
||||||
const std::size_t small_piece_threshold,
|
const std::size_t small_piece_threshold,
|
||||||
@@ -58,6 +52,9 @@ PieceTable::operator=(const PieceTable &other)
|
|||||||
version_ = other.version_;
|
version_ = other.version_;
|
||||||
range_cache_ = {};
|
range_cache_ = {};
|
||||||
find_cache_ = {};
|
find_cache_ = {};
|
||||||
|
last_deleted_ = {};
|
||||||
|
piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
|
||||||
|
InvalidateLineIndex();
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +69,8 @@ PieceTable::PieceTable(PieceTable &&other) noexcept
|
|||||||
{
|
{
|
||||||
other.dirty_ = true;
|
other.dirty_ = true;
|
||||||
other.total_size_ = 0;
|
other.total_size_ = 0;
|
||||||
|
other.InvalidateLineIndex(); // its index described the moved-away text
|
||||||
|
other.piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
|
||||||
version_ = other.version_;
|
version_ = other.version_;
|
||||||
range_cache_ = {};
|
range_cache_ = {};
|
||||||
find_cache_ = {};
|
find_cache_ = {};
|
||||||
@@ -91,9 +90,15 @@ PieceTable::operator=(PieceTable &&other) noexcept
|
|||||||
total_size_ = other.total_size_;
|
total_size_ = other.total_size_;
|
||||||
other.dirty_ = true;
|
other.dirty_ = true;
|
||||||
other.total_size_ = 0;
|
other.total_size_ = 0;
|
||||||
|
other.InvalidateLineIndex(); // its index described the moved-away text
|
||||||
version_ = other.version_;
|
version_ = other.version_;
|
||||||
range_cache_ = {};
|
range_cache_ = {};
|
||||||
find_cache_ = {};
|
find_cache_ = {};
|
||||||
|
last_deleted_ = {};
|
||||||
|
other.last_deleted_ = {};
|
||||||
|
piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
|
||||||
|
other.piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
|
||||||
|
InvalidateLineIndex();
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,14 +106,6 @@ PieceTable::operator=(PieceTable &&other) noexcept
|
|||||||
PieceTable::~PieceTable() = default;
|
PieceTable::~PieceTable() = default;
|
||||||
|
|
||||||
|
|
||||||
void
|
|
||||||
PieceTable::Reserve(const std::size_t newCapacity)
|
|
||||||
{
|
|
||||||
add_.reserve(newCapacity);
|
|
||||||
materialized_.reserve(newCapacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Setter to allow tuning consolidation heuristics
|
// Setter to allow tuning consolidation heuristics
|
||||||
void
|
void
|
||||||
PieceTable::SetConsolidationParams(const std::size_t piece_limit,
|
PieceTable::SetConsolidationParams(const std::size_t piece_limit,
|
||||||
@@ -192,6 +189,7 @@ void
|
|||||||
PieceTable::Clear()
|
PieceTable::Clear()
|
||||||
{
|
{
|
||||||
pieces_.clear();
|
pieces_.clear();
|
||||||
|
std::string().swap(original_);
|
||||||
add_.clear();
|
add_.clear();
|
||||||
materialized_.clear();
|
materialized_.clear();
|
||||||
total_size_ = 0;
|
total_size_ = 0;
|
||||||
@@ -201,6 +199,58 @@ PieceTable::Clear()
|
|||||||
version_++;
|
version_++;
|
||||||
range_cache_ = {};
|
range_cache_ = {};
|
||||||
find_cache_ = {};
|
find_cache_ = {};
|
||||||
|
last_deleted_ = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
PieceTable::AdoptOriginal(std::string &&bytes)
|
||||||
|
{
|
||||||
|
// Allocate before touching the current content: if this throws, the
|
||||||
|
// table is unchanged (everything below is non-throwing).
|
||||||
|
std::vector<Piece> pieces;
|
||||||
|
if (!bytes.empty())
|
||||||
|
pieces.push_back(Piece{Source::Original, 0, bytes.size()});
|
||||||
|
Clear();
|
||||||
|
std::string().swap(add_); // release a previous content's capacity
|
||||||
|
std::string().swap(materialized_);
|
||||||
|
original_ = std::move(bytes);
|
||||||
|
total_size_ = original_.size();
|
||||||
|
pieces_.swap(pieces);
|
||||||
|
version_++;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string_view
|
||||||
|
PieceTable::ContentView() const
|
||||||
|
{
|
||||||
|
if (total_size_ == 0)
|
||||||
|
return {};
|
||||||
|
if (pieces_.size() == 1) {
|
||||||
|
const Piece &p = pieces_.front();
|
||||||
|
return {(p.src == Source::Original ? original_ : add_).data() + p.start, p.len};
|
||||||
|
}
|
||||||
|
materialize();
|
||||||
|
return {materialized_.data(), materialized_.size()};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string_view
|
||||||
|
PieceTable::View(std::size_t byte_offset, std::size_t len) const
|
||||||
|
{
|
||||||
|
if (byte_offset >= total_size_ || len == 0)
|
||||||
|
return {};
|
||||||
|
len = std::min(len, total_size_ - byte_offset);
|
||||||
|
if (dirty_) {
|
||||||
|
// Materialized text is stale: serve the range from its piece when it
|
||||||
|
// fits in one, rather than copying the whole buffer.
|
||||||
|
auto [idx, inner] = locate(byte_offset);
|
||||||
|
if (idx < pieces_.size() && inner + len <= pieces_[idx].len) {
|
||||||
|
const Piece &p = pieces_[idx];
|
||||||
|
return {(p.src == Source::Original ? original_ : add_).data() + p.start + inner, len};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ContentView().substr(byte_offset, len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -221,6 +271,7 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
|
|||||||
last.len += len;
|
last.len += len;
|
||||||
total_size_ += len;
|
total_size_ += len;
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
|
InvalidateLineIndex();
|
||||||
version_++;
|
version_++;
|
||||||
range_cache_ = {};
|
range_cache_ = {};
|
||||||
find_cache_ = {};
|
find_cache_ = {};
|
||||||
@@ -254,6 +305,7 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
|
|||||||
first.len += len;
|
first.len += len;
|
||||||
total_size_ += len;
|
total_size_ += len;
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
|
InvalidateLineIndex();
|
||||||
version_++;
|
version_++;
|
||||||
range_cache_ = {};
|
range_cache_ = {};
|
||||||
find_cache_ = {};
|
find_cache_ = {};
|
||||||
@@ -273,7 +325,6 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
|
|||||||
void
|
void
|
||||||
PieceTable::materialize() const
|
PieceTable::materialize() const
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
if (!dirty_) {
|
if (!dirty_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -300,16 +351,20 @@ PieceTable::locate(const std::size_t byte_offset) const
|
|||||||
if (byte_offset >= total_size_) {
|
if (byte_offset >= total_size_) {
|
||||||
return {pieces_.size(), 0};
|
return {pieces_.size(), 0};
|
||||||
}
|
}
|
||||||
std::size_t off = byte_offset;
|
// Rebuilt once per modification: an edited buffer's lookups (two per
|
||||||
for (std::size_t i = 0; i < pieces_.size(); ++i) {
|
// rendered line per frame) used to walk the piece list each time.
|
||||||
const auto &p = pieces_[i];
|
if (piece_ends_version_ != version_ || piece_ends_.size() != pieces_.size()) {
|
||||||
if (off < p.len) {
|
piece_ends_.resize(pieces_.size());
|
||||||
return {i, off};
|
std::size_t end = 0;
|
||||||
|
for (std::size_t i = 0; i < pieces_.size(); ++i)
|
||||||
|
piece_ends_[i] = end += pieces_[i].len;
|
||||||
|
piece_ends_version_ = version_;
|
||||||
}
|
}
|
||||||
off -= p.len;
|
const auto it = std::upper_bound(piece_ends_.begin(), piece_ends_.end(), byte_offset);
|
||||||
}
|
if (it == piece_ends_.end())
|
||||||
// Should not reach here unless inconsistency; return end
|
return {pieces_.size(), 0}; // inconsistent sizes; treat as the end
|
||||||
return {pieces_.size(), 0};
|
const std::size_t i = static_cast<std::size_t>(it - piece_ends_.begin());
|
||||||
|
return {i, byte_offset - (i > 0 ? piece_ends_[i - 1] : 0)};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -349,33 +404,102 @@ PieceTable::coalesceNeighbors(std::size_t index)
|
|||||||
void
|
void
|
||||||
PieceTable::InvalidateLineIndex() const
|
PieceTable::InvalidateLineIndex() const
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
line_index_dirty_ = true;
|
line_index_dirty_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
PieceTable::lineUpperBound(const std::size_t byte_offset) const
|
||||||
|
{
|
||||||
|
// Line starts ascend; binary search over them with the shift applied.
|
||||||
|
std::size_t lo = 0, hi = line_index_.size();
|
||||||
|
while (lo < hi) {
|
||||||
|
const std::size_t mid = lo + (hi - lo) / 2;
|
||||||
|
if (lineStart(mid) <= byte_offset)
|
||||||
|
lo = mid + 1;
|
||||||
|
else
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
PieceTable::applyLineShift() const
|
||||||
|
{
|
||||||
|
for (std::size_t i = line_shift_from_; i < line_index_.size(); ++i)
|
||||||
|
line_index_[i] += line_shift_by_;
|
||||||
|
line_shift_from_ = std::numeric_limits<std::size_t>::max();
|
||||||
|
line_shift_by_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
PieceTable::lineIndexOnInsert(std::size_t offset, const char *text, std::size_t len) const
|
||||||
|
{
|
||||||
|
if (line_index_dirty_)
|
||||||
|
return;
|
||||||
|
// Line starts after the insertion point move right; a start exactly at
|
||||||
|
// the insertion point stays (the text joins that line).
|
||||||
|
const std::size_t first_after = lineUpperBound(offset);
|
||||||
|
std::vector<std::size_t> added;
|
||||||
|
const char *end = text + len;
|
||||||
|
for (const char *p = text; (p = static_cast<const char *>(std::memchr(p, '\n', end - p))) != nullptr; ++p)
|
||||||
|
added.push_back(offset + static_cast<std::size_t>(p - text) + 1);
|
||||||
|
// The pending shift can absorb this one only if it starts at the same
|
||||||
|
// line (the usual case: typing along one line).
|
||||||
|
if (line_shift_by_ != 0 && line_shift_from_ != first_after)
|
||||||
|
applyLineShift();
|
||||||
|
if (!added.empty())
|
||||||
|
line_index_.insert(line_index_.begin() + static_cast<std::ptrdiff_t>(first_after), added.begin(),
|
||||||
|
added.end());
|
||||||
|
// The new starts are exact; the ones after them move by len.
|
||||||
|
line_shift_from_ = first_after + added.size();
|
||||||
|
line_shift_by_ += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
PieceTable::lineIndexOnDelete(std::size_t offset, std::size_t len) const
|
||||||
|
{
|
||||||
|
if (line_index_dirty_)
|
||||||
|
return;
|
||||||
|
// Starts in (offset, offset+len] followed a deleted newline: drop them.
|
||||||
|
// Later starts move left.
|
||||||
|
const std::size_t lo = lineUpperBound(offset);
|
||||||
|
const std::size_t hi = lineUpperBound(offset + len);
|
||||||
|
// After the erase the starts from hi on are at lo; a pending shift
|
||||||
|
// beginning anywhere in [lo, hi] covers exactly them.
|
||||||
|
if (line_shift_by_ != 0 && (line_shift_from_ < lo || line_shift_from_ > hi))
|
||||||
|
applyLineShift();
|
||||||
|
line_index_.erase(line_index_.begin() + static_cast<std::ptrdiff_t>(lo),
|
||||||
|
line_index_.begin() + static_cast<std::ptrdiff_t>(hi));
|
||||||
|
line_shift_from_ = lo;
|
||||||
|
line_shift_by_ -= len;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
PieceTable::RebuildLineIndex() const
|
PieceTable::RebuildLineIndex() const
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
|
|
||||||
if (!line_index_dirty_) {
|
if (!line_index_dirty_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
line_index_.clear();
|
line_index_.clear();
|
||||||
line_index_.push_back(0);
|
line_index_.push_back(0);
|
||||||
|
line_shift_from_ = std::numeric_limits<std::size_t>::max();
|
||||||
|
line_shift_by_ = 0;
|
||||||
|
|
||||||
std::size_t pos = 0;
|
std::size_t pos = 0;
|
||||||
for (const auto &pc: pieces_) {
|
for (const auto &pc: pieces_) {
|
||||||
const std::string &src = pc.src == Source::Original ? original_ : add_;
|
const std::string &src = pc.src == Source::Original ? original_ : add_;
|
||||||
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
|
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
|
||||||
|
|
||||||
for (std::size_t j = 0; j < pc.len; ++j) {
|
const char *end = base + pc.len;
|
||||||
if (base[j] == '\n') {
|
// The next line starts after each newline.
|
||||||
// next line starts after the newline
|
for (const char *p = base; (p = static_cast<const char *>(std::memchr(p, '\n', end - p))) != nullptr; ++p)
|
||||||
line_index_.push_back(pos + j + 1);
|
line_index_.push_back(pos + static_cast<std::size_t>(p - base) + 1);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pos += pc.len;
|
pos += pc.len;
|
||||||
}
|
}
|
||||||
@@ -394,6 +518,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
|
|||||||
byte_offset = total_size_;
|
byte_offset = total_size_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
last_deleted_ = {};
|
||||||
const std::size_t add_start = add_.size();
|
const std::size_t add_start = add_.size();
|
||||||
add_.append(text, len);
|
add_.append(text, len);
|
||||||
|
|
||||||
@@ -401,7 +526,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
|
|||||||
pieces_.push_back(Piece{Source::Add, add_start, len});
|
pieces_.push_back(Piece{Source::Add, add_start, len});
|
||||||
total_size_ += len;
|
total_size_ += len;
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
InvalidateLineIndex();
|
lineIndexOnInsert(byte_offset, add_.data() + add_start, len);
|
||||||
maybeConsolidate();
|
maybeConsolidate();
|
||||||
version_++;
|
version_++;
|
||||||
range_cache_ = {};
|
range_cache_ = {};
|
||||||
@@ -415,7 +540,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
|
|||||||
pieces_.push_back(Piece{Source::Add, add_start, len});
|
pieces_.push_back(Piece{Source::Add, add_start, len});
|
||||||
total_size_ += len;
|
total_size_ += len;
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
InvalidateLineIndex();
|
lineIndexOnInsert(byte_offset, add_.data() + add_start, len);
|
||||||
coalesceNeighbors(pieces_.size() - 1);
|
coalesceNeighbors(pieces_.size() - 1);
|
||||||
maybeConsolidate();
|
maybeConsolidate();
|
||||||
version_++;
|
version_++;
|
||||||
@@ -443,7 +568,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
|
|||||||
|
|
||||||
total_size_ += len;
|
total_size_ += len;
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
InvalidateLineIndex();
|
lineIndexOnInsert(byte_offset, add_.data() + add_start, len);
|
||||||
// Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0))
|
// Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0))
|
||||||
std::size_t ins_index = idx + (inner > 0 ? 1 : 0);
|
std::size_t ins_index = idx + (inner > 0 ? 1 : 0);
|
||||||
coalesceNeighbors(ins_index);
|
coalesceNeighbors(ins_index);
|
||||||
@@ -467,6 +592,16 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
|
|||||||
len = total_size_ - byte_offset;
|
len = total_size_ - byte_offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember what a large deletion removed, so undo can refer to the
|
||||||
|
// stored bytes instead of keeping its own copy (see TakeDeletedSpans).
|
||||||
|
last_deleted_ = {};
|
||||||
|
if (len >= kCaptureDeletedMin) {
|
||||||
|
last_deleted_.spans = SpansInRange(byte_offset, len);
|
||||||
|
last_deleted_.offset = byte_offset;
|
||||||
|
last_deleted_.len = len;
|
||||||
|
last_deleted_.valid = true;
|
||||||
|
}
|
||||||
|
|
||||||
auto [idx, inner] = locate(byte_offset);
|
auto [idx, inner] = locate(byte_offset);
|
||||||
std::size_t remaining = len;
|
std::size_t remaining = len;
|
||||||
|
|
||||||
@@ -513,7 +648,7 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
|
|||||||
|
|
||||||
total_size_ -= len;
|
total_size_ -= len;
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
InvalidateLineIndex();
|
lineIndexOnDelete(byte_offset, len);
|
||||||
if (idx < pieces_.size())
|
if (idx < pieces_.size())
|
||||||
coalesceNeighbors(idx);
|
coalesceNeighbors(idx);
|
||||||
if (idx > 0)
|
if (idx > 0)
|
||||||
@@ -525,6 +660,104 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ===== Span access =====
|
||||||
|
|
||||||
|
std::vector<TextSpan>
|
||||||
|
PieceTable::SpansInRange(std::size_t byte_offset, std::size_t len) const
|
||||||
|
{
|
||||||
|
std::vector<TextSpan> out;
|
||||||
|
if (byte_offset >= total_size_ || len == 0)
|
||||||
|
return out;
|
||||||
|
len = std::min(len, total_size_ - byte_offset);
|
||||||
|
auto [idx, inner] = locate(byte_offset);
|
||||||
|
while (len > 0 && idx < pieces_.size()) {
|
||||||
|
const Piece &p = pieces_[idx];
|
||||||
|
const std::size_t take = std::min(p.len - inner, len);
|
||||||
|
out.push_back(TextSpan{p.src == Source::Add, p.start + inner, take});
|
||||||
|
len -= take;
|
||||||
|
inner = 0;
|
||||||
|
++idx;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
PieceTable::SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const
|
||||||
|
{
|
||||||
|
std::size_t pos = 0;
|
||||||
|
bool equal = true;
|
||||||
|
VisitSpans(spans, [&](const char *data, std::size_t n) {
|
||||||
|
if (!equal || pos + n > text.size() || std::memcmp(data, text.data() + pos, n) != 0)
|
||||||
|
equal = false;
|
||||||
|
pos += n;
|
||||||
|
});
|
||||||
|
return equal && pos == text.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
PieceTable::InsertSpans(std::size_t byte_offset, const std::vector<TextSpan> &spans)
|
||||||
|
{
|
||||||
|
if (byte_offset > total_size_)
|
||||||
|
byte_offset = total_size_;
|
||||||
|
std::vector<Piece> ins;
|
||||||
|
std::size_t len = 0;
|
||||||
|
for (const TextSpan &sp: spans) {
|
||||||
|
const std::string &src = sp.add ? add_ : original_;
|
||||||
|
if (sp.len == 0 || sp.start + sp.len > src.size())
|
||||||
|
continue;
|
||||||
|
ins.push_back(Piece{sp.add ? Source::Add : Source::Original, sp.start, sp.len});
|
||||||
|
len += sp.len;
|
||||||
|
}
|
||||||
|
if (len == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::size_t at = pieces_.size();
|
||||||
|
if (byte_offset < total_size_) {
|
||||||
|
auto [idx, inner] = locate(byte_offset);
|
||||||
|
if (inner > 0) {
|
||||||
|
// Split the piece containing the insertion point.
|
||||||
|
const Piece target = pieces_[idx];
|
||||||
|
pieces_[idx].len = inner;
|
||||||
|
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx + 1),
|
||||||
|
Piece{target.src, target.start + inner, target.len - inner});
|
||||||
|
at = idx + 1;
|
||||||
|
} else {
|
||||||
|
at = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(at), ins.begin(), ins.end());
|
||||||
|
total_size_ += len;
|
||||||
|
dirty_ = true;
|
||||||
|
std::size_t off = byte_offset;
|
||||||
|
for (const Piece &p: ins) {
|
||||||
|
const std::string &src = p.src == Source::Add ? add_ : original_;
|
||||||
|
lineIndexOnInsert(off, src.data() + p.start, p.len);
|
||||||
|
off += p.len;
|
||||||
|
}
|
||||||
|
coalesceNeighbors(std::min(at + ins.size(), pieces_.size() - 1));
|
||||||
|
if (at > 0)
|
||||||
|
coalesceNeighbors(at - 1);
|
||||||
|
maybeConsolidate();
|
||||||
|
version_++;
|
||||||
|
range_cache_ = {};
|
||||||
|
find_cache_ = {};
|
||||||
|
last_deleted_ = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<TextSpan>
|
||||||
|
PieceTable::TakeDeletedSpans(std::size_t byte_offset, std::size_t len)
|
||||||
|
{
|
||||||
|
std::vector<TextSpan> out;
|
||||||
|
if (last_deleted_.valid && last_deleted_.offset == byte_offset && last_deleted_.len == len)
|
||||||
|
out = std::move(last_deleted_.spans);
|
||||||
|
last_deleted_ = {};
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== Consolidation implementation =====
|
// ===== Consolidation implementation =====
|
||||||
|
|
||||||
void
|
void
|
||||||
@@ -562,9 +795,10 @@ PieceTable::consolidateRange(std::size_t start_idx, std::size_t end_idx)
|
|||||||
pieces_.begin() + static_cast<std::ptrdiff_t>(end_idx));
|
pieces_.begin() + static_cast<std::ptrdiff_t>(end_idx));
|
||||||
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx), consolidated);
|
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx), consolidated);
|
||||||
|
|
||||||
// total_size_ unchanged
|
// total_size_ unchanged. Content and byte offsets are unchanged too, so
|
||||||
|
// the line index stays valid (invalidating it here forced a full rescan
|
||||||
|
// on every edit once a buffer had many pieces).
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
InvalidateLineIndex();
|
|
||||||
coalesceNeighbors(start_idx);
|
coalesceNeighbors(start_idx);
|
||||||
// Layout changed; invalidate caches/version
|
// Layout changed; invalidate caches/version
|
||||||
version_++;
|
version_++;
|
||||||
@@ -631,8 +865,8 @@ PieceTable::GetLineRange(std::size_t line_num) const
|
|||||||
return {0, 0};
|
return {0, 0};
|
||||||
if (line_num >= line_index_.size())
|
if (line_num >= line_index_.size())
|
||||||
return {0, 0};
|
return {0, 0};
|
||||||
std::size_t start = line_index_[line_num];
|
std::size_t start = lineStart(line_num);
|
||||||
std::size_t end = (line_num + 1 < line_index_.size()) ? line_index_[line_num + 1] : total_size_;
|
std::size_t end = (line_num + 1 < line_index_.size()) ? lineStart(line_num + 1) : total_size_;
|
||||||
return {start, end};
|
return {start, end};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,14 +877,10 @@ PieceTable::GetLine(std::size_t line_num) const
|
|||||||
auto [start, end] = GetLineRange(line_num);
|
auto [start, end] = GetLineRange(line_num);
|
||||||
if (end < start)
|
if (end < start)
|
||||||
return std::string();
|
return std::string();
|
||||||
// Trim trailing '\n'
|
// Every line but the last ends at a newline (each newline starts an
|
||||||
if (end > start) {
|
// index entry); leave it out.
|
||||||
// To check last char, we can get it via GetRange of len 1 at end-1 without materializing whole
|
if (end > start && line_num + 1 < line_index_.size())
|
||||||
std::string last = GetRange(end - 1, 1);
|
|
||||||
if (!last.empty() && last[0] == '\n') {
|
|
||||||
end -= 1;
|
end -= 1;
|
||||||
}
|
|
||||||
}
|
|
||||||
return GetRange(start, end - start);
|
return GetRange(start, end - start);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,9 +893,9 @@ PieceTable::ByteOffsetToLineCol(std::size_t byte_offset) const
|
|||||||
RebuildLineIndex();
|
RebuildLineIndex();
|
||||||
if (line_index_.empty())
|
if (line_index_.empty())
|
||||||
return {0, 0};
|
return {0, 0};
|
||||||
auto it = std::upper_bound(line_index_.begin(), line_index_.end(), byte_offset);
|
const std::size_t ub = lineUpperBound(byte_offset);
|
||||||
std::size_t row = (it == line_index_.begin()) ? 0 : static_cast<std::size_t>((it - line_index_.begin()) - 1);
|
std::size_t row = ub == 0 ? 0 : ub - 1;
|
||||||
std::size_t col = byte_offset - line_index_[row];
|
std::size_t col = byte_offset - lineStart(row);
|
||||||
return {row, col};
|
return {row, col};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,15 +908,12 @@ PieceTable::LineColToByteOffset(std::size_t row, std::size_t col) const
|
|||||||
return 0;
|
return 0;
|
||||||
if (row >= line_index_.size())
|
if (row >= line_index_.size())
|
||||||
return total_size_;
|
return total_size_;
|
||||||
std::size_t start = line_index_[row];
|
std::size_t start = lineStart(row);
|
||||||
std::size_t end = (row + 1 < line_index_.size()) ? line_index_[row + 1] : total_size_;
|
std::size_t end = (row + 1 < line_index_.size()) ? lineStart(row + 1) : total_size_;
|
||||||
// Clamp col to line length excluding trailing newline
|
// Clamp col to the line's length without its newline (every line but
|
||||||
if (end > start) {
|
// the last has one).
|
||||||
std::string last = GetRange(end - 1, 1);
|
if (end > start && row + 1 < line_index_.size())
|
||||||
if (!last.empty() && last[0] == '\n') {
|
|
||||||
end -= 1;
|
end -= 1;
|
||||||
}
|
|
||||||
}
|
|
||||||
std::size_t target = start + std::min(col, end - start);
|
std::size_t target = start + std::min(col, end - start);
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
@@ -702,7 +929,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
|||||||
|
|
||||||
// Fast path: return cached value if version/offset/len match
|
// Fast path: return cached value if version/offset/len match
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
if (range_cache_.valid && range_cache_.version == version_ &&
|
if (range_cache_.valid && range_cache_.version == version_ &&
|
||||||
range_cache_.off == byte_offset && range_cache_.len == len) {
|
range_cache_.off == byte_offset && range_cache_.len == len) {
|
||||||
return range_cache_.data;
|
return range_cache_.data;
|
||||||
@@ -712,7 +938,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
|||||||
std::string out;
|
std::string out;
|
||||||
out.reserve(len);
|
out.reserve(len);
|
||||||
if (!dirty_) {
|
if (!dirty_) {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
// Already materialized; slice directly
|
// Already materialized; slice directly
|
||||||
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
|
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
|
||||||
} else {
|
} else {
|
||||||
@@ -737,7 +962,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
|||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
range_cache_.valid = true;
|
range_cache_.valid = true;
|
||||||
range_cache_.version = version_;
|
range_cache_.version = version_;
|
||||||
range_cache_.off = byte_offset;
|
range_cache_.off = byte_offset;
|
||||||
@@ -756,7 +980,6 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
|
|||||||
if (start > total_size_)
|
if (start > total_size_)
|
||||||
return std::numeric_limits<std::size_t>::max();
|
return std::numeric_limits<std::size_t>::max();
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
if (find_cache_.valid &&
|
if (find_cache_.valid &&
|
||||||
find_cache_.version == version_ &&
|
find_cache_.version == version_ &&
|
||||||
find_cache_.needle == needle &&
|
find_cache_.needle == needle &&
|
||||||
@@ -768,7 +991,6 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
|
|||||||
materialize();
|
materialize();
|
||||||
std::size_t pos;
|
std::size_t pos;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
pos = materialized_.find(needle, start);
|
pos = materialized_.find(needle, start);
|
||||||
if (pos == std::string::npos)
|
if (pos == std::string::npos)
|
||||||
pos = std::numeric_limits<std::size_t>::max();
|
pos = std::numeric_limits<std::size_t>::max();
|
||||||
|
|||||||
+105
-25
@@ -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_: Initial file content (currently unused, reserved for future)
|
* - original_: A loaded file's content (AdoptOriginal), never modified
|
||||||
* - add_: All text added during editing
|
* - add_: All text added during editing
|
||||||
*
|
*
|
||||||
* Key advantages:
|
* Key advantages:
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
*
|
*
|
||||||
* 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
|
||||||
*
|
*
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
*
|
*
|
||||||
* Implementation notes:
|
* Implementation notes:
|
||||||
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
|
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
|
||||||
* - Thread-safe for concurrent reads (mutex protects caches and lazy rebuilds)
|
* - Not thread-safe: even reads update caches. Buffers are used from the
|
||||||
|
* main thread only (the swap writer thread gets copies of the bytes).
|
||||||
* - Version tracking invalidates caches on mutations
|
* - Version tracking invalidates caches on mutations
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
@@ -42,15 +44,15 @@
|
|||||||
#include <ostream>
|
#include <ostream>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <mutex>
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "TextSpan.h"
|
||||||
|
|
||||||
|
|
||||||
class PieceTable {
|
class PieceTable {
|
||||||
public:
|
public:
|
||||||
PieceTable();
|
PieceTable();
|
||||||
|
|
||||||
explicit PieceTable(std::size_t initialCapacity);
|
|
||||||
|
|
||||||
// Advanced constructor allowing configuration of consolidation heuristics
|
// Advanced constructor allowing configuration of consolidation heuristics
|
||||||
PieceTable(std::size_t initialCapacity,
|
PieceTable(std::size_t initialCapacity,
|
||||||
std::size_t piece_limit,
|
std::size_t piece_limit,
|
||||||
@@ -67,9 +69,6 @@ public:
|
|||||||
|
|
||||||
~PieceTable();
|
~PieceTable();
|
||||||
|
|
||||||
// Public API mirrors GapBuffer
|
|
||||||
void Reserve(std::size_t newCapacity);
|
|
||||||
|
|
||||||
void AppendChar(char c);
|
void AppendChar(char c);
|
||||||
|
|
||||||
void Append(const char *s, std::size_t len);
|
void Append(const char *s, std::size_t len);
|
||||||
@@ -85,18 +84,40 @@ public:
|
|||||||
// Content management
|
// Content management
|
||||||
void Clear();
|
void Clear();
|
||||||
|
|
||||||
|
// Replace the content with `bytes`, taken over without copying (a loaded
|
||||||
|
// file). Undo spans into the old storage are invalidated, as by Clear().
|
||||||
|
// If it throws (std::bad_alloc), the content is unchanged.
|
||||||
|
void AdoptOriginal(std::string &&bytes);
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
char *Data()
|
// The whole text, contiguous; nullptr when empty. Valid until the next
|
||||||
|
// modification.
|
||||||
|
[[nodiscard]] const char *Data() const
|
||||||
{
|
{
|
||||||
materialize();
|
const std::string_view v = ContentView();
|
||||||
return materialized_.empty() ? nullptr : materialized_.data();
|
return v.empty() ? nullptr : v.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] const char *Data() const
|
// The whole text as one view: in place for a single piece, else the
|
||||||
|
// materialized copy (built once per modification). Valid until the next
|
||||||
|
// modification.
|
||||||
|
[[nodiscard]] std::string_view ContentView() const;
|
||||||
|
|
||||||
|
// A view of [byte_offset, byte_offset + len), clamped to the content: in
|
||||||
|
// place when the range lies within one piece (no copy), else into the
|
||||||
|
// materialized text. Valid until the next modification.
|
||||||
|
[[nodiscard]] std::string_view View(std::size_t byte_offset, std::size_t len) const;
|
||||||
|
|
||||||
|
// Call fn(const char *data, std::size_t len) for each piece, in order:
|
||||||
|
// the content without materializing it.
|
||||||
|
template<typename Fn>
|
||||||
|
void ForEachChunk(Fn &&fn) const
|
||||||
{
|
{
|
||||||
const_cast<PieceTable *>(this)->materialize();
|
for (const Piece &p: pieces_) {
|
||||||
return materialized_.empty() ? nullptr : materialized_.data();
|
if (p.len > 0)
|
||||||
|
fn((p.src == Source::Original ? original_ : add_).data() + p.start, p.len);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -106,13 +127,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::size_t Capacity() const
|
|
||||||
{
|
|
||||||
// Capacity for piece table isn't directly meaningful; report materialized capacity
|
|
||||||
return materialized_.capacity();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ===== New buffer-wide API (Phase 1) =====
|
// ===== New buffer-wide API (Phase 1) =====
|
||||||
// Byte-based editing operations
|
// Byte-based editing operations
|
||||||
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
||||||
@@ -139,6 +153,36 @@ public:
|
|||||||
// Stream out content without materializing the entire buffer
|
// Stream out content without materializing the entire buffer
|
||||||
void WriteToStream(std::ostream &out) const;
|
void WriteToStream(std::ostream &out) const;
|
||||||
|
|
||||||
|
// ===== Span access (see TextSpan.h) =====
|
||||||
|
// The storage spans backing [byte_offset, byte_offset + len).
|
||||||
|
[[nodiscard]] std::vector<TextSpan> SpansInRange(std::size_t byte_offset, std::size_t len) const;
|
||||||
|
|
||||||
|
// Call fn(const char *data, std::size_t len) for each span's bytes, in order.
|
||||||
|
template<typename Fn>
|
||||||
|
void VisitSpans(const std::vector<TextSpan> &spans, Fn &&fn) const
|
||||||
|
{
|
||||||
|
for (const TextSpan &sp: spans) {
|
||||||
|
const std::string &src = sp.add ? add_ : original_;
|
||||||
|
if (sp.len > 0 && sp.start + sp.len <= src.size())
|
||||||
|
fn(src.data() + sp.start, sp.len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whether the spans hold exactly `text`.
|
||||||
|
[[nodiscard]] bool SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const;
|
||||||
|
|
||||||
|
// Insert the spans' bytes at byte_offset without copying them into
|
||||||
|
// storage again (the new pieces reference the existing bytes).
|
||||||
|
void InsertSpans(std::size_t byte_offset, const std::vector<TextSpan> &spans);
|
||||||
|
|
||||||
|
// Deletions of at least kCaptureDeletedMin bytes remember the spans they
|
||||||
|
// removed; this returns them if the most recent deletion was exactly
|
||||||
|
// [byte_offset, byte_offset + len), else nothing. Consumes the capture.
|
||||||
|
static constexpr std::size_t kCaptureDeletedMin = 4096;
|
||||||
|
|
||||||
|
[[nodiscard]] std::vector<TextSpan> TakeDeletedSpans(std::size_t byte_offset, std::size_t len);
|
||||||
|
|
||||||
// Heuristic configuration
|
// Heuristic configuration
|
||||||
void SetConsolidationParams(std::size_t piece_limit,
|
void SetConsolidationParams(std::size_t piece_limit,
|
||||||
std::size_t small_piece_threshold,
|
std::size_t small_piece_threshold,
|
||||||
@@ -175,10 +219,16 @@ private:
|
|||||||
// Line index support (rebuilt lazily on demand)
|
// Line index support (rebuilt lazily on demand)
|
||||||
void InvalidateLineIndex() const;
|
void InvalidateLineIndex() const;
|
||||||
|
|
||||||
|
// Keep a valid line index in step with an edit instead of rescanning the
|
||||||
|
// whole buffer on the next query. No-ops if the index is already dirty.
|
||||||
|
void lineIndexOnInsert(std::size_t offset, const char *text, std::size_t len) const;
|
||||||
|
|
||||||
|
void lineIndexOnDelete(std::size_t offset, std::size_t len) const;
|
||||||
|
|
||||||
void RebuildLineIndex() const;
|
void RebuildLineIndex() const;
|
||||||
|
|
||||||
// Underlying storages
|
// Underlying storages
|
||||||
std::string original_; // unused for builder use-case, but kept for API symmetry
|
std::string original_; // a loaded file's bytes (AdoptOriginal)
|
||||||
std::string add_;
|
std::string add_;
|
||||||
std::vector<Piece> pieces_;
|
std::vector<Piece> pieces_;
|
||||||
|
|
||||||
@@ -188,9 +238,34 @@ private:
|
|||||||
mutable std::uint64_t version_ = 0;
|
mutable std::uint64_t version_ = 0;
|
||||||
std::size_t total_size_ = 0;
|
std::size_t total_size_ = 0;
|
||||||
|
|
||||||
|
// End offset of each piece (prefix sums) for binary-search lookup,
|
||||||
|
// valid while piece_ends_version_ == version_ (every mutation bumps it).
|
||||||
|
mutable std::vector<std::size_t> piece_ends_;
|
||||||
|
mutable std::uint64_t piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
|
||||||
|
|
||||||
// Cached line index: starting byte offset of each line (always contains at least 1 entry: 0)
|
// Cached line index: starting byte offset of each line (always contains at least 1 entry: 0)
|
||||||
mutable std::vector<std::size_t> line_index_;
|
mutable std::vector<std::size_t> line_index_;
|
||||||
mutable bool line_index_dirty_ = true;
|
mutable bool line_index_dirty_ = true;
|
||||||
|
// A shift not yet applied to line_index_: entries from index
|
||||||
|
// line_shift_from_ on are really line_index_[i] + line_shift_by_ (mod
|
||||||
|
// 2^64, so it may be negative). An edit moves every later line start;
|
||||||
|
// applying that at once cost O(lines) per keystroke, so consecutive edits
|
||||||
|
// on one line only adjust the pending shift.
|
||||||
|
mutable std::size_t line_shift_from_ = std::numeric_limits<std::size_t>::max();
|
||||||
|
mutable std::size_t line_shift_by_ = 0;
|
||||||
|
|
||||||
|
// Start of line i (i < line_index_.size()), with the pending shift.
|
||||||
|
[[nodiscard]] std::size_t lineStart(std::size_t i) const
|
||||||
|
{
|
||||||
|
return i >= line_shift_from_ ? line_index_[i] + line_shift_by_ : line_index_[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Index of the first line starting after byte_offset.
|
||||||
|
[[nodiscard]] std::size_t lineUpperBound(std::size_t byte_offset) const;
|
||||||
|
|
||||||
|
// Fold the pending shift into line_index_.
|
||||||
|
void applyLineShift() const;
|
||||||
|
|
||||||
// Heuristic knobs
|
// Heuristic knobs
|
||||||
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
|
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
|
||||||
@@ -214,8 +289,13 @@ private:
|
|||||||
std::size_t result = std::numeric_limits<std::size_t>::max();
|
std::size_t result = std::numeric_limits<std::size_t>::max();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct DeletedCapture {
|
||||||
|
bool valid = false;
|
||||||
|
std::size_t offset = 0;
|
||||||
|
std::size_t len = 0;
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
} last_deleted_;
|
||||||
|
|
||||||
mutable RangeCache range_cache_;
|
mutable RangeCache range_cache_;
|
||||||
mutable FindCache find_cache_;
|
mutable FindCache find_cache_;
|
||||||
|
|
||||||
mutable std::mutex mutex_;
|
|
||||||
};
|
};
|
||||||
-983
@@ -1,983 +0,0 @@
|
|||||||
#include "QtFrontend.h"
|
|
||||||
|
|
||||||
#include <QApplication>
|
|
||||||
#include <QWidget>
|
|
||||||
#include <QKeyEvent>
|
|
||||||
#include <QTimer>
|
|
||||||
#include <QScreen>
|
|
||||||
#include <QFont>
|
|
||||||
#include <QFontMetrics>
|
|
||||||
#include <QFontDatabase>
|
|
||||||
#include <QFileDialog>
|
|
||||||
#include <QFontDialog>
|
|
||||||
#include <QPainter>
|
|
||||||
#include <QPaintEvent>
|
|
||||||
#include <QWheelEvent>
|
|
||||||
#include <regex>
|
|
||||||
|
|
||||||
#include "Editor.h"
|
|
||||||
#include "Command.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include "GUITheme.h"
|
|
||||||
#include "Highlight.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
class MainWindow : public QWidget {
|
|
||||||
public:
|
|
||||||
explicit MainWindow(class QtInputHandler &ih, QWidget *parent = nullptr)
|
|
||||||
: QWidget(parent), input_(ih)
|
|
||||||
{
|
|
||||||
// Match ImGui window title format
|
|
||||||
setWindowTitle(QStringLiteral("kge - kyle's graphical editor ")
|
|
||||||
+ QStringLiteral(KTE_VERSION_STR));
|
|
||||||
resize(1280, 800);
|
|
||||||
setFocusPolicy(Qt::StrongFocus);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
bool WasClosed() const
|
|
||||||
{
|
|
||||||
return closed_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetEditor(Editor *ed)
|
|
||||||
{
|
|
||||||
ed_ = ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetFontFamilyAndSize(QString family, int px)
|
|
||||||
{
|
|
||||||
if (family.isEmpty())
|
|
||||||
family = QStringLiteral("Brass Mono");
|
|
||||||
if (px <= 0)
|
|
||||||
px = 18;
|
|
||||||
font_family_ = std::move(family);
|
|
||||||
font_px_ = px;
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void keyPressEvent(QKeyEvent *event) override
|
|
||||||
{
|
|
||||||
// Route to editor keymap; if handled, accept and stop propagation so
|
|
||||||
// Qt doesn't trigger any default widget shortcuts.
|
|
||||||
if (input_.ProcessKeyEvent(*event)) {
|
|
||||||
event->accept();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QWidget::keyPressEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void paintEvent(QPaintEvent *event) override
|
|
||||||
{
|
|
||||||
Q_UNUSED(event);
|
|
||||||
QPainter p(this);
|
|
||||||
p.setRenderHint(QPainter::TextAntialiasing, true);
|
|
||||||
|
|
||||||
// Colors from GUITheme palette (Qt branch)
|
|
||||||
auto to_qcolor = [](const KteColor &c) -> QColor {
|
|
||||||
int r = int(std::round(c.x * 255.0f));
|
|
||||||
int g = int(std::round(c.y * 255.0f));
|
|
||||||
int b = int(std::round(c.z * 255.0f));
|
|
||||||
int a = int(std::round(c.w * 255.0f));
|
|
||||||
return QColor(r, g, b, a);
|
|
||||||
};
|
|
||||||
const auto pal = kte::GetPalette();
|
|
||||||
const QColor bg = to_qcolor(pal.bg);
|
|
||||||
const QColor fg = to_qcolor(pal.fg);
|
|
||||||
const QColor sel_bg = to_qcolor(pal.sel_bg);
|
|
||||||
const QColor cur_bg = to_qcolor(pal.cur_bg);
|
|
||||||
const QColor status_bg = to_qcolor(pal.status_bg);
|
|
||||||
const QColor status_fg = to_qcolor(pal.status_fg);
|
|
||||||
|
|
||||||
// Background
|
|
||||||
p.fillRect(rect(), bg);
|
|
||||||
|
|
||||||
// Font/metrics (configured or defaults)
|
|
||||||
QFont f(font_family_, font_px_);
|
|
||||||
p.setFont(f);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = fm.height();
|
|
||||||
const int ch_w = std::max(1, fm.horizontalAdvance(QStringLiteral(" ")));
|
|
||||||
|
|
||||||
// Layout metrics
|
|
||||||
const int pad_l = 8;
|
|
||||||
const int pad_t = 6;
|
|
||||||
const int pad_r = 8;
|
|
||||||
const int pad_b = 6;
|
|
||||||
const int status_h = line_h + 6; // status bar height
|
|
||||||
|
|
||||||
// Content area (text viewport)
|
|
||||||
const QRect content_rect(pad_l,
|
|
||||||
pad_t,
|
|
||||||
width() - pad_l - pad_r,
|
|
||||||
height() - pad_t - pad_b - status_h);
|
|
||||||
|
|
||||||
// Text viewport occupies all content area (no extra title row)
|
|
||||||
QRect viewport(content_rect.x(), content_rect.y(), content_rect.width(), content_rect.height());
|
|
||||||
|
|
||||||
// Draw buffer contents
|
|
||||||
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
|
|
||||||
const Buffer *buf = ed_->CurrentBuffer();
|
|
||||||
if (buf) {
|
|
||||||
const 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/*
|
|
||||||
* QtFrontend - couples QtInputHandler + QtRenderer and owns Qt lifecycle
|
|
||||||
*/
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "Frontend.h"
|
|
||||||
#include "GUIConfig.h"
|
|
||||||
#include "QtInputHandler.h"
|
|
||||||
#include "QtRenderer.h"
|
|
||||||
|
|
||||||
class QApplication;
|
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
// Keep the public class name GUIFrontend to match main.cc selection logic.
|
|
||||||
class GUIFrontend final : public Frontend {
|
|
||||||
public:
|
|
||||||
GUIFrontend() = default;
|
|
||||||
|
|
||||||
~GUIFrontend() override = default;
|
|
||||||
|
|
||||||
bool Init(int &argc, char **argv, Editor &ed) override;
|
|
||||||
|
|
||||||
void Step(Editor &ed, bool &running) override;
|
|
||||||
|
|
||||||
void Shutdown() override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
GUIConfig config_{};
|
|
||||||
QtInputHandler input_{};
|
|
||||||
QtRenderer renderer_{};
|
|
||||||
|
|
||||||
QApplication *app_ = nullptr; // owned
|
|
||||||
QWidget *window_ = nullptr; // owned
|
|
||||||
int width_ = 1280;
|
|
||||||
int height_ = 800;
|
|
||||||
};
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
// Refactor: route all key processing through command subsystem, mirroring ImGuiInputHandler
|
|
||||||
|
|
||||||
#include "QtInputHandler.h"
|
|
||||||
|
|
||||||
#include <QKeyEvent>
|
|
||||||
|
|
||||||
#include <ncurses.h>
|
|
||||||
|
|
||||||
#include "Editor.h"
|
|
||||||
#include "KKeymap.h"
|
|
||||||
|
|
||||||
// Temporary verbose logging to debug macOS Qt key translation issues
|
|
||||||
// Default to off; enable by defining QT_IH_DEBUG=1 at compile time when needed.
|
|
||||||
#ifndef QT_IH_DEBUG
|
|
||||||
#define QT_IH_DEBUG 0
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if QT_IH_DEBUG
|
|
||||||
#include <cstdio>
|
|
||||||
static const char *
|
|
||||||
mods_str(Qt::KeyboardModifiers m)
|
|
||||||
{
|
|
||||||
static thread_local char buf[64];
|
|
||||||
buf[0] = '\0';
|
|
||||||
bool first = true;
|
|
||||||
auto add = [&](const char *s) {
|
|
||||||
if (!first)
|
|
||||||
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "|");
|
|
||||||
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "%s", s);
|
|
||||||
first = false;
|
|
||||||
};
|
|
||||||
if (m & Qt::ShiftModifier)
|
|
||||||
add("Shift");
|
|
||||||
if (m & Qt::ControlModifier)
|
|
||||||
add("Ctrl");
|
|
||||||
if (m & Qt::AltModifier)
|
|
||||||
add("Alt");
|
|
||||||
if (m & Qt::MetaModifier)
|
|
||||||
add("Meta");
|
|
||||||
if (first)
|
|
||||||
std::snprintf(buf, sizeof(buf), "none");
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
#define LOGF(...) std::fprintf(stderr, __VA_ARGS__)
|
|
||||||
#else
|
|
||||||
#define LOGF(...) ((void)0)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static bool
|
|
||||||
IsPrintableQt(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
// Printable if it yields non-empty text and no Ctrl/Meta modifier
|
|
||||||
if (e.modifiers() & (Qt::ControlModifier | Qt::MetaModifier))
|
|
||||||
return false;
|
|
||||||
const QString t = e.text();
|
|
||||||
return !t.isEmpty() && !t.at(0).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static int
|
|
||||||
ToAsciiKey(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
const QString t = e.text();
|
|
||||||
if (!t.isEmpty()) {
|
|
||||||
const QChar c = t.at(0);
|
|
||||||
if (!c.isNull())
|
|
||||||
return KLowerAscii(c.unicode());
|
|
||||||
}
|
|
||||||
// When modifiers (like Control) are held, Qt::text() can be empty on macOS.
|
|
||||||
// Fall back to mapping common virtual keys to ASCII.
|
|
||||||
switch (e.key()) {
|
|
||||||
case Qt::Key_A:
|
|
||||||
return 'a';
|
|
||||||
case Qt::Key_B:
|
|
||||||
return 'b';
|
|
||||||
case Qt::Key_C:
|
|
||||||
return 'c';
|
|
||||||
case Qt::Key_D:
|
|
||||||
return 'd';
|
|
||||||
case Qt::Key_E:
|
|
||||||
return 'e';
|
|
||||||
case Qt::Key_F:
|
|
||||||
return 'f';
|
|
||||||
case Qt::Key_G:
|
|
||||||
return 'g';
|
|
||||||
case Qt::Key_H:
|
|
||||||
return 'h';
|
|
||||||
case Qt::Key_I:
|
|
||||||
return 'i';
|
|
||||||
case Qt::Key_J:
|
|
||||||
return 'j';
|
|
||||||
case Qt::Key_K:
|
|
||||||
return 'k';
|
|
||||||
case Qt::Key_L:
|
|
||||||
return 'l';
|
|
||||||
case Qt::Key_M:
|
|
||||||
return 'm';
|
|
||||||
case Qt::Key_N:
|
|
||||||
return 'n';
|
|
||||||
case Qt::Key_O:
|
|
||||||
return 'o';
|
|
||||||
case Qt::Key_P:
|
|
||||||
return 'p';
|
|
||||||
case Qt::Key_Q:
|
|
||||||
return 'q';
|
|
||||||
case Qt::Key_R:
|
|
||||||
return 'r';
|
|
||||||
case Qt::Key_S:
|
|
||||||
return 's';
|
|
||||||
case Qt::Key_T:
|
|
||||||
return 't';
|
|
||||||
case Qt::Key_U:
|
|
||||||
return 'u';
|
|
||||||
case Qt::Key_V:
|
|
||||||
return 'v';
|
|
||||||
case Qt::Key_W:
|
|
||||||
return 'w';
|
|
||||||
case Qt::Key_X:
|
|
||||||
return 'x';
|
|
||||||
case Qt::Key_Y:
|
|
||||||
return 'y';
|
|
||||||
case Qt::Key_Z:
|
|
||||||
return 'z';
|
|
||||||
case Qt::Key_0:
|
|
||||||
return '0';
|
|
||||||
case Qt::Key_1:
|
|
||||||
return '1';
|
|
||||||
case Qt::Key_2:
|
|
||||||
return '2';
|
|
||||||
case Qt::Key_3:
|
|
||||||
return '3';
|
|
||||||
case Qt::Key_4:
|
|
||||||
return '4';
|
|
||||||
case Qt::Key_5:
|
|
||||||
return '5';
|
|
||||||
case Qt::Key_6:
|
|
||||||
return '6';
|
|
||||||
case Qt::Key_7:
|
|
||||||
return '7';
|
|
||||||
case Qt::Key_8:
|
|
||||||
return '8';
|
|
||||||
case Qt::Key_9:
|
|
||||||
return '9';
|
|
||||||
case Qt::Key_Comma:
|
|
||||||
return ',';
|
|
||||||
case Qt::Key_Period:
|
|
||||||
return '.';
|
|
||||||
case Qt::Key_Semicolon:
|
|
||||||
return ';';
|
|
||||||
case Qt::Key_Apostrophe:
|
|
||||||
return '\'';
|
|
||||||
case Qt::Key_Minus:
|
|
||||||
return '-';
|
|
||||||
case Qt::Key_Equal:
|
|
||||||
return '=';
|
|
||||||
case Qt::Key_Slash:
|
|
||||||
return '/';
|
|
||||||
case Qt::Key_Backslash:
|
|
||||||
return '\\';
|
|
||||||
case Qt::Key_BracketLeft:
|
|
||||||
return '[';
|
|
||||||
case Qt::Key_BracketRight:
|
|
||||||
return ']';
|
|
||||||
case Qt::Key_QuoteLeft:
|
|
||||||
return '`';
|
|
||||||
case Qt::Key_Space:
|
|
||||||
return ' ';
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Case-preserving ASCII derivation for k-prefix handling where we need to
|
|
||||||
// distinguish between 'C' and 'c'. Falls back to virtual-key mapping if
|
|
||||||
// event text is unavailable (common when Control/Meta held on macOS).
|
|
||||||
static int
|
|
||||||
ToAsciiKeyPreserveCase(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
const QString t = e.text();
|
|
||||||
if (!t.isEmpty()) {
|
|
||||||
const QChar c = t.at(0);
|
|
||||||
if (!c.isNull())
|
|
||||||
return c.unicode();
|
|
||||||
}
|
|
||||||
// Fall back to virtual key mapping (letters as uppercase A..Z)
|
|
||||||
switch (e.key()) {
|
|
||||||
case Qt::Key_A:
|
|
||||||
return 'A';
|
|
||||||
case Qt::Key_B:
|
|
||||||
return 'B';
|
|
||||||
case Qt::Key_C:
|
|
||||||
return 'C';
|
|
||||||
case Qt::Key_D:
|
|
||||||
return 'D';
|
|
||||||
case Qt::Key_E:
|
|
||||||
return 'E';
|
|
||||||
case Qt::Key_F:
|
|
||||||
return 'F';
|
|
||||||
case Qt::Key_G:
|
|
||||||
return 'G';
|
|
||||||
case Qt::Key_H:
|
|
||||||
return 'H';
|
|
||||||
case Qt::Key_I:
|
|
||||||
return 'I';
|
|
||||||
case Qt::Key_J:
|
|
||||||
return 'J';
|
|
||||||
case Qt::Key_K:
|
|
||||||
return 'K';
|
|
||||||
case Qt::Key_L:
|
|
||||||
return 'L';
|
|
||||||
case Qt::Key_M:
|
|
||||||
return 'M';
|
|
||||||
case Qt::Key_N:
|
|
||||||
return 'N';
|
|
||||||
case Qt::Key_O:
|
|
||||||
return 'O';
|
|
||||||
case Qt::Key_P:
|
|
||||||
return 'P';
|
|
||||||
case Qt::Key_Q:
|
|
||||||
return 'Q';
|
|
||||||
case Qt::Key_R:
|
|
||||||
return 'R';
|
|
||||||
case Qt::Key_S:
|
|
||||||
return 'S';
|
|
||||||
case Qt::Key_T:
|
|
||||||
return 'T';
|
|
||||||
case Qt::Key_U:
|
|
||||||
return 'U';
|
|
||||||
case Qt::Key_V:
|
|
||||||
return 'V';
|
|
||||||
case Qt::Key_W:
|
|
||||||
return 'W';
|
|
||||||
case Qt::Key_X:
|
|
||||||
return 'X';
|
|
||||||
case Qt::Key_Y:
|
|
||||||
return 'Y';
|
|
||||||
case Qt::Key_Z:
|
|
||||||
return 'Z';
|
|
||||||
case Qt::Key_Comma:
|
|
||||||
return ',';
|
|
||||||
case Qt::Key_Period:
|
|
||||||
return '.';
|
|
||||||
case Qt::Key_Semicolon:
|
|
||||||
return ';';
|
|
||||||
case Qt::Key_Apostrophe:
|
|
||||||
return '\'';
|
|
||||||
case Qt::Key_Minus:
|
|
||||||
return '-';
|
|
||||||
case Qt::Key_Equal:
|
|
||||||
return '=';
|
|
||||||
case Qt::Key_Slash:
|
|
||||||
return '/';
|
|
||||||
case Qt::Key_Backslash:
|
|
||||||
return '\\';
|
|
||||||
case Qt::Key_BracketLeft:
|
|
||||||
return '[';
|
|
||||||
case Qt::Key_BracketRight:
|
|
||||||
return ']';
|
|
||||||
case Qt::Key_QuoteLeft:
|
|
||||||
return '`';
|
|
||||||
case Qt::Key_Space:
|
|
||||||
return ' ';
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
bool
|
|
||||||
QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
const Qt::KeyboardModifiers mods = e.modifiers();
|
|
||||||
LOGF("[QtIH] keyPress key=0x%X mods=%s text='%s' k_prefix=%d k_ctrl_pending=%d esc_meta=%d\n",
|
|
||||||
e.key(), mods_str(mods), e.text().toUtf8().constData(), (int)k_prefix_, (int)k_ctrl_pending_,
|
|
||||||
(int)esc_meta_);
|
|
||||||
|
|
||||||
// Control-chord detection: only treat the physical Control key as control-like.
|
|
||||||
// Do NOT include Meta (Command) here so that ⌘-letter shortcuts do not fall into
|
|
||||||
// the Ctrl map (prevents ⌘-T being mistaken for C-t).
|
|
||||||
const bool ctrl_like = (mods & Qt::ControlModifier);
|
|
||||||
|
|
||||||
// 1) Universal argument digits (when active), consume digits without enqueuing commands
|
|
||||||
if (ed_ && ed_
|
|
||||||
|
|
||||||
->
|
|
||||||
UArg() != 0
|
|
||||||
) {
|
|
||||||
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
|
|
||||||
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
|
|
||||||
int d = e.key() - Qt::Key_0;
|
|
||||||
ed_->UArgDigit(d);
|
|
||||||
// request status refresh
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::UArgStatus, std::string(), 0});
|
|
||||||
LOGF("[QtIH] UArg digit %d -> enqueue UArgStatus\n", d);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Enter k-prefix on C-k
|
|
||||||
if (ctrl_like && (e.key() == Qt::Key_K)) {
|
|
||||||
k_prefix_ = true;
|
|
||||||
k_ctrl_pending_ = false;
|
|
||||||
LOGF("[QtIH] Enter KPrefix\n");
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::KPrefix, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) If currently in k-prefix, resolve next key via KLookupKCommand
|
|
||||||
if (k_prefix_) {
|
|
||||||
// ESC/meta prefix should not interfere with k-suffix resolution
|
|
||||||
esc_meta_ = false;
|
|
||||||
// Support literal 'C' (uppercase) or '^' to indicate the next key is Ctrl-qualified.
|
|
||||||
// Use case-preserving derivation so that 'c' (lowercase) can still be a valid suffix
|
|
||||||
// like C-k c (BufferClose).
|
|
||||||
int ascii_raw = ToAsciiKeyPreserveCase(e);
|
|
||||||
if (ascii_raw == 'C' || ascii_raw == '^') {
|
|
||||||
k_ctrl_pending_ = true;
|
|
||||||
if (ed_)
|
|
||||||
ed_->SetStatus("C-k C _");
|
|
||||||
LOGF("[QtIH] KPrefix: set k_ctrl_pending via '%c'\n", (ascii_raw == 'C') ? 'C' : '^');
|
|
||||||
return true; // consume, wait for next key
|
|
||||||
}
|
|
||||||
int ascii_key = (ascii_raw != 0) ? ascii_raw : ToAsciiKey(e);
|
|
||||||
int lower = KLowerAscii(ascii_key);
|
|
||||||
// Only pass a control suffix for specific supported keys (d/x/q),
|
|
||||||
// matching ImGui behavior so that holding Ctrl during the suffix
|
|
||||||
// doesn't break other mappings like C-k c (BufferClose).
|
|
||||||
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
|
|
||||||
bool pass_ctrl = (ctrl_like || k_ctrl_pending_) && ctrl_suffix_supported;
|
|
||||||
k_ctrl_pending_ = false; // consume pending qualifier on any suffix
|
|
||||||
LOGF("[QtIH] KPrefix: ascii_key=%d lower=%d pass_ctrl=%d\n", ascii_key, lower, (int)pass_ctrl);
|
|
||||||
if (ascii_key != 0) {
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
|
|
||||||
LOGF("[QtIH] KPrefix: mapped to command id=%d\n", (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
} else {
|
|
||||||
// Unknown k-command: notify
|
|
||||||
std::string a;
|
|
||||||
a.push_back(static_cast<char>(ascii_key));
|
|
||||||
LOGF("[QtIH] KPrefix: unknown command for '%c'\n", (char)ascii_key);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::UnknownKCommand, a, 0});
|
|
||||||
}
|
|
||||||
k_prefix_ = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// If not resolvable, consume and exit k-prefix
|
|
||||||
k_prefix_ = false;
|
|
||||||
LOGF("[QtIH] KPrefix: unresolved key; exiting prefix\n");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3.5) GUI shortcut: Command/Meta + T opens the visual font picker (Qt only).
|
|
||||||
// Require Meta present and Control NOT present so Ctrl-T never triggers this.
|
|
||||||
if ((mods & Qt::MetaModifier) && !(mods & Qt::ControlModifier) && e.key() == Qt::Key_T) {
|
|
||||||
LOGF("[QtIH] Meta/Super-T -> VisualFontPickerToggle\n");
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::VisualFontPickerToggle, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) ESC as Meta prefix (set state). Alt/Meta chord handled below directly.
|
|
||||||
if (e.key() == Qt::Key_Escape) {
|
|
||||||
esc_meta_ = true;
|
|
||||||
LOGF("[QtIH] ESC: set esc_meta\n");
|
|
||||||
return true; // consumed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Alt/Meta bindings (ESC f/b equivalent). Handle either Alt/Meta or pending esc_meta_
|
|
||||||
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
|
|
||||||
#if defined(__APPLE__)
|
|
||||||
if (esc_meta_ || (mods & Qt::AltModifier)) {
|
|
||||||
#else
|
|
||||||
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
|
|
||||||
|
|
||||||
#endif
|
|
||||||
int ascii_key = 0;
|
|
||||||
if (e.key() == Qt::Key_Backspace) {
|
|
||||||
ascii_key = KEY_BACKSPACE;
|
|
||||||
} else if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
|
|
||||||
ascii_key = 'a' + (e.key() - Qt::Key_A);
|
|
||||||
} else if (e.key() == Qt::Key_Comma) {
|
|
||||||
ascii_key = '<';
|
|
||||||
} else if (e.key() == Qt::Key_Period) {
|
|
||||||
ascii_key = '>';
|
|
||||||
}
|
|
||||||
// If still unknown, try deriving from text (covers digits, punctuation, locale)
|
|
||||||
if (ascii_key == 0) {
|
|
||||||
ascii_key = ToAsciiKey(e);
|
|
||||||
}
|
|
||||||
esc_meta_ = false; // one-shot regardless
|
|
||||||
if (ascii_key != 0) {
|
|
||||||
ascii_key = KLowerAscii(ascii_key);
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupEscCommand(ascii_key, id)) {
|
|
||||||
LOGF("[QtIH] ESC/Meta: mapped '%d' -> id=%d\n", ascii_key, (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
// Report invalid ESC sequence just like ImGui path
|
|
||||||
LOGF("[QtIH] ESC/Meta: unknown command for ascii=%d\n", ascii_key);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::UnknownEscCommand, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nothing derivable: consume (ESC prefix cleared) and do not insert text
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Control-chord direct mappings (e.g., C-n/C-p/C-f/C-b...)
|
|
||||||
if (ctrl_like) {
|
|
||||||
// Universal argument handling: C-u starts collection; C-g cancels
|
|
||||||
if (e.key() == Qt::Key_U) {
|
|
||||||
if (ed_)
|
|
||||||
ed_->UArgStart();
|
|
||||||
LOGF("[QtIH] Ctrl-chord: start universal argument\n");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (e.key() == Qt::Key_G) {
|
|
||||||
if (ed_)
|
|
||||||
ed_->UArgClear();
|
|
||||||
k_ctrl_pending_ = false;
|
|
||||||
k_prefix_ = false;
|
|
||||||
LOGF("[QtIH] Ctrl-chord: cancel universal argument and k-prefix via C-g\n");
|
|
||||||
// Fall through to map C-g to Refresh via ctrl map
|
|
||||||
}
|
|
||||||
if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
|
|
||||||
int ascii_key = 'a' + (e.key() - Qt::Key_A);
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupCtrlCommand(ascii_key, id)) {
|
|
||||||
LOGF("[QtIH] Ctrl-chord: 'C-%c' -> id=%d\n", (char)ascii_key, (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no mapping, continue to allow other keys below
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7) Special navigation/edit keys (match ImGui behavior)
|
|
||||||
{
|
|
||||||
CommandId id;
|
|
||||||
bool has = false;
|
|
||||||
switch (e.key()) {
|
|
||||||
case Qt::Key_Return:
|
|
||||||
case Qt::Key_Enter:
|
|
||||||
id = CommandId::Newline;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Backspace:
|
|
||||||
id = CommandId::Backspace;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Delete:
|
|
||||||
id = CommandId::DeleteChar;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Left:
|
|
||||||
id = CommandId::MoveLeft;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Right:
|
|
||||||
id = CommandId::MoveRight;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Up:
|
|
||||||
id = CommandId::MoveUp;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Down:
|
|
||||||
id = CommandId::MoveDown;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Home:
|
|
||||||
id = CommandId::MoveHome;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_End:
|
|
||||||
id = CommandId::MoveEnd;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_PageUp:
|
|
||||||
id = CommandId::PageUp;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_PageDown:
|
|
||||||
id = CommandId::PageDown;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (has) {
|
|
||||||
LOGF("[QtIH] Special key -> id=%d\n", (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8) Insert printable text
|
|
||||||
if (IsPrintableQt(e)) {
|
|
||||||
std::string s = e.text().toStdString();
|
|
||||||
if (!s.empty()) {
|
|
||||||
LOGF("[QtIH] InsertText '%s'\n", s.c_str());
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::InsertText, s, 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGF("[QtIH] Unhandled key\n");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
bool
|
|
||||||
QtInputHandler::Poll(MappedInput &out)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
|
||||||
if (q_.empty())
|
|
||||||
return false;
|
|
||||||
out = q_.front();
|
|
||||||
q_.pop();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/*
|
|
||||||
* QtInputHandler - Qt-based input mapping for GUI mode
|
|
||||||
*/
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <mutex>
|
|
||||||
#include <queue>
|
|
||||||
|
|
||||||
#include "InputHandler.h"
|
|
||||||
|
|
||||||
class QKeyEvent;
|
|
||||||
|
|
||||||
class QtInputHandler final : public InputHandler {
|
|
||||||
public:
|
|
||||||
QtInputHandler() = default;
|
|
||||||
|
|
||||||
~QtInputHandler() override = default;
|
|
||||||
|
|
||||||
|
|
||||||
void Attach(Editor *ed) override
|
|
||||||
{
|
|
||||||
ed_ = ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Translate a Qt key event to editor command and enqueue if applicable.
|
|
||||||
// Returns true if it produced a mapped command or consumed input.
|
|
||||||
bool ProcessKeyEvent(const QKeyEvent &e);
|
|
||||||
|
|
||||||
bool Poll(MappedInput &out) override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::mutex mu_;
|
|
||||||
std::queue<MappedInput> q_;
|
|
||||||
bool k_prefix_ = false;
|
|
||||||
bool k_ctrl_pending_ = false; // C-k C-… qualifier
|
|
||||||
bool esc_meta_ = false; // ESC-prefix for next key
|
|
||||||
bool suppress_text_input_once_ = false; // reserved (Qt sends text in keyPressEvent)
|
|
||||||
Editor *ed_ = nullptr;
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
#include "QtRenderer.h"
|
|
||||||
|
|
||||||
#include <QWidget>
|
|
||||||
#include <QPainter>
|
|
||||||
#include <QPaintEvent>
|
|
||||||
#include <QFont>
|
|
||||||
#include <QFontMetrics>
|
|
||||||
|
|
||||||
#include "Editor.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
class EditorWidget : public QWidget {
|
|
||||||
public:
|
|
||||||
explicit EditorWidget(QWidget *parent = nullptr) : QWidget(parent)
|
|
||||||
{
|
|
||||||
setAttribute(Qt::WA_OpaquePaintEvent);
|
|
||||||
setFocusPolicy(Qt::StrongFocus);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetEditor(Editor *ed)
|
|
||||||
{
|
|
||||||
ed_ = ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void paintEvent(QPaintEvent *event) override
|
|
||||||
{
|
|
||||||
Q_UNUSED(event);
|
|
||||||
QPainter p(this);
|
|
||||||
// Background
|
|
||||||
const QColor bg(28, 28, 30);
|
|
||||||
p.fillRect(rect(), bg);
|
|
||||||
|
|
||||||
// Font and metrics
|
|
||||||
QFont f("JetBrains Mono", 13);
|
|
||||||
p.setFont(f);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = fm.height();
|
|
||||||
|
|
||||||
// Title
|
|
||||||
p.setPen(QColor(220, 220, 220));
|
|
||||||
p.drawText(8, fm.ascent() + 4, QStringLiteral("kte (Qt frontend)"));
|
|
||||||
|
|
||||||
// Status bar at bottom
|
|
||||||
const int bar_h = line_h + 6; // padding
|
|
||||||
const int bar_y = height() - bar_h;
|
|
||||||
QRect status_rect(0, bar_y, width(), bar_h);
|
|
||||||
p.fillRect(status_rect, QColor(40, 40, 44));
|
|
||||||
p.setPen(QColor(180, 180, 140));
|
|
||||||
if (ed_) {
|
|
||||||
const QString status = QString::fromStdString(ed_->Status());
|
|
||||||
// draw at baseline within the bar
|
|
||||||
const int baseline = bar_y + 3 + fm.ascent();
|
|
||||||
p.drawText(8, baseline, status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
Editor *ed_ = nullptr;
|
|
||||||
};
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void
|
|
||||||
QtRenderer::Draw(Editor &ed)
|
|
||||||
{
|
|
||||||
if (!widget_)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// If our widget is an EditorWidget, pass the editor pointer for painting
|
|
||||||
if (auto *ew = dynamic_cast<EditorWidget *>(widget_)) {
|
|
||||||
ew->SetEditor(&ed);
|
|
||||||
}
|
|
||||||
// Request a repaint
|
|
||||||
widget_->update();
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/*
|
|
||||||
* QtRenderer - minimal Qt-based renderer
|
|
||||||
*/
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "Renderer.h"
|
|
||||||
|
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
class QtRenderer final : public Renderer {
|
|
||||||
public:
|
|
||||||
QtRenderer() = default;
|
|
||||||
|
|
||||||
~QtRenderer() override = default;
|
|
||||||
|
|
||||||
|
|
||||||
void Attach(QWidget *widget)
|
|
||||||
{
|
|
||||||
widget_ = widget;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void Draw(Editor &ed) override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
QWidget *widget_ = nullptr; // not owned
|
|
||||||
};
|
|
||||||
+4
-5
@@ -848,7 +848,6 @@ pub enum CommandId {
|
|||||||
|
|
||||||
// GUI-specific
|
// GUI-specific
|
||||||
VisualFilePickerToggle,
|
VisualFilePickerToggle,
|
||||||
VisualFontPickerToggle,
|
|
||||||
ThemeNext,
|
ThemeNext,
|
||||||
ThemePrev,
|
ThemePrev,
|
||||||
ThemeSetByName,
|
ThemeSetByName,
|
||||||
@@ -938,7 +937,7 @@ pub enum CommandError {
|
|||||||
**Current C++ Implementation:**
|
**Current C++ Implementation:**
|
||||||
|
|
||||||
- `Frontend` trait with Init/Step/Shutdown lifecycle
|
- `Frontend` trait with Init/Step/Shutdown lifecycle
|
||||||
- Three implementations: Terminal (ncurses), ImGui (SDL2/OpenGL), Qt
|
- Two implementations: Terminal (ncurses), ImGui (SDL2/OpenGL)
|
||||||
- Step() does one iteration: poll input, dispatch commands, render
|
- Step() does one iteration: poll input, dispatch commands, render
|
||||||
- Each frontend owns InputHandler and Renderer instances
|
- Each frontend owns InputHandler and Renderer instances
|
||||||
|
|
||||||
@@ -986,7 +985,7 @@ pub enum FrontendError {
|
|||||||
- `InputHandler` trait with Poll() returning `MappedInput`
|
- `InputHandler` trait with Poll() returning `MappedInput`
|
||||||
- `MappedInput`: hasCommand bool, CommandId, arg string, count
|
- `MappedInput`: hasCommand bool, CommandId, arg string, count
|
||||||
- Terminal: ncurses-based, translates keys to commands
|
- Terminal: ncurses-based, translates keys to commands
|
||||||
- ImGui/Qt: event-based, handles text input differently
|
- ImGui: event-based, handles text input differently
|
||||||
- Keymap lookups via `KKeymap` functions (KCommand, Ctrl, Esc)
|
- Keymap lookups via `KKeymap` functions (KCommand, Ctrl, Esc)
|
||||||
|
|
||||||
**Rust Design:**
|
**Rust Design:**
|
||||||
@@ -1075,7 +1074,7 @@ impl InputHandler for TerminalInputHandler {
|
|||||||
|
|
||||||
- `Renderer` trait with single Draw(Editor&) method
|
- `Renderer` trait with single Draw(Editor&) method
|
||||||
- Terminal: ncurses-based, manual cursor positioning
|
- Terminal: ncurses-based, manual cursor positioning
|
||||||
- ImGui/Qt: retained mode GUI, draw calls within Begin/End blocks
|
- ImGui: immediate mode GUI, draw calls within Begin/End blocks
|
||||||
- Syntax highlighting integrated via Buffer::Highlighter()
|
- Syntax highlighting integrated via Buffer::Highlighter()
|
||||||
|
|
||||||
**Rust Design:**
|
**Rust Design:**
|
||||||
@@ -1200,7 +1199,7 @@ impl Frontend for TerminalFrontend {
|
|||||||
|
|
||||||
#### 9.2 GUI Frontend (egui-based)
|
#### 9.2 GUI Frontend (egui-based)
|
||||||
|
|
||||||
For GUI, consider replacing ImGui/Qt with `egui` (pure Rust, immediate
|
For GUI, consider replacing ImGui with `egui` (pure Rust, immediate
|
||||||
mode):
|
mode):
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
|||||||
+344
@@ -0,0 +1,344 @@
|
|||||||
|
#include "RegexEngine.h"
|
||||||
|
|
||||||
|
#include <regex>
|
||||||
|
|
||||||
|
#include "RegexGuard.h"
|
||||||
|
|
||||||
|
#if defined(KTE_USE_PCRE2)
|
||||||
|
#define PCRE2_CODE_UNIT_WIDTH 8
|
||||||
|
#include <pcre2.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
namespace {
|
||||||
|
// Expand an ECMAScript replacement for one match, as std::regex_replace
|
||||||
|
// does: $` is the text since the previous match (match_results::prefix()).
|
||||||
|
// group(i, begin, end) returns whether group i participated and where;
|
||||||
|
// ngroups counts group 0.
|
||||||
|
template<typename GroupFn>
|
||||||
|
void
|
||||||
|
append_format(std::string &out, std::string_view fmt, std::string_view subject, std::size_t prev_end,
|
||||||
|
std::size_t ms, std::size_t me, std::size_t ngroups, GroupFn &&group)
|
||||||
|
{
|
||||||
|
for (std::size_t i = 0; i < fmt.size(); ++i) {
|
||||||
|
const char c = fmt[i];
|
||||||
|
if (c != '$' || i + 1 >= fmt.size()) {
|
||||||
|
out.push_back(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const char n = fmt[i + 1];
|
||||||
|
if (n == '$') {
|
||||||
|
out.push_back('$');
|
||||||
|
++i;
|
||||||
|
} else if (n == '&') {
|
||||||
|
out.append(subject.substr(ms, me - ms));
|
||||||
|
++i;
|
||||||
|
} else if (n == '`') {
|
||||||
|
out.append(subject.substr(prev_end, ms - prev_end));
|
||||||
|
++i;
|
||||||
|
} else if (n == '\'') {
|
||||||
|
out.append(subject.substr(me));
|
||||||
|
++i;
|
||||||
|
} else if (n >= '0' && n <= '9') {
|
||||||
|
// As libstdc++'s match_results::format: up to two digits, read
|
||||||
|
// greedily; $0 is the whole match; a group the pattern does not
|
||||||
|
// have expands to nothing.
|
||||||
|
std::size_t idx = static_cast<std::size_t>(n - '0');
|
||||||
|
++i;
|
||||||
|
if (i + 1 < fmt.size() && fmt[i + 1] >= '0' && fmt[i + 1] <= '9') {
|
||||||
|
idx = idx * 10 + static_cast<std::size_t>(fmt[i + 1] - '0');
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
std::size_t b = 0, e = 0;
|
||||||
|
if (idx == 0)
|
||||||
|
out.append(subject.substr(ms, me - ms));
|
||||||
|
else if (idx < ngroups && group(idx, b, e))
|
||||||
|
out.append(subject.substr(b, e - b));
|
||||||
|
} else {
|
||||||
|
out.push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(KTE_USE_PCRE2)
|
||||||
|
|
||||||
|
// PCRE2 gives up on a match attempt after this many internal steps
|
||||||
|
// (catastrophic backtracking), reporting it instead of running for ages.
|
||||||
|
static constexpr std::uint32_t kMatchLimit = 10000000;
|
||||||
|
|
||||||
|
|
||||||
|
struct Regex::Impl {
|
||||||
|
pcre2_code *code = nullptr;
|
||||||
|
pcre2_match_data *md = nullptr;
|
||||||
|
pcre2_match_context *mctx = nullptr;
|
||||||
|
std::size_t ngroups = 1; // including group 0
|
||||||
|
mutable bool limit_hit = false;
|
||||||
|
|
||||||
|
|
||||||
|
~Impl()
|
||||||
|
{
|
||||||
|
if (md)
|
||||||
|
pcre2_match_data_free(md);
|
||||||
|
if (mctx)
|
||||||
|
pcre2_match_context_free(mctx);
|
||||||
|
if (code)
|
||||||
|
pcre2_code_free(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// pcre2_match with a subject that is never a null pointer.
|
||||||
|
int match(std::string_view s, std::size_t from, std::uint32_t opts) const
|
||||||
|
{
|
||||||
|
static const char empty = '\0';
|
||||||
|
const char *p = s.data() ? s.data() : ∅
|
||||||
|
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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* RegexEngine.h - the regex engine behind search, replace and match
|
||||||
|
* highlighting.
|
||||||
|
*
|
||||||
|
* With KTE_USE_PCRE2 (the default when libpcre2-8 is found) patterns are
|
||||||
|
* compiled by PCRE2 with its JIT: typically one to two orders of magnitude
|
||||||
|
* faster than std::regex, no recursion on the C stack, and a match limit
|
||||||
|
* that stops catastrophic backtracking (e.g. "^(a+)+$") instead of hanging
|
||||||
|
* the editor. Otherwise std::regex (ECMAScript) is used, run on a large
|
||||||
|
* stack (see RunGuarded). The syntax both accept covers ordinary use;
|
||||||
|
* PCRE2 additionally has lookbehind, possessive quantifiers and the like.
|
||||||
|
*
|
||||||
|
* Replacement strings use ECMAScript syntax with either engine, as
|
||||||
|
* std::regex_replace (libstdc++) reads it: $& or $0 (the match), $1..$99
|
||||||
|
* (groups, two digits read greedily; a group the pattern lacks expands to
|
||||||
|
* nothing), $` (the text since the previous match), $' (the rest of the
|
||||||
|
* line), $$ (a dollar sign). As in ECMAScript, '.' matches neither CR nor
|
||||||
|
* LF and '$' matches only at the end of the line's text.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
class Regex {
|
||||||
|
public:
|
||||||
|
Regex();
|
||||||
|
|
||||||
|
~Regex();
|
||||||
|
|
||||||
|
Regex(Regex &&other) noexcept;
|
||||||
|
|
||||||
|
Regex &operator=(Regex &&other) noexcept;
|
||||||
|
|
||||||
|
Regex(const Regex &) = delete;
|
||||||
|
|
||||||
|
Regex &operator=(const Regex &) = delete;
|
||||||
|
|
||||||
|
// Compile; on failure err describes the problem and Valid() is false.
|
||||||
|
bool Compile(std::string_view pattern, std::string &err);
|
||||||
|
|
||||||
|
[[nodiscard]] bool Valid() const;
|
||||||
|
|
||||||
|
// The first match in `subject` starting at byte `from` or later; `from`
|
||||||
|
// may be inside the subject (anchors and lookbehind see what precedes
|
||||||
|
// it). False if there is none, or if the engine gave up (LimitHit()).
|
||||||
|
bool Search(std::string_view subject, std::size_t from, std::size_t &pos, std::size_t &len) const;
|
||||||
|
|
||||||
|
// Every non-overlapping match replaced by `fmt` (ECMAScript syntax, see
|
||||||
|
// above); an empty match is not repeated at the same position.
|
||||||
|
[[nodiscard]] std::string ReplaceAll(std::string_view subject, std::string_view fmt) const;
|
||||||
|
|
||||||
|
// Whether the last Search/ReplaceAll stopped at the engine's match limit.
|
||||||
|
[[nodiscard]] bool LimitHit() const;
|
||||||
|
|
||||||
|
// Run fn, which does regex work that may span long text, where the
|
||||||
|
// engine cannot overflow the stack: std::regex recurses once per matched
|
||||||
|
// character, so fn runs to completion on a thread with a large stack
|
||||||
|
// (RegexGuard.h); PCRE2 does not recurse on the C stack, so fn runs
|
||||||
|
// inline. Exceptions thrown by fn are rethrown.
|
||||||
|
static void RunGuarded(const std::function<void()> &fn);
|
||||||
|
|
||||||
|
// "PCRE2" or "std::regex".
|
||||||
|
static const char *EngineName();
|
||||||
|
|
||||||
|
// The longest line search-as-you-type and match highlighting run the
|
||||||
|
// regex over (per keystroke/frame); std::regex can be quadratic in line
|
||||||
|
// length with no way to interrupt it, PCRE2 is bounded by its limit.
|
||||||
|
static std::size_t IncrementalLineLimit();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#include "RegexGuard.h"
|
||||||
|
|
||||||
|
#include <exception>
|
||||||
|
#include <pthread.h>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
namespace {
|
||||||
|
// 1 GiB supports matches spanning several million characters.
|
||||||
|
constexpr std::size_t kLargeStackBytes = std::size_t{1} << 30;
|
||||||
|
|
||||||
|
struct Job {
|
||||||
|
const std::function<void()> *fn;
|
||||||
|
std::exception_ptr error;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
void *
|
||||||
|
run_job(void *arg)
|
||||||
|
{
|
||||||
|
auto *job = static_cast<Job *>(arg);
|
||||||
|
try {
|
||||||
|
(*job->fn)();
|
||||||
|
} catch (...) {
|
||||||
|
job->error = std::current_exception();
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
RunWithLargeStack(const std::function<void()> &fn)
|
||||||
|
{
|
||||||
|
Job job{&fn, nullptr};
|
||||||
|
pthread_attr_t attr;
|
||||||
|
bool started = false;
|
||||||
|
pthread_t tid{};
|
||||||
|
if (pthread_attr_init(&attr) == 0) {
|
||||||
|
if (pthread_attr_setstacksize(&attr, kLargeStackBytes) == 0)
|
||||||
|
started = pthread_create(&tid, &attr, run_job, &job) == 0;
|
||||||
|
pthread_attr_destroy(&attr);
|
||||||
|
}
|
||||||
|
if (!started) {
|
||||||
|
fn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pthread_join(tid, nullptr);
|
||||||
|
if (job.error)
|
||||||
|
std::rethrow_exception(job.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// RegexGuard.h - keep std::regex from overflowing the stack
|
||||||
|
//
|
||||||
|
// libstdc++'s std::regex matcher recurses roughly once per character of a
|
||||||
|
// match, so a pattern such as ".*" or "a+" on a line of a few tens of
|
||||||
|
// thousands of characters (minified JS/JSON) overflows a default 8 MiB
|
||||||
|
// stack and crashes the editor.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
// Run fn to completion on a thread with a large stack (address space is
|
||||||
|
// reserved, pages are committed only as used), so regex work over long lines
|
||||||
|
// cannot overflow. If such a thread cannot be created, fn runs on the calling
|
||||||
|
// thread. Exceptions thrown by fn are rethrown to the caller.
|
||||||
|
void RunWithLargeStack(const std::function<void()> &fn);
|
||||||
|
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#include "SearchHighlight.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
SearchHighlight::SearchHighlight(const Editor &ed)
|
||||||
|
{
|
||||||
|
active_ = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||||
|
if (!active_)
|
||||||
|
return;
|
||||||
|
query_ = ed.SearchQuery();
|
||||||
|
regex_ = ed.PromptActive() && (ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
|
||||||
|
ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind);
|
||||||
|
if (regex_) {
|
||||||
|
std::string err;
|
||||||
|
rx_valid_ = rx_.Compile(query_, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
SearchHighlight::Ranges(std::string_view line, std::vector<std::pair<std::size_t, std::size_t> > &out) const
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
if (!active_)
|
||||||
|
return;
|
||||||
|
if (regex_) {
|
||||||
|
if (!rx_valid_ || line.size() > Regex::IncrementalLineLimit())
|
||||||
|
return;
|
||||||
|
std::size_t from = 0, pos = 0, len = 0;
|
||||||
|
while (from <= line.size() && rx_.Search(line, from, pos, len)) {
|
||||||
|
out.emplace_back(pos, pos + len);
|
||||||
|
from = pos + std::max<std::size_t>(len, 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while ((pos = line.find(query_, pos)) != std::string_view::npos) {
|
||||||
|
out.emplace_back(pos, pos + query_.size());
|
||||||
|
pos += query_.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* SearchHighlight.h - the search-match ranges every renderer highlights.
|
||||||
|
*
|
||||||
|
* Built once per frame from the editor's search state (the regex, when the
|
||||||
|
* regex search or regex replace prompt is up, is compiled here once rather
|
||||||
|
* than per line), then asked for each visible line's matches.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "RegexEngine.h"
|
||||||
|
|
||||||
|
class Editor;
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
class SearchHighlight {
|
||||||
|
public:
|
||||||
|
explicit SearchHighlight(const Editor &ed);
|
||||||
|
|
||||||
|
// Whether a search is up with a non-empty query.
|
||||||
|
[[nodiscard]] bool Active() const
|
||||||
|
{
|
||||||
|
return active_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The matches in one line as [start, end) byte ranges, ordered and not
|
||||||
|
// overlapping. Nothing for an invalid pattern (the status line shows the
|
||||||
|
// error) or, in regex mode, a line longer than
|
||||||
|
// Regex::IncrementalLineLimit().
|
||||||
|
void Ranges(std::string_view line, std::vector<std::pair<std::size_t, std::size_t> > &out) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool active_ = false;
|
||||||
|
bool regex_ = false;
|
||||||
|
bool rx_valid_ = false;
|
||||||
|
std::string query_;
|
||||||
|
Regex rx_;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
@@ -24,9 +24,17 @@ namespace kte {
|
|||||||
enum class SwapRecType : std::uint8_t {
|
enum class SwapRecType : std::uint8_t {
|
||||||
INS = 1,
|
INS = 1,
|
||||||
DEL = 2,
|
DEL = 2,
|
||||||
|
// SPLIT and JOIN are no longer written; replay still reads them.
|
||||||
SPLIT = 3,
|
SPLIT = 3,
|
||||||
JOIN = 4,
|
JOIN = 4,
|
||||||
META = 0xF0,
|
META = 0xF0,
|
||||||
|
// A checkpoint too large for one record (a record's length field is 24
|
||||||
|
// bits) is written as BEGIN [encver u8][total u64][crc32 u32], DATA
|
||||||
|
// chunks (raw bytes), END [encver u8]. Replay applies it only at END,
|
||||||
|
// with the size and CRC-32 matching; an unfinished one is ignored.
|
||||||
|
CHKPT_BEGIN = 0xFB,
|
||||||
|
CHKPT_DATA = 0xFC,
|
||||||
|
CHKPT_END = 0xFD,
|
||||||
CHKPT = 0xFE,
|
CHKPT = 0xFE,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +88,19 @@ public:
|
|||||||
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
||||||
void Checkpoint(Buffer *buf = nullptr);
|
void Checkpoint(Buffer *buf = nullptr);
|
||||||
|
|
||||||
|
// Main thread, called once per frame: request the recovery checkpoint of
|
||||||
|
// any journal left with a gap (a lost record) whose last attempt is more
|
||||||
|
// than the retry interval ago. Otherwise that checkpoint is only
|
||||||
|
// requested by the next edit, and a journal that lost a record just
|
||||||
|
// before the user stopped typing would stay behind indefinitely.
|
||||||
|
void RetryGapCheckpoints();
|
||||||
|
|
||||||
|
// Main thread: the oldest pending message for the user (a journal that
|
||||||
|
// another kte holds, or that fell behind because writes failed), or ""
|
||||||
|
// when there is none. Such failures otherwise went only to the error
|
||||||
|
// log, leaving crash recovery silently off or incomplete.
|
||||||
|
std::string TakeUserNotice();
|
||||||
|
|
||||||
|
|
||||||
void SetConfig(const SwapConfig &cfg)
|
void SetConfig(const SwapConfig &cfg)
|
||||||
{
|
{
|
||||||
@@ -94,6 +115,15 @@ public:
|
|||||||
// Detach(buf) or SwapManager destruction.
|
// Detach(buf) or SwapManager destruction.
|
||||||
SwapRecorder *RecorderFor(Buffer *buf);
|
SwapRecorder *RecorderFor(Buffer *buf);
|
||||||
|
|
||||||
|
// Re-key an attached buffer's journal/recorder entries after its Buffer object
|
||||||
|
// has moved to a new address (e.g. std::vector<Buffer> reallocation/erase-shift).
|
||||||
|
// Callers must ensure no swap records for old_addr are in flight (see Flush())
|
||||||
|
// before calling this, and must not call it with an address that isn't
|
||||||
|
// currently attached. Returns the recorder for new_addr (nullptr if old_addr
|
||||||
|
// wasn't attached); the caller is responsible for calling
|
||||||
|
// new_buf->SetSwapRecorder() with the result.
|
||||||
|
SwapRecorder *Rehome(Buffer *old_addr, Buffer *new_addr);
|
||||||
|
|
||||||
// Notify that the buffer's filename changed (e.g., SaveAs)
|
// Notify that the buffer's filename changed (e.g., SaveAs)
|
||||||
void NotifyFilenameChanged(Buffer &buf);
|
void NotifyFilenameChanged(Buffer &buf);
|
||||||
|
|
||||||
@@ -102,15 +132,34 @@ public:
|
|||||||
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
|
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
|
||||||
// state results from applying records up to the failure point; callers should
|
// state results from applying records up to the failure point; callers should
|
||||||
// treat this as a recovery failure and surface `err`.
|
// treat this as a recovery failure and surface `err`.
|
||||||
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
|
//
|
||||||
|
// A record cut short at end of file (a crash mid-append) ends the replay
|
||||||
|
// successfully; err then describes the incomplete record. If valid_bytes
|
||||||
|
// is given, it receives the length of the journal's valid prefix, so a
|
||||||
|
// caller that keeps using the journal can truncate the torn tail first.
|
||||||
|
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err,
|
||||||
|
std::uint64_t *valid_bytes = nullptr);
|
||||||
|
|
||||||
// Compute the swap path for a file-backed buffer by filename.
|
// Compute the swap path for a file-backed buffer by filename.
|
||||||
// Returns empty string if filename is empty.
|
// Returns empty string if filename is empty.
|
||||||
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
|
// True if another process currently holds the journal at swap_path (it
|
||||||
|
// is live, not left by a crash): it must be neither replayed nor removed.
|
||||||
|
static bool JournalInUse(const std::string &swap_path);
|
||||||
|
|
||||||
|
// False if the journal records which version of `file_path` it applies
|
||||||
|
// to and the file on disk no longer matches (edited or replaced since
|
||||||
|
// the journal started); replaying it would apply edits to the wrong text.
|
||||||
|
// Journals without that record (older kte) are assumed to match.
|
||||||
|
static bool JournalMatchesFile(const std::string &swap_path, const std::string &file_path);
|
||||||
|
|
||||||
// Test-only hook to keep swap path logic centralized.
|
// Test-only hook to keep swap path logic centralized.
|
||||||
// (Avoid duplicating naming rules in unit tests.)
|
// (Avoid duplicating naming rules in unit tests.)
|
||||||
#ifdef KTE_TESTS
|
#ifdef KTE_TESTS
|
||||||
|
// Base files larger than this get their CRC on the writer thread.
|
||||||
|
static void SetSyncCrcLimitForTests(std::uint64_t bytes);
|
||||||
|
|
||||||
static std::string ComputeSwapPathForTests(const Buffer &buf)
|
static std::string ComputeSwapPathForTests(const Buffer &buf)
|
||||||
{
|
{
|
||||||
return ComputeSidecarPath(buf);
|
return ComputeSidecarPath(buf);
|
||||||
@@ -165,10 +214,6 @@ private:
|
|||||||
|
|
||||||
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
|
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
|
||||||
|
|
||||||
void RecordSplit(Buffer &buf, int row, int col);
|
|
||||||
|
|
||||||
void RecordJoin(Buffer &buf, int row);
|
|
||||||
|
|
||||||
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
||||||
|
|
||||||
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
||||||
@@ -183,16 +228,53 @@ private:
|
|||||||
std::uint64_t last_chkpt_ns{0};
|
std::uint64_t last_chkpt_ns{0};
|
||||||
std::uint64_t edit_bytes_since_chkpt{0};
|
std::uint64_t edit_bytes_since_chkpt{0};
|
||||||
std::uint64_t approx_size_bytes{0};
|
std::uint64_t approx_size_bytes{0};
|
||||||
|
// A record was lost (write failure, breaker open, oversize). Records are
|
||||||
|
// position-based, so later ones no longer apply to the journal's state:
|
||||||
|
// drop them until a checkpoint re-establishes the full content.
|
||||||
|
bool gap{false};
|
||||||
|
bool gap_notified{false}; // the user was told about the current gap
|
||||||
|
bool chkpt_queued{false}; // a checkpoint is queued and not yet being written
|
||||||
|
std::uint32_t header_version{0}; // of the open journal file (0: none yet)
|
||||||
|
bool lockout_notified{false}; // told about the lock; kept across ResetJournal
|
||||||
|
std::uint64_t gap_chkpt_request_ns{0};
|
||||||
|
bool gap_unfixable_reported{false}; // gap on a buffer too large to checkpoint
|
||||||
|
// Another kte process holds this journal's lock: do not write to it.
|
||||||
|
bool locked_out{false};
|
||||||
|
// Identity of the file the journal's records apply to (captured when
|
||||||
|
// the journal starts) and written into its header, so recovery can
|
||||||
|
// tell whether the file changed since.
|
||||||
|
bool has_base{false};
|
||||||
|
std::uint64_t base_size{0};
|
||||||
|
std::int64_t base_mtime_ns{0};
|
||||||
|
bool has_base_crc{false}; // CRC-32 of the base file's content
|
||||||
|
std::uint32_t base_crc{0};
|
||||||
|
// A large base file's CRC is computed by the writer thread before it
|
||||||
|
// creates the journal (see process_one), not on open/save.
|
||||||
|
std::string base_crc_file;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Pending {
|
struct Pending {
|
||||||
Buffer *buf{nullptr};
|
Buffer *buf{nullptr};
|
||||||
SwapRecType type{SwapRecType::INS};
|
SwapRecType type{SwapRecType::INS};
|
||||||
std::vector<std::uint8_t> payload; // framed payload only
|
std::vector<std::uint8_t> payload; // framed payload only
|
||||||
|
std::string chkpt; // CHKPT: the buffer's content (framed by the writer)
|
||||||
bool urgent_flush{false};
|
bool urgent_flush{false};
|
||||||
std::uint64_t seq{0};
|
std::uint64_t seq{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A record (or, for a large checkpoint, several) as segments to write:
|
||||||
|
// small framing buffers owned here, and a reference to the checkpoint
|
||||||
|
// content (not copied).
|
||||||
|
struct RecordFrames {
|
||||||
|
std::deque<std::vector<std::uint8_t> > owned;
|
||||||
|
std::vector<std::pair<const std::uint8_t *, std::size_t> > segs;
|
||||||
|
std::size_t total = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void frame_pending(const Pending &p, RecordFrames &f);
|
||||||
|
|
||||||
|
static bool write_frames(int fd, const RecordFrames &f);
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||||
|
|
||||||
@@ -204,17 +286,39 @@ private:
|
|||||||
|
|
||||||
static std::string SwapDirRoot();
|
static std::string SwapDirRoot();
|
||||||
|
|
||||||
static bool write_header(int fd);
|
static bool write_header(int fd, const JournalCtx &ctx);
|
||||||
|
|
||||||
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err);
|
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err, bool *locked_out = nullptr);
|
||||||
|
|
||||||
static void close_ctx(JournalCtx &ctx);
|
static void close_ctx(JournalCtx &ctx);
|
||||||
|
|
||||||
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record,
|
static bool compact_to_checkpoint(JournalCtx &ctx, const RecordFrames &chkpt_record,
|
||||||
std::string &err);
|
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);
|
||||||
@@ -235,6 +339,11 @@ 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};
|
||||||
@@ -245,6 +354,10 @@ 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_)
|
||||||
|
|||||||
+17
-4
@@ -22,10 +22,12 @@ Open(const char *path, int flags, mode_t mode)
|
|||||||
int
|
int
|
||||||
Close(int fd)
|
Close(int fd)
|
||||||
{
|
{
|
||||||
int ret;
|
// Do not retry on EINTR: on Linux (and most systems) the descriptor is
|
||||||
do {
|
// released even when close() is interrupted, so a retry could close an
|
||||||
ret = ::close(fd);
|
// fd that another thread (e.g. the swap writer) has just opened.
|
||||||
} while (ret == -1 && errno == EINTR);
|
const int ret = ::close(fd);
|
||||||
|
if (ret == -1 && errno == EINTR)
|
||||||
|
return 0;
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,17 @@ 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)
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-4
@@ -12,11 +12,10 @@ 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);
|
||||||
|
|
||||||
// EINTR-safe wrapper for close(2).
|
// Wrapper for close(2).
|
||||||
// Returns 0 on success, -1 on failure (errno set).
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
// Automatically retries on EINTR.
|
// EINTR is treated as success and never retried: the descriptor has already
|
||||||
// Note: Some systems may not restart close() on EINTR, but we retry anyway
|
// been released on Linux, and retrying could close an unrelated, reused fd.
|
||||||
// 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).
|
||||||
@@ -34,6 +33,11 @@ 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
@@ -0,0 +1,72 @@
|
|||||||
|
// TermWidth.h - terminal cell width of a decoded character
|
||||||
|
//
|
||||||
|
// Shared by TerminalRenderer (drawing and cursor placement) and the command
|
||||||
|
// layer (horizontal scrolling, mouse column mapping) so they always agree.
|
||||||
|
// Tabs are handled by the callers, which know the current column.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cwchar>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
// Tab stops every kTabWidth columns, in every frontend and in the command
|
||||||
|
// layer's horizontal scrolling (they must agree, or the cursor and the
|
||||||
|
// scroll offset are misplaced on lines with tabs).
|
||||||
|
constexpr std::size_t kTabWidth = 8;
|
||||||
|
|
||||||
|
|
||||||
|
// C0 controls and DEL are drawn as caret notation ("^M", "^?"): two cells.
|
||||||
|
// ncurses would otherwise act on them (carriage return, backspace) and
|
||||||
|
// corrupt the line.
|
||||||
|
inline bool
|
||||||
|
IsCaretControl(wchar_t wch)
|
||||||
|
{
|
||||||
|
return (wch >= 0 && wch < 0x20) || wch == 0x7f;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// C1 controls (U+0080..U+009F, also what a stray byte in that range decodes
|
||||||
|
// to) are drawn as '?': sent raw, U+009B is a terminal CSI.
|
||||||
|
inline bool
|
||||||
|
IsC1Control(wchar_t wch)
|
||||||
|
{
|
||||||
|
return wch >= 0x80 && wch <= 0x9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
inline int
|
||||||
|
CellWidth(wchar_t wch)
|
||||||
|
{
|
||||||
|
if (IsCaretControl(wch))
|
||||||
|
return 2;
|
||||||
|
if (IsC1Control(wch))
|
||||||
|
return 1;
|
||||||
|
const int w = wcwidth(wch);
|
||||||
|
return w < 0 ? 1 : w;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Decode the character at line[i]: its byte length and display width in
|
||||||
|
// cells (a tab reaches the next multiple of tabw from column col). Measures
|
||||||
|
// the way TerminalRenderer draws: an invalid byte or NUL is one byte, measured
|
||||||
|
// as the byte value.
|
||||||
|
inline void
|
||||||
|
MeasureChar(std::string_view line, std::size_t i, std::size_t col, std::size_t &len, std::size_t &width,
|
||||||
|
std::size_t tabw = kTabWidth)
|
||||||
|
{
|
||||||
|
std::mbstate_t state{};
|
||||||
|
wchar_t wch = 0;
|
||||||
|
const auto rc = std::mbrtowc(&wch, line.data() + i, line.size() - i, &state);
|
||||||
|
if (rc == static_cast<std::size_t>(-1) || rc == static_cast<std::size_t>(-2) || rc == 0) {
|
||||||
|
wch = static_cast<unsigned char>(line[i]);
|
||||||
|
len = 1;
|
||||||
|
} else {
|
||||||
|
len = rc;
|
||||||
|
}
|
||||||
|
if (wch == L'\t')
|
||||||
|
width = tabw - (col % tabw);
|
||||||
|
else
|
||||||
|
width = static_cast<std::size_t>(CellWidth(wch));
|
||||||
|
}
|
||||||
|
} // namespace kte
|
||||||
+51
-5
@@ -1,3 +1,6 @@
|
|||||||
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
|
#include <string>
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
#include <termios.h>
|
#include <termios.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
@@ -12,6 +15,12 @@ TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
|
|||||||
{
|
{
|
||||||
(void) argc;
|
(void) argc;
|
||||||
(void) argv;
|
(void) argv;
|
||||||
|
// Keys are read from stdin. Piped input would be executed as editor
|
||||||
|
// commands (and after EOF the loop spun at full CPU with no way to quit).
|
||||||
|
if (!isatty(STDIN_FILENO)) {
|
||||||
|
std::fprintf(stderr, "kte: standard input is not a terminal\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
|
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
|
||||||
{
|
{
|
||||||
struct termios tio{};
|
struct termios tio{};
|
||||||
@@ -43,7 +52,9 @@ TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
|
|||||||
// Enable 8-bit meta key sequences (Alt/ESC-prefix handling in terminals)
|
// Enable 8-bit meta key sequences (Alt/ESC-prefix handling in terminals)
|
||||||
meta(stdscr, TRUE);
|
meta(stdscr, TRUE);
|
||||||
// Make ESC key sequences resolve quickly so ESC+<key> works as meta
|
// Make ESC key sequences resolve quickly so ESC+<key> works as meta
|
||||||
#ifdef set_escdelay
|
// set_escdelay is an ncurses extension function (not a macro), so test for
|
||||||
|
// ncurses itself rather than `#ifdef set_escdelay`, which is always false.
|
||||||
|
#if defined(NCURSES_VERSION)
|
||||||
set_escdelay(TerminalFrontend::kEscDelayMs);
|
set_escdelay(TerminalFrontend::kEscDelayMs);
|
||||||
#endif
|
#endif
|
||||||
// Make getch() block briefly instead of busy-looping; reduces CPU when idle
|
// Make getch() block briefly instead of busy-looping; reduces CPU when idle
|
||||||
@@ -86,29 +97,64 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
|||||||
// Handle resize and keep editor dimensions synced
|
// Handle resize and keep editor dimensions synced
|
||||||
int r, c;
|
int r, c;
|
||||||
getmaxyx(stdscr, r, c);
|
getmaxyx(stdscr, r, c);
|
||||||
|
bool changed = first_frame_;
|
||||||
if (r != prev_r_ || c != prev_c_) {
|
if (r != prev_r_ || c != prev_c_) {
|
||||||
resizeterm(r, c);
|
resizeterm(r, c);
|
||||||
clear();
|
clear();
|
||||||
prev_r_ = r;
|
prev_r_ = r;
|
||||||
prev_c_ = c;
|
prev_c_ = c;
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||||
|
|
||||||
// Allow deferred opens (including swap recovery prompts) to run.
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
ed.ProcessPendingOpens();
|
// Its return value only reports an opened buffer; a deferred open that
|
||||||
|
// starts a prompt or sets a status must be drawn too.
|
||||||
|
{
|
||||||
|
const bool prompt_before = ed.PromptActive();
|
||||||
|
const std::string status_before = ed.Status();
|
||||||
|
const std::time_t status_time_before = ed.StatusTime();
|
||||||
|
if (ed.ProcessPendingOpens())
|
||||||
|
changed = true;
|
||||||
|
if (ed.PromptActive() != prompt_before || ed.Status() != status_before ||
|
||||||
|
ed.StatusTime() != status_time_before)
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle all pending input before drawing: one key per frame made a
|
||||||
|
// pasted block take a frame (and a full redraw) per character. The first
|
||||||
|
// read waits up to the configured timeout; the rest do not wait.
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
int handled = 0;
|
||||||
if (mi.hasCommand) {
|
while (handled < 4096 && input_.PollKey(mi)) {
|
||||||
|
changed = true;
|
||||||
|
if (mi.hasCommand)
|
||||||
Execute(ed, mi.id, mi.arg, mi.count);
|
Execute(ed, mi.id, mi.arg, mi.count);
|
||||||
|
if (ed.QuitRequested())
|
||||||
|
break;
|
||||||
|
if (++handled == 1)
|
||||||
|
timeout(0);
|
||||||
}
|
}
|
||||||
}
|
if (handled > 0)
|
||||||
|
timeout(16);
|
||||||
|
|
||||||
if (ed.QuitRequested()) {
|
if (ed.QuitRequested()) {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redraw after input or a resize, and otherwise about once a second
|
||||||
|
// (status messages, deferred work): redrawing every 16 ms kept a core
|
||||||
|
// busy on large or long-line files while idle.
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
if (changed || now - last_draw_ >= std::chrono::milliseconds(1000)) {
|
||||||
|
try {
|
||||||
renderer_.Draw(ed);
|
renderer_.Draw(ed);
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
ed.SetStatus(std::string("Display error: ") + e.what());
|
||||||
|
}
|
||||||
|
last_draw_ = now;
|
||||||
|
first_frame_ = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle
|
* TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <chrono>
|
||||||
#include <termios.h>
|
#include <termios.h>
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ private:
|
|||||||
TerminalRenderer renderer_{};
|
TerminalRenderer renderer_{};
|
||||||
int prev_r_ = 0;
|
int prev_r_ = 0;
|
||||||
int prev_c_ = 0;
|
int prev_c_ = 0;
|
||||||
|
// Redraw only when something may have changed (see Step).
|
||||||
|
bool first_frame_ = true;
|
||||||
|
std::chrono::steady_clock::time_point last_draw_{};
|
||||||
// Saved terminal attributes to restore on shutdown
|
// Saved terminal attributes to restore on shutdown
|
||||||
bool have_orig_tio_ = false;
|
bool have_orig_tio_ = false;
|
||||||
struct termios orig_tio_{};
|
struct termios orig_tio_{};
|
||||||
|
|||||||
+158
-38
@@ -1,4 +1,6 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <climits>
|
||||||
|
#include <cwchar>
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
|
|
||||||
#include "TerminalInputHandler.h"
|
#include "TerminalInputHandler.h"
|
||||||
@@ -12,6 +14,22 @@ CTRL(char c)
|
|||||||
{
|
{
|
||||||
return c & 0x1F;
|
return c & 0x1F;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Encode a single wide character in the process locale's multibyte encoding
|
||||||
|
// (UTF-8, given main.cc's setlocale(LC_ALL, "")). Returns false if the
|
||||||
|
// codepoint can't be represented, leaving `out` untouched.
|
||||||
|
bool
|
||||||
|
wchar_to_mb(wchar_t wc, std::string &out)
|
||||||
|
{
|
||||||
|
std::mbstate_t state{};
|
||||||
|
char buf[MB_LEN_MAX];
|
||||||
|
std::size_t n = std::wcrtomb(buf, wc, &state);
|
||||||
|
if (n == static_cast<std::size_t>(-1))
|
||||||
|
return false;
|
||||||
|
out.assign(buf, n);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TerminalInputHandler::TerminalInputHandler() = default;
|
TerminalInputHandler::TerminalInputHandler() = default;
|
||||||
@@ -21,6 +39,7 @@ TerminalInputHandler::~TerminalInputHandler() = default;
|
|||||||
|
|
||||||
static bool
|
static bool
|
||||||
map_key_to_command(const int ch,
|
map_key_to_command(const int ch,
|
||||||
|
const bool is_keycode,
|
||||||
bool &k_prefix,
|
bool &k_prefix,
|
||||||
bool &esc_meta,
|
bool &esc_meta,
|
||||||
bool &k_ctrl_pending,
|
bool &k_ctrl_pending,
|
||||||
@@ -28,9 +47,22 @@ map_key_to_command(const int ch,
|
|||||||
Editor *ed,
|
Editor *ed,
|
||||||
MappedInput &out)
|
MappedInput &out)
|
||||||
{
|
{
|
||||||
// Handle special keys from ncurses
|
// Handle special keys from ncurses. These are only meaningful when
|
||||||
|
// get_wch() reported KEY_CODE_YES: a regular (possibly non-ASCII) wide
|
||||||
|
// character can numerically collide with a KEY_* constant otherwise
|
||||||
|
// (e.g. U+0107 'ć' equals KEY_BACKSPACE's value), which would wrongly
|
||||||
|
// swallow it as a special key instead of inserting it.
|
||||||
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
||||||
switch (ch) {
|
//
|
||||||
|
// ESC marks the next key as Meta. Consume that flag on every other key up
|
||||||
|
// front: keys handled before the Meta lookup below (arrows, mouse, Enter,
|
||||||
|
// control chords) used to leave it set, turning a later plain key into a
|
||||||
|
// Meta command (ESC, Left, d => delete word).
|
||||||
|
const bool meta = esc_meta && ch != 27;
|
||||||
|
if (ch != 27)
|
||||||
|
esc_meta = false;
|
||||||
|
|
||||||
|
switch (is_keycode ? ch : -1) {
|
||||||
case KEY_ENTER:
|
case KEY_ENTER:
|
||||||
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
@@ -142,9 +174,10 @@ map_key_to_command(const int ch,
|
|||||||
out = {true, CommandId::DeleteChar, "", 0};
|
out = {true, CommandId::DeleteChar, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_RESIZE:
|
case KEY_RESIZE:
|
||||||
k_prefix = false;
|
// TerminalFrontend::Step picks up the new size before the next draw.
|
||||||
k_ctrl_pending = false;
|
// Refresh is the C-g cancel handler, so issuing it here would clear the
|
||||||
out = {true, CommandId::Refresh, "", 0};
|
// mark and cancel open prompts on every window resize.
|
||||||
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -160,6 +193,37 @@ map_key_to_command(const int ch,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meta (ESC-prefixed) keys: backspace, Enter and printable keys use the
|
||||||
|
// ESC keymap. Checked before control chords so ESC ^H (meta-backspace) is
|
||||||
|
// not taken as C-h, and before Enter so ESC Enter reaches SmartNewline.
|
||||||
|
// Other control chords after ESC act as themselves.
|
||||||
|
const bool is_backspace = (is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H');
|
||||||
|
const bool meta_key = meta && (is_backspace || (!is_keycode && (ch == '\n' || ch == '\r' || ch >= 0x20)));
|
||||||
|
if (meta_key) {
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
|
int ascii_key = ch;
|
||||||
|
if (is_backspace) {
|
||||||
|
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
||||||
|
} else if (ch == ',') {
|
||||||
|
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
||||||
|
ascii_key = '<';
|
||||||
|
} else if (ch == '.') {
|
||||||
|
// Likewise, map '.' to '>'
|
||||||
|
ascii_key = '>';
|
||||||
|
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
|
||||||
|
ascii_key = ascii_key - 'A' + 'a';
|
||||||
|
}
|
||||||
|
CommandId id;
|
||||||
|
if (KLookupEscCommand(ascii_key, id)) {
|
||||||
|
out = {true, id, "", 0};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Unhandled ESC sequence: exit escape mode and show status
|
||||||
|
out = {true, CommandId::UnknownEscCommand, "", 0};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Control keys
|
// Control keys
|
||||||
if (ch == CTRL('K')) {
|
if (ch == CTRL('K')) {
|
||||||
// C-k prefix
|
// C-k prefix
|
||||||
@@ -254,34 +318,8 @@ map_key_to_command(const int ch,
|
|||||||
out = {true, CommandId::Newline, "", 0};
|
out = {true, CommandId::Newline, "", 0};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// If previous key was ESC, interpret as meta and use ESC keymap
|
|
||||||
if (esc_meta) {
|
|
||||||
esc_meta = false;
|
|
||||||
int ascii_key = ch;
|
|
||||||
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
|
|
||||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
|
||||||
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
|
||||||
} else if (ch == ',') {
|
|
||||||
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
|
||||||
ascii_key = '<';
|
|
||||||
} else if (ch == '.') {
|
|
||||||
// Likewise, map '.' to '>'
|
|
||||||
ascii_key = '>';
|
|
||||||
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
|
|
||||||
ascii_key = ascii_key - 'A' + 'a';
|
|
||||||
}
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupEscCommand(ascii_key, id)) {
|
|
||||||
out = {true, id, "", 0};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Unhandled ESC sequence: exit escape mode and show status
|
|
||||||
out = {true, CommandId::UnknownEscCommand, "", 0};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
||||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
k_ctrl_pending = false;
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::Backspace, "", 0};
|
out = {true, CommandId::Backspace, "", 0};
|
||||||
@@ -297,11 +335,20 @@ map_key_to_command(const int ch,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Printable ASCII
|
// Printable character: ASCII, or (for a regular decoded wide character,
|
||||||
if (ch >= 0x20 && ch <= 0x7E) {
|
// not a keycode) any other printable Unicode codepoint - e.g. accented
|
||||||
|
// Latin, Cyrillic, CJK, etc.
|
||||||
|
if (!is_keycode && ch >= 0x20 && ch != 0x7F) {
|
||||||
|
std::string mb;
|
||||||
|
if (ch <= 0x7E) {
|
||||||
|
mb.assign(1, static_cast<char>(ch));
|
||||||
|
} else if (!wchar_to_mb(static_cast<wchar_t>(ch), mb)) {
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
out.hasCommand = true;
|
out.hasCommand = true;
|
||||||
out.id = CommandId::InsertText;
|
out.id = CommandId::InsertText;
|
||||||
out.arg.assign(1, static_cast<char>(ch));
|
out.arg = mb;
|
||||||
out.count = 0;
|
out.count = 0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -311,15 +358,88 @@ map_key_to_command(const int ch,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Read one key: an ncurses KEY_* code, or a character decoded from UTF-8
|
||||||
|
// here rather than by get_wch(). With a zero timeout (the frontend reads the
|
||||||
|
// rest of a batch without waiting), get_wch() gave up on a character whose
|
||||||
|
// bytes had not all arrived yet, and ncurses then discarded the remaining
|
||||||
|
// bytes as an invalid sequence: text pasted over a slow link lost
|
||||||
|
// characters, and whatever followed them in the same read.
|
||||||
|
// Returns false when there is no input.
|
||||||
|
static bool
|
||||||
|
read_key(int &ch, bool &is_keycode)
|
||||||
|
{
|
||||||
|
const int c = getch();
|
||||||
|
if (c == ERR)
|
||||||
|
return false;
|
||||||
|
is_keycode = c > 0xFF;
|
||||||
|
ch = c;
|
||||||
|
if (is_keycode || c < 0x80)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Lead byte of a multibyte character: how many continuation bytes follow.
|
||||||
|
int need;
|
||||||
|
char32_t cp;
|
||||||
|
if (c >= 0xC2 && c <= 0xDF) {
|
||||||
|
need = 1;
|
||||||
|
cp = static_cast<char32_t>(c & 0x1F);
|
||||||
|
} else if (c >= 0xE0 && c <= 0xEF) {
|
||||||
|
need = 2;
|
||||||
|
cp = static_cast<char32_t>(c & 0x0F);
|
||||||
|
} else if (c >= 0xF0 && c <= 0xF4) {
|
||||||
|
need = 3;
|
||||||
|
cp = static_cast<char32_t>(c & 0x07);
|
||||||
|
} else {
|
||||||
|
ch = -1; // stray continuation or invalid byte: ignore it
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// The rest of the character is on its way; wait for it (restoring the
|
||||||
|
// caller's timeout), but not long if it never comes: terminals that send
|
||||||
|
// Alt as a raw 8-bit byte (rxvt meta8) produce lone lead bytes, and
|
||||||
|
// every such key would otherwise stall input.
|
||||||
|
const int saved_delay = wgetdelay(stdscr);
|
||||||
|
timeout(250);
|
||||||
|
for (int i = 0; i < need; ++i) {
|
||||||
|
const int b = getch();
|
||||||
|
if (b == ERR || b > 0xFF || (b & 0xC0) != 0x80) {
|
||||||
|
if (b != ERR)
|
||||||
|
ungetch(b); // not part of this character; read it next
|
||||||
|
ch = -1;
|
||||||
|
timeout(saved_delay);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cp = (cp << 6) | static_cast<char32_t>(b & 0x3F);
|
||||||
|
}
|
||||||
|
timeout(saved_delay);
|
||||||
|
// Reject overlong forms and surrogates.
|
||||||
|
if ((need == 2 && cp < 0x800) || (need == 3 && cp < 0x10000) || (cp >= 0xD800 && cp <= 0xDFFF) ||
|
||||||
|
cp > 0x10FFFF) {
|
||||||
|
ch = -1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ch = static_cast<int>(cp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
TerminalInputHandler::decode_(MappedInput &out)
|
TerminalInputHandler::decode_(MappedInput &out)
|
||||||
{
|
{
|
||||||
int ch = getch();
|
int ch = 0;
|
||||||
if (ch == ERR) {
|
bool is_keycode = false;
|
||||||
|
if (!read_key(ch, is_keycode))
|
||||||
return false; // no input
|
return false; // no input
|
||||||
|
if (ch < 0) {
|
||||||
|
// Invalid byte sequence, dropped. It still ends a pending ESC or
|
||||||
|
// C-k prefix, as any other key would; left set, it applied to the
|
||||||
|
// next key instead.
|
||||||
|
esc_meta_ = false;
|
||||||
|
k_prefix_ = false;
|
||||||
|
k_ctrl_pending_ = false;
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
bool consumed = map_key_to_command(
|
bool consumed = map_key_to_command(
|
||||||
ch,
|
ch, is_keycode,
|
||||||
k_prefix_, esc_meta_,
|
k_prefix_, esc_meta_,
|
||||||
k_ctrl_pending_,
|
k_ctrl_pending_,
|
||||||
mouse_selecting_,
|
mouse_selecting_,
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ public:
|
|||||||
|
|
||||||
bool Poll(MappedInput &out) override;
|
bool Poll(MappedInput &out) override;
|
||||||
|
|
||||||
|
// Read one key if available. Returns false when no input was read; true
|
||||||
|
// when a key was consumed (out.hasCommand says whether it produced a
|
||||||
|
// command: prefixes such as C-k or ESC do not).
|
||||||
|
bool PollKey(MappedInput &out)
|
||||||
|
{
|
||||||
|
out = {};
|
||||||
|
return decode_(out);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool decode_(MappedInput &out);
|
bool decode_(MappedInput &out);
|
||||||
|
|
||||||
|
|||||||
+96
-91
@@ -6,10 +6,11 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
#include <regex>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "TerminalRenderer.h"
|
#include "TerminalRenderer.h"
|
||||||
|
#include "SearchHighlight.h"
|
||||||
|
#include "TermWidth.h"
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "Highlight.h"
|
#include "Highlight.h"
|
||||||
@@ -42,11 +43,13 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
|
|
||||||
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
|
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
|
||||||
if (buf) {
|
if (buf) {
|
||||||
const auto &lines = buf->Rows();
|
// Per-row access only: Rows() rebuilds a string for every line of the
|
||||||
|
// file after each edit, which made every frame O(file size).
|
||||||
|
const std::size_t nlines = buf->Nrows();
|
||||||
std::size_t rowoffs = buf->Rowoffs();
|
std::size_t rowoffs = buf->Rowoffs();
|
||||||
std::size_t coloffs = buf->Coloffs();
|
std::size_t coloffs = buf->Coloffs();
|
||||||
|
|
||||||
const int tabw = 8;
|
const int tabw = static_cast<int>(kte::kTabWidth);
|
||||||
// Phase 3: prefetch visible viewport highlights (current terminal area)
|
// Phase 3: prefetch visible viewport highlights (current terminal area)
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||||
int fr = static_cast<int>(rowoffs);
|
int fr = static_cast<int>(rowoffs);
|
||||||
@@ -54,41 +57,18 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search matches: the pattern is compiled once per frame.
|
||||||
|
const kte::SearchHighlight search_hl(ed);
|
||||||
|
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
|
||||||
for (int r = 0; r < content_rows; ++r) {
|
for (int r = 0; r < content_rows; ++r) {
|
||||||
move(r, 0);
|
move(r, 0);
|
||||||
std::size_t li = rowoffs + static_cast<std::size_t>(r);
|
std::size_t li = rowoffs + static_cast<std::size_t>(r);
|
||||||
std::size_t render_col = 0;
|
std::size_t render_col = 0;
|
||||||
std::size_t src_i = 0;
|
std::size_t src_i = 0;
|
||||||
// Compute matches for this line if search highlighting is active
|
// Per-row copy: GetLineView() would materialize the whole buffer
|
||||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
// after every edit.
|
||||||
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
|
const std::string line = li < nlines ? buf->GetLineString(li) : std::string();
|
||||||
if (search_mode && li < lines.size()) {
|
search_hl.Ranges(line, ranges);
|
||||||
std::string sline = static_cast<std::string>(lines[li]);
|
|
||||||
// If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges
|
|
||||||
if (ed.PromptActive() && (
|
|
||||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
|
||||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
|
||||||
try {
|
|
||||||
std::regex rx(ed.SearchQuery());
|
|
||||||
for (auto it = std::sregex_iterator(sline.begin(), sline.end(), rx);
|
|
||||||
it != std::sregex_iterator(); ++it) {
|
|
||||||
const auto &m = *it;
|
|
||||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
|
||||||
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
|
||||||
ranges.emplace_back(sx, ex);
|
|
||||||
}
|
|
||||||
} catch (const std::regex_error &) {
|
|
||||||
// ignore invalid patterns here; status shows error
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const std::string &q = ed.SearchQuery();
|
|
||||||
std::size_t pos = 0;
|
|
||||||
while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) {
|
|
||||||
ranges.emplace_back(pos, pos + q.size());
|
|
||||||
pos += q.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto is_src_in_hl = [&](std::size_t si) -> bool {
|
auto is_src_in_hl = [&](std::size_t si) -> bool {
|
||||||
if (ranges.empty())
|
if (ranges.empty())
|
||||||
return false;
|
return false;
|
||||||
@@ -140,8 +120,7 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
int written = 0;
|
int written = 0;
|
||||||
if (li < lines.size()) {
|
if (li < nlines) {
|
||||||
std::string line = static_cast<std::string>(lines[li]);
|
|
||||||
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
|
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
|
||||||
const std::size_t vsel_spot_src = vsel_on_line
|
const std::size_t vsel_spot_src = vsel_on_line
|
||||||
? std::min(buf->Curx(), line.size())
|
? std::min(buf->Curx(), line.size())
|
||||||
@@ -168,14 +147,11 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
wch_len = static_cast<int>(res);
|
wch_len = static_cast<int>(res);
|
||||||
}
|
}
|
||||||
if (wch == L'\t') {
|
if (wch == L'\t') {
|
||||||
constexpr std::size_t tab_width = 8;
|
constexpr std::size_t tab_width = kte::kTabWidth;
|
||||||
const std::size_t next_tab = tab_width - (rc % tab_width);
|
const std::size_t next_tab = tab_width - (rc % tab_width);
|
||||||
rc += next_tab;
|
rc += next_tab;
|
||||||
} else {
|
} else {
|
||||||
int w = wcwidth(wch);
|
rc += static_cast<std::size_t>(kte::CellWidth(wch));
|
||||||
if (w < 0)
|
|
||||||
w = 1;
|
|
||||||
rc += static_cast<std::size_t>(w);
|
|
||||||
}
|
}
|
||||||
si += static_cast<std::size_t>(wch_len);
|
si += static_cast<std::size_t>(wch_len);
|
||||||
}
|
}
|
||||||
@@ -187,42 +163,24 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
std::vector<kte::HighlightSpan> sane_spans;
|
std::vector<kte::HighlightSpan> sane_spans;
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
||||||
HasHighlighter()) {
|
HasHighlighter()) {
|
||||||
kte::LineHighlight lh_val = buf->Highlighter()->GetLine(
|
const kte::LineHighlight &lh_val = buf->Highlighter()->GetLine(
|
||||||
*buf, static_cast<int>(li), buf->Version());
|
*buf, static_cast<int>(li), buf->Version());
|
||||||
// Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties
|
kte::SanitizeSpans(line, lh_val.spans, sane_spans);
|
||||||
const std::size_t line_len = line.size();
|
|
||||||
sane_spans.reserve(lh_val.spans.size());
|
|
||||||
for (const auto &sp: lh_val.spans) {
|
|
||||||
int s_raw = sp.col_start;
|
|
||||||
int e_raw = sp.col_end;
|
|
||||||
if (e_raw < s_raw)
|
|
||||||
std::swap(e_raw, s_raw);
|
|
||||||
std::size_t s = static_cast<std::size_t>(std::max(
|
|
||||||
0, std::min(s_raw, static_cast<int>(line_len))));
|
|
||||||
std::size_t e = static_cast<std::size_t>(std::max(
|
|
||||||
static_cast<int>(s),
|
|
||||||
std::min(e_raw, static_cast<int>(line_len))));
|
|
||||||
if (e <= s)
|
|
||||||
continue;
|
|
||||||
sane_spans.push_back(kte::HighlightSpan{
|
|
||||||
static_cast<int>(s), static_cast<int>(e), sp.kind
|
|
||||||
});
|
|
||||||
}
|
|
||||||
std::sort(sane_spans.begin(), sane_spans.end(),
|
|
||||||
[](const kte::HighlightSpan &a, const kte::HighlightSpan &b) {
|
|
||||||
return a.col_start < b.col_start;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// Called left to right while drawing: walk the spans with a
|
||||||
|
// cursor (rescanning from the first span for every byte was
|
||||||
|
// quadratic on long highlighted lines).
|
||||||
|
std::size_t span_i = 0;
|
||||||
|
int last_token_si = -1;
|
||||||
auto token_at = [&](std::size_t src_index) -> kte::TokenKind {
|
auto token_at = [&](std::size_t src_index) -> kte::TokenKind {
|
||||||
if (sane_spans.empty())
|
const int si = static_cast<int>(src_index);
|
||||||
return kte::TokenKind::Default;
|
if (si < last_token_si)
|
||||||
int si = static_cast<int>(src_index);
|
span_i = 0; // asked about an earlier byte: restart
|
||||||
for (const auto &sp: sane_spans) {
|
last_token_si = si;
|
||||||
if (si < sp.col_start)
|
while (span_i < sane_spans.size() && sane_spans[span_i].col_end <= si)
|
||||||
break;
|
++span_i;
|
||||||
if (si >= sp.col_start && si < sp.col_end)
|
if (span_i < sane_spans.size() && sane_spans[span_i].col_start <= si)
|
||||||
return sp.kind;
|
return sane_spans[span_i].kind;
|
||||||
}
|
|
||||||
return kte::TokenKind::Default;
|
return kte::TokenKind::Default;
|
||||||
};
|
};
|
||||||
auto token_attr = [&](kte::TokenKind k) -> attr_t {
|
auto token_attr = [&](kte::TokenKind k) -> attr_t {
|
||||||
@@ -242,6 +200,45 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Fast-forward to the scroll offset with the draw loop's own skip
|
||||||
|
// rules (a tab is skipped only if it ends at or before coloffs,
|
||||||
|
// any other character while it starts before coloffs), cached
|
||||||
|
// for the row's current content.
|
||||||
|
if (coloffs > 0 && src_i == 0) {
|
||||||
|
if (skip_cache_.size() < static_cast<std::size_t>(content_rows))
|
||||||
|
skip_cache_.resize(static_cast<std::size_t>(content_rows));
|
||||||
|
SkipCache &sc = skip_cache_[static_cast<std::size_t>(r)];
|
||||||
|
if (!(sc.valid && sc.buf_id == buf->Id() && sc.version == buf->Version() && sc.row == li &&
|
||||||
|
sc.coloffs == coloffs)) {
|
||||||
|
std::size_t s = 0, c = 0;
|
||||||
|
while (s < line.size()) {
|
||||||
|
std::mbstate_t st = std::mbstate_t();
|
||||||
|
wchar_t w = 0;
|
||||||
|
std::size_t n = std::mbrtowc(&w, &line[s], line.size() - s, &st);
|
||||||
|
if (n == (size_t) -1 || n == (size_t) -2) {
|
||||||
|
w = static_cast<unsigned char>(line[s]);
|
||||||
|
n = 1;
|
||||||
|
} else if (n == 0) {
|
||||||
|
w = L'\0';
|
||||||
|
n = 1;
|
||||||
|
}
|
||||||
|
if (w == L'\t') {
|
||||||
|
const std::size_t next_tab = tabw - (c % tabw);
|
||||||
|
if (c + next_tab > coloffs)
|
||||||
|
break;
|
||||||
|
c += next_tab;
|
||||||
|
} else {
|
||||||
|
if (c >= coloffs)
|
||||||
|
break;
|
||||||
|
c += static_cast<std::size_t>(kte::CellWidth(w));
|
||||||
|
}
|
||||||
|
s += n;
|
||||||
|
}
|
||||||
|
sc = SkipCache{buf->Id(), buf->Version(), li, coloffs, s, c, true};
|
||||||
|
}
|
||||||
|
src_i = sc.src;
|
||||||
|
render_col = sc.col;
|
||||||
|
}
|
||||||
while (written < cols) {
|
while (written < cols) {
|
||||||
bool from_src = false;
|
bool from_src = false;
|
||||||
wchar_t wch = L' ';
|
wchar_t wch = L' ';
|
||||||
@@ -286,7 +283,7 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
vsel_on_line && !vsel_spot_is_eol && src_i ==
|
vsel_on_line && !vsel_spot_is_eol && src_i ==
|
||||||
vsel_spot_src;
|
vsel_spot_src;
|
||||||
bool in_sel = in_mark || in_vsel;
|
bool in_sel = in_mark || in_vsel;
|
||||||
bool in_hl = search_mode && is_src_in_hl(src_i);
|
bool in_hl = search_hl.Active() && is_src_in_hl(src_i);
|
||||||
bool in_cur =
|
bool in_cur =
|
||||||
has_current && li == cur_my && src_i >= cur_mx
|
has_current && li == cur_my && src_i >= cur_mx
|
||||||
&&
|
&&
|
||||||
@@ -310,10 +307,8 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
src_i += wch_len;
|
src_i += wch_len;
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
// normal char
|
// normal char (controls are drawn as ^X or '?')
|
||||||
disp_w = wcwidth(wch);
|
disp_w = kte::CellWidth(wch);
|
||||||
if (disp_w < 0)
|
|
||||||
disp_w = 1; // non-printable or similar
|
|
||||||
|
|
||||||
if (render_col < coloffs) {
|
if (render_col < coloffs) {
|
||||||
render_col += disp_w;
|
render_col += disp_w;
|
||||||
@@ -345,7 +340,7 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool in_sel = in_mark || in_vsel;
|
bool in_sel = in_mark || in_vsel;
|
||||||
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
bool in_hl = search_hl.Active() && from_src && is_src_in_hl(src_i);
|
||||||
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
|
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
|
||||||
src_i < cur_mend;
|
src_i < cur_mend;
|
||||||
attr_t a = A_NORMAL;
|
attr_t a = A_NORMAL;
|
||||||
@@ -361,7 +356,12 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
attrset(a);
|
attrset(a);
|
||||||
|
|
||||||
if (from_src) {
|
if (from_src && kte::IsCaretControl(wch)) {
|
||||||
|
addch('^');
|
||||||
|
addch(wch == 0x7f ? '?' : static_cast<chtype>(wch + 0x40));
|
||||||
|
} else if (from_src && kte::IsC1Control(wch)) {
|
||||||
|
addch('?');
|
||||||
|
} else if (from_src) {
|
||||||
cchar_t cch;
|
cchar_t cch;
|
||||||
wchar_t warr[2] = {wch, L'\0'};
|
wchar_t warr[2] = {wch, L'\0'};
|
||||||
setcchar(&cch, warr, 0, 0, nullptr);
|
setcchar(&cch, warr, 0, 0, nullptr);
|
||||||
@@ -389,8 +389,11 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
std::size_t cx = buf->Curx();
|
std::size_t cx = buf->Curx();
|
||||||
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
|
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
|
||||||
std::size_t rx_recomputed = 0;
|
std::size_t rx_recomputed = 0;
|
||||||
if (cy < lines.size()) {
|
if (cy < nlines && cursor_cache_.valid && cursor_cache_.buf_id == buf->Id() &&
|
||||||
const std::string line_for_cursor = static_cast<std::string>(lines[cy]);
|
cursor_cache_.version == buf->Version() && cursor_cache_.row == cy && cursor_cache_.cx == cx) {
|
||||||
|
rx_recomputed = cursor_cache_.rx;
|
||||||
|
} else if (cy < nlines) {
|
||||||
|
const std::string line_for_cursor = buf->GetLineString(cy);
|
||||||
std::size_t src_i_cur = 0;
|
std::size_t src_i_cur = 0;
|
||||||
std::size_t render_col_cur = 0;
|
std::size_t render_col_cur = 0;
|
||||||
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
|
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
|
||||||
@@ -400,23 +403,25 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
&wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur,
|
&wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur,
|
||||||
&state);
|
&state);
|
||||||
|
|
||||||
|
// Same decoding and widths as the drawing loop above.
|
||||||
|
std::size_t len = res;
|
||||||
if (res == (size_t) -1 || res == (size_t) -2) {
|
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||||
render_col_cur += 1;
|
wch = static_cast<unsigned char>(line_for_cursor[src_i_cur]);
|
||||||
src_i_cur += 1;
|
len = 1;
|
||||||
} else if (res == 0) {
|
} else if (res == 0) {
|
||||||
src_i_cur += 1;
|
wch = L'\0';
|
||||||
} else {
|
len = 1;
|
||||||
|
}
|
||||||
if (wch == L'\t') {
|
if (wch == L'\t') {
|
||||||
std::size_t next_tab = tabw - (render_col_cur % tabw);
|
std::size_t next_tab = tabw - (render_col_cur % tabw);
|
||||||
render_col_cur += next_tab;
|
render_col_cur += next_tab;
|
||||||
} else {
|
} else {
|
||||||
int dw = wcwidth(wch);
|
render_col_cur += static_cast<std::size_t>(kte::CellWidth(wch));
|
||||||
render_col_cur += (dw < 0) ? 1 : dw;
|
|
||||||
}
|
|
||||||
src_i_cur += res;
|
|
||||||
}
|
}
|
||||||
|
src_i_cur += len;
|
||||||
}
|
}
|
||||||
rx_recomputed = render_col_cur;
|
rx_recomputed = render_col_cur;
|
||||||
|
cursor_cache_ = CursorCache{buf->Id(), buf->Version(), cy, cx, rx_recomputed, true};
|
||||||
}
|
}
|
||||||
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
|
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
|
||||||
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
|
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
|
||||||
@@ -541,7 +546,7 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
left += " [RO]";
|
left += " [RO]";
|
||||||
// Append total line count as "<n>L"
|
// Append total line count as "<n>L"
|
||||||
if (b) {
|
if (b) {
|
||||||
unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
|
unsigned long lcount = static_cast<unsigned long>(b->Nrows());
|
||||||
left += " ";
|
left += " ";
|
||||||
left += std::to_string(lcount);
|
left += std::to_string(lcount);
|
||||||
left += "L";
|
left += "L";
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* TerminalRenderer - ncurses-based renderer for terminal mode
|
* TerminalRenderer - ncurses-based renderer for terminal mode
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstddef>
|
||||||
#include "Renderer.h"
|
#include "Renderer.h"
|
||||||
|
|
||||||
|
|
||||||
@@ -12,4 +15,30 @@ public:
|
|||||||
~TerminalRenderer() override;
|
~TerminalRenderer() override;
|
||||||
|
|
||||||
void Draw(Editor &ed) override;
|
void Draw(Editor &ed) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Where a row's visible part starts (the draw loop's skip up to the
|
||||||
|
// horizontal scroll offset), cached per screen row. Scanning a very long
|
||||||
|
// line from column 0 on every frame made each redraw O(line length).
|
||||||
|
struct SkipCache {
|
||||||
|
std::uint64_t buf_id = 0; // Buffer::Id(); addresses get reused
|
||||||
|
std::uint64_t version = 0;
|
||||||
|
std::size_t row = 0;
|
||||||
|
std::size_t coloffs = 0;
|
||||||
|
std::size_t src = 0; // byte offset where drawing resumes
|
||||||
|
std::size_t col = 0; // render column at that offset
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<SkipCache> skip_cache_;
|
||||||
|
|
||||||
|
// Render column of the cursor, cached the same way.
|
||||||
|
struct CursorCache {
|
||||||
|
std::uint64_t buf_id = 0; // Buffer::Id(); addresses get reused
|
||||||
|
std::uint64_t version = 0;
|
||||||
|
std::size_t row = 0;
|
||||||
|
std::size_t cx = 0;
|
||||||
|
std::size_t rx = 0;
|
||||||
|
bool valid = false;
|
||||||
|
} cursor_cache_;
|
||||||
};
|
};
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* TextSpan.h - a reference to bytes held in a PieceTable's storage.
|
||||||
|
*
|
||||||
|
* PieceTable storage is append-only (edits add pieces; nothing rewrites
|
||||||
|
* stored bytes), so a span stays valid for the life of the table's storage.
|
||||||
|
* Undo records large texts as spans instead of copies of the bytes.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
|
||||||
|
struct TextSpan {
|
||||||
|
bool add{false}; // false: original storage, true: add storage
|
||||||
|
std::size_t start{0};
|
||||||
|
std::size_t len{0};
|
||||||
|
};
|
||||||
+34
@@ -1,6 +1,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "TextSpan.h"
|
||||||
|
|
||||||
|
|
||||||
enum class UndoType : std::uint8_t {
|
enum class UndoType : std::uint8_t {
|
||||||
@@ -10,6 +13,12 @@ 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 {
|
||||||
@@ -18,7 +27,32 @@ struct UndoNode {
|
|||||||
int col{};
|
int col{};
|
||||||
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
||||||
std::string text;
|
std::string text;
|
||||||
|
// A large Insert/Paste/Delete text is held as references to the buffer's
|
||||||
|
// storage instead (text is then empty and span_len is its length): undo
|
||||||
|
// of a replace-all over a 100 MB file otherwise kept 200 MB of copies.
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
std::size_t span_len = 0;
|
||||||
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
||||||
UndoNode *child = nullptr; // next in current timeline
|
UndoNode *child = nullptr; // next in current timeline
|
||||||
UndoNode *next = nullptr; // redo branch
|
UndoNode *next = nullptr; // redo branch
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool HasSpans() const
|
||||||
|
{
|
||||||
|
return !spans.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Length of the node's text, however it is held.
|
||||||
|
[[nodiscard]] std::size_t TextSize() const
|
||||||
|
{
|
||||||
|
return HasSpans() ? span_len : text.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Approximate heap and object bytes held by this node.
|
||||||
|
[[nodiscard]] std::size_t CostBytes() const
|
||||||
|
{
|
||||||
|
return sizeof(UndoNode) + text.capacity() + spans.capacity() * sizeof(TextSpan);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <stack>
|
|
||||||
#include <vector>
|
|
||||||
#include <memory>
|
|
||||||
#include "UndoNode.h"
|
|
||||||
|
|
||||||
// Pool allocator for UndoNode to eliminate frequent malloc/free.
|
|
||||||
// Uses fixed-size blocks to keep node addresses stable.
|
|
||||||
class UndoNodePool {
|
|
||||||
public:
|
|
||||||
explicit UndoNodePool(std::size_t block_size = 64)
|
|
||||||
: block_size_(block_size) {}
|
|
||||||
|
|
||||||
|
|
||||||
UndoNode *acquire()
|
|
||||||
{
|
|
||||||
if (available_.empty())
|
|
||||||
allocate_block();
|
|
||||||
auto *node = available_.top();
|
|
||||||
available_.pop();
|
|
||||||
// Node comes zeroed; ensure links are reset
|
|
||||||
node->text.clear();
|
|
||||||
node->parent = nullptr;
|
|
||||||
node->child = nullptr;
|
|
||||||
node->next = nullptr;
|
|
||||||
node->row = node->col = 0;
|
|
||||||
node->type = UndoType{};
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void release(UndoNode *node)
|
|
||||||
{
|
|
||||||
if (!node)
|
|
||||||
return;
|
|
||||||
// Clear heavy fields to free memory held by strings
|
|
||||||
node->text.clear();
|
|
||||||
node->parent = nullptr;
|
|
||||||
node->child = nullptr;
|
|
||||||
node->next = nullptr;
|
|
||||||
node->row = node->col = 0;
|
|
||||||
node->type = UndoType{};
|
|
||||||
available_.push(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
void allocate_block()
|
|
||||||
{
|
|
||||||
// allocate a new block; keep ownership so memory stays valid
|
|
||||||
std::unique_ptr<UndoNode[]> block(new UndoNode[block_size_]);
|
|
||||||
UndoNode *base = block.get();
|
|
||||||
blocks_.push_back(std::move(block));
|
|
||||||
for (std::size_t i = 0; i < block_size_; ++i) {
|
|
||||||
// ensure the node is reset; rely on default constructor/zero init
|
|
||||||
available_.push(&base[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
std::size_t block_size_;
|
|
||||||
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
|
|
||||||
std::stack<UndoNode *> available_;
|
|
||||||
};
|
|
||||||
+298
-34
@@ -1,7 +1,9 @@
|
|||||||
#include "UndoSystem.h"
|
#include "UndoSystem.h"
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
|
#include <algorithm>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
||||||
@@ -15,6 +17,9 @@ UndoSystem::BeginGroup()
|
|||||||
commit();
|
commit();
|
||||||
if (active_group_id_ == 0)
|
if (active_group_id_ == 0)
|
||||||
active_group_id_ = next_group_id_++;
|
active_group_id_ = next_group_id_++;
|
||||||
|
// Groups nest (e.g. SmartNewline wraps Newline, which groups its own
|
||||||
|
// visual-line splits); only the outermost EndGroup closes the group.
|
||||||
|
++group_depth_;
|
||||||
return active_group_id_;
|
return active_group_id_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +28,12 @@ void
|
|||||||
UndoSystem::EndGroup()
|
UndoSystem::EndGroup()
|
||||||
{
|
{
|
||||||
commit();
|
commit();
|
||||||
|
if (group_depth_ > 0)
|
||||||
|
--group_depth_;
|
||||||
|
if (group_depth_ == 0) {
|
||||||
active_group_id_ = 0;
|
active_group_id_ = 0;
|
||||||
|
maybe_prune();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +47,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);
|
UndoType::InsertRow || type == UndoType::JoinLines);
|
||||||
if (always_standalone) {
|
if (always_standalone) {
|
||||||
commit();
|
commit();
|
||||||
}
|
}
|
||||||
@@ -77,6 +87,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,6 +153,9 @@ UndoSystem::commit()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try_convert_to_spans(tree_.pending);
|
||||||
|
bytes_ += tree_.pending->CostBytes();
|
||||||
|
|
||||||
if (!tree_.root) {
|
if (!tree_.root) {
|
||||||
tree_.root = tree_.pending;
|
tree_.root = tree_.pending;
|
||||||
tree_.pending->parent = nullptr;
|
tree_.pending->parent = nullptr;
|
||||||
@@ -169,6 +183,8 @@ UndoSystem::commit()
|
|||||||
tree_.pending = nullptr;
|
tree_.pending = nullptr;
|
||||||
pending_mode_ = PendingAppendMode::Append;
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
|
if (group_depth_ == 0)
|
||||||
|
maybe_prune();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -240,6 +256,24 @@ UndoSystem::mark_saved()
|
|||||||
{
|
{
|
||||||
commit();
|
commit();
|
||||||
tree_.saved = tree_.current;
|
tree_.saved = tree_.current;
|
||||||
|
base_unsaved_ = false;
|
||||||
|
update_dirty_flag();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::AbortGroups()
|
||||||
|
{
|
||||||
|
commit();
|
||||||
|
group_depth_ = 0;
|
||||||
|
active_group_id_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::mark_base_unsaved()
|
||||||
|
{
|
||||||
|
base_unsaved_ = true;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,19 +293,61 @@ void
|
|||||||
UndoSystem::clear()
|
UndoSystem::clear()
|
||||||
{
|
{
|
||||||
discard_pending();
|
discard_pending();
|
||||||
free_node(tree_.root);
|
// The root may have siblings (edits made after undoing to the start).
|
||||||
|
FreeUndoForest(tree_.root);
|
||||||
tree_.root = nullptr;
|
tree_.root = nullptr;
|
||||||
tree_.current = nullptr;
|
tree_.current = nullptr;
|
||||||
tree_.saved = nullptr;
|
tree_.saved = nullptr;
|
||||||
|
base_unsaved_ = false;
|
||||||
|
bytes_ = 0;
|
||||||
active_group_id_ = 0;
|
active_group_id_ = 0;
|
||||||
|
group_depth_ = 0;
|
||||||
next_group_id_ = 1;
|
next_group_id_ = 1;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Position just after the node's text inserted at (node->col, node->row).
|
||||||
|
// The text may span lines: the column is then the length after its last
|
||||||
|
// newline, not col + size (which left the cursor past the end of the line,
|
||||||
|
// so later edits and their undo records went to the wrong place).
|
||||||
|
static void
|
||||||
|
end_of_text(const Buffer &buf, const UndoNode *node, std::size_t &x, std::size_t &y)
|
||||||
|
{
|
||||||
|
y = static_cast<std::size_t>(node->row);
|
||||||
|
x = static_cast<std::size_t>(node->col);
|
||||||
|
auto scan = [&](const char *d, std::size_t n) {
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
if (d[i] == '\n') {
|
||||||
|
++y;
|
||||||
|
x = 0;
|
||||||
|
} else {
|
||||||
|
++x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (node->HasSpans())
|
||||||
|
buf.VisitSpans(node->spans, scan);
|
||||||
|
else
|
||||||
|
scan(node->text.data(), node->text.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Insert the node's text at its position (from spans without copying).
|
||||||
|
static void
|
||||||
|
insert_node_text(Buffer &buf, const UndoNode *node)
|
||||||
|
{
|
||||||
|
if (node->HasSpans())
|
||||||
|
buf.insert_spans(node->row, node->col, node->spans);
|
||||||
|
else
|
||||||
|
buf.insert_text(node->row, node->col, node->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::apply(const UndoNode *node, int direction)
|
UndoSystem::apply(const UndoNode *node, int direction)
|
||||||
{
|
{
|
||||||
|
std::size_t ex = 0, ey = 0;
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
||||||
@@ -280,22 +356,22 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
case UndoType::Insert:
|
case UndoType::Insert:
|
||||||
case UndoType::Paste:
|
case UndoType::Paste:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
insert_node_text(*buf_, node);
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
end_of_text(*buf_, node, ex, ey);
|
||||||
static_cast<std::size_t>(node->row));
|
buf_->SetCursor(ex, ey);
|
||||||
} else {
|
} else {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->TextSize());
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Delete:
|
case UndoType::Delete:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->TextSize());
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
insert_node_text(*buf_, node);
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
end_of_text(*buf_, node, ex, ey);
|
||||||
static_cast<std::size_t>(node->row));
|
buf_->SetCursor(ex, ey);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Newline:
|
case UndoType::Newline:
|
||||||
@@ -325,6 +401,16 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,20 +418,13 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
void
|
void
|
||||||
UndoSystem::free_node(UndoNode *node)
|
UndoSystem::free_node(UndoNode *node)
|
||||||
{
|
{
|
||||||
|
// Free this node and its child subtree, but not its siblings.
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
// Free child subtree(s) and sibling branches
|
UndoNode *children = node->child;
|
||||||
if (node->child) {
|
|
||||||
// Free entire redo list starting at child, including each subtree
|
|
||||||
UndoNode *branch = node->child;
|
|
||||||
while (branch) {
|
|
||||||
UndoNode *next = branch->next;
|
|
||||||
free_node(branch);
|
|
||||||
branch = next;
|
|
||||||
}
|
|
||||||
node->child = nullptr;
|
node->child = nullptr;
|
||||||
}
|
|
||||||
delete node;
|
delete node;
|
||||||
|
FreeUndoForest(children);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -353,26 +432,27 @@ void
|
|||||||
UndoSystem::free_branch(UndoNode *node)
|
UndoSystem::free_branch(UndoNode *node)
|
||||||
{
|
{
|
||||||
// Free a branch list (node and its next siblings) including their subtrees
|
// Free a branch list (node and its next siblings) including their subtrees
|
||||||
while (node) {
|
FreeUndoForest(node);
|
||||||
UndoNode *next = node->next;
|
|
||||||
free_node(node);
|
|
||||||
node = next;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Iterative search for `target` below `cur`; sets out_parent to its parent.
|
||||||
static bool
|
static bool
|
||||||
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
||||||
{
|
{
|
||||||
if (!cur)
|
if (!cur)
|
||||||
return false;
|
return false;
|
||||||
for (UndoNode *child = cur->child; child != nullptr; child = child->next) {
|
std::vector<UndoNode *> stack{cur};
|
||||||
|
while (!stack.empty()) {
|
||||||
|
UndoNode *node = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
for (UndoNode *child = node->child; child != nullptr; child = child->next) {
|
||||||
if (child == target) {
|
if (child == target) {
|
||||||
out_parent = cur;
|
out_parent = node;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (dfs_find_parent(child, target, out_parent))
|
stack.push_back(child);
|
||||||
return true;
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -391,11 +471,190 @@ UndoSystem::find_parent(UndoNode *from, UndoNode *target)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Texts at least this long are held as storage spans when possible.
|
||||||
|
static constexpr std::size_t kSpanMinBytes = PieceTable::kCaptureDeletedMin;
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::try_convert_to_spans(UndoNode *node)
|
||||||
|
{
|
||||||
|
if (!buf_ || !node || node->text.size() < kSpanMinBytes)
|
||||||
|
return;
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
switch (node->type) {
|
||||||
|
case UndoType::Insert:
|
||||||
|
case UndoType::Paste:
|
||||||
|
// The inserted text is in the buffer at the node's position.
|
||||||
|
spans = buf_->SpansAt(node->row, node->col, node->text.size());
|
||||||
|
break;
|
||||||
|
case UndoType::Delete:
|
||||||
|
// The deleted text is still in storage; the deletion remembered where.
|
||||||
|
spans = buf_->TakeDeletedSpans(node->row, node->col, node->text.size());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only when the spans hold exactly the recorded text; otherwise (the
|
||||||
|
// text is not where the node says) keep the copy.
|
||||||
|
if (spans.empty() || !buf_->SpansEqual(spans, node->text))
|
||||||
|
return;
|
||||||
|
node->span_len = node->text.size();
|
||||||
|
node->spans = std::move(spans);
|
||||||
|
node->spans.shrink_to_fit();
|
||||||
|
std::string().swap(node->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::maybe_prune()
|
||||||
|
{
|
||||||
|
std::size_t budget = budget_;
|
||||||
|
if (budget == 0) {
|
||||||
|
const std::size_t content = buf_ ? buf_->ContentBytes() : 0;
|
||||||
|
budget = std::max<std::size_t>(std::size_t{64} << 20, content * 2);
|
||||||
|
}
|
||||||
|
if (bytes_ <= budget)
|
||||||
|
return;
|
||||||
|
// Prune well below the budget so this does not run on every commit.
|
||||||
|
prune_to(budget - budget / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::forget_saved_state()
|
||||||
|
{
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
base_unsaved_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Total bytes held by a node, its children and its siblings.
|
||||||
|
static std::size_t
|
||||||
|
forest_bytes(const UndoNode *first)
|
||||||
|
{
|
||||||
|
std::size_t total = 0;
|
||||||
|
std::vector<const UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const UndoNode *n = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
total += n->CostBytes();
|
||||||
|
if (n->child)
|
||||||
|
stack.push_back(n->child);
|
||||||
|
if (n->next)
|
||||||
|
stack.push_back(n->next);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
forest_contains(const UndoNode *first, const UndoNode *target)
|
||||||
|
{
|
||||||
|
if (!target)
|
||||||
|
return false;
|
||||||
|
std::vector<const UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const UndoNode *n = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
if (n == target)
|
||||||
|
return true;
|
||||||
|
if (n->child)
|
||||||
|
stack.push_back(n->child);
|
||||||
|
if (n->next)
|
||||||
|
stack.push_back(n->next);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::prune_to(const std::size_t target)
|
||||||
|
{
|
||||||
|
// The line kept: root..current, then current's active redo line.
|
||||||
|
std::vector<UndoNode *> line;
|
||||||
|
for (UndoNode *n = tree_.current; n; n = n->parent)
|
||||||
|
line.push_back(n);
|
||||||
|
std::reverse(line.begin(), line.end());
|
||||||
|
{
|
||||||
|
UndoNode *n = tree_.current ? tree_.current->child : tree_.root;
|
||||||
|
for (; n; n = n->child)
|
||||||
|
line.push_back(n);
|
||||||
|
}
|
||||||
|
if (line.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Detach and free the siblings of `keep` in the list starting at *head.
|
||||||
|
auto drop_siblings = [&](UndoNode **head, UndoNode *keep) {
|
||||||
|
UndoNode *n = *head;
|
||||||
|
*head = keep;
|
||||||
|
while (n) {
|
||||||
|
UndoNode *nx = n->next;
|
||||||
|
if (n != keep) {
|
||||||
|
n->next = nullptr;
|
||||||
|
if (forest_contains(n, tree_.saved))
|
||||||
|
forget_saved_state();
|
||||||
|
FreeUndoForest(n);
|
||||||
|
}
|
||||||
|
n = nx;
|
||||||
|
}
|
||||||
|
keep->next = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Branches off the kept line: reachable only by choosing a redo branch.
|
||||||
|
drop_siblings(&tree_.root, line.front());
|
||||||
|
for (std::size_t i = 0; i + 1 < line.size(); ++i)
|
||||||
|
drop_siblings(&line[i]->child, line[i + 1]);
|
||||||
|
bytes_ = forest_bytes(tree_.root);
|
||||||
|
|
||||||
|
// 2. The oldest edits, while they are older than the current state. An
|
||||||
|
// undo group goes whole: undoing what remained of it would stop at a
|
||||||
|
// state inside the group, which the user never saw.
|
||||||
|
std::size_t i = 0;
|
||||||
|
bool mid_group = false;
|
||||||
|
while ((bytes_ > target || mid_group) && i < line.size() && line[i] != tree_.current && tree_.current) {
|
||||||
|
UndoNode *old = line[i];
|
||||||
|
UndoNode *nx = old->child;
|
||||||
|
if (!nx)
|
||||||
|
break;
|
||||||
|
if (old->group_id != 0 && old->group_id == tree_.current->group_id)
|
||||||
|
break; // the current state's own group stays whole
|
||||||
|
// Dropping `old` loses the state before it. `old`'s own state
|
||||||
|
// becomes the first state in history.
|
||||||
|
if (tree_.saved == nullptr)
|
||||||
|
forget_saved_state();
|
||||||
|
else if (tree_.saved == old)
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
mid_group = old->group_id != 0 && nx->group_id == old->group_id;
|
||||||
|
bytes_ -= old->CostBytes();
|
||||||
|
old->child = nullptr;
|
||||||
|
delete old;
|
||||||
|
nx->parent = nullptr;
|
||||||
|
tree_.root = nx;
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The redo line beyond the current state, newest first.
|
||||||
|
if (bytes_ > target && tree_.current && tree_.current->child) {
|
||||||
|
UndoNode *future = tree_.current->child;
|
||||||
|
if (forest_contains(future, tree_.saved))
|
||||||
|
forget_saved_state();
|
||||||
|
tree_.current->child = nullptr;
|
||||||
|
bytes_ -= forest_bytes(future);
|
||||||
|
FreeUndoForest(future);
|
||||||
|
}
|
||||||
|
update_dirty_flag();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::update_dirty_flag()
|
UndoSystem::update_dirty_flag()
|
||||||
{
|
{
|
||||||
// dirty if current != saved
|
// dirty if current != saved
|
||||||
bool dirty = (tree_.current != tree_.saved);
|
bool dirty = base_unsaved_ || (tree_.current != tree_.saved);
|
||||||
buf_->SetDirty(dirty);
|
buf_->SetDirty(dirty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +683,8 @@ 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 "?";
|
||||||
}
|
}
|
||||||
@@ -434,11 +695,14 @@ UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
|
|||||||
{
|
{
|
||||||
if (!root || !target)
|
if (!root || !target)
|
||||||
return false;
|
return false;
|
||||||
if (root == target)
|
std::vector<UndoNode *> stack{root};
|
||||||
return true;
|
while (!stack.empty()) {
|
||||||
for (UndoNode *child = root->child; child != nullptr; child = child->next) {
|
UndoNode *node = stack.back();
|
||||||
if (is_descendant(child, target))
|
stack.pop_back();
|
||||||
|
if (node == target)
|
||||||
return true;
|
return true;
|
||||||
|
for (UndoNode *child = node->child; child != nullptr; child = child->next)
|
||||||
|
stack.push_back(child);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,12 +76,35 @@ public:
|
|||||||
|
|
||||||
void mark_saved();
|
void mark_saved();
|
||||||
|
|
||||||
|
// No undo state matches the file on disk any more (e.g. after crash
|
||||||
|
// recovery replayed edits onto it): the buffer stays dirty in every state
|
||||||
|
// until the next mark_saved() or clear().
|
||||||
|
void mark_base_unsaved();
|
||||||
|
|
||||||
|
// Close any open group (after an interrupted command) and seal pending.
|
||||||
|
void AbortGroups();
|
||||||
|
|
||||||
void discard_pending();
|
void discard_pending();
|
||||||
|
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
void UpdateBufferReference(Buffer &new_buf);
|
void UpdateBufferReference(Buffer &new_buf);
|
||||||
|
|
||||||
|
// History memory budget in bytes (node texts, spans and nodes). When
|
||||||
|
// committed history exceeds it, branches off the current path and then
|
||||||
|
// the oldest edits are dropped. 0 (the default) means max(64 MiB, twice
|
||||||
|
// the buffer's size).
|
||||||
|
void SetByteBudget(std::size_t bytes)
|
||||||
|
{
|
||||||
|
budget_ = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t HistoryBytes() const
|
||||||
|
{
|
||||||
|
return bytes_;
|
||||||
|
}
|
||||||
|
|
||||||
#if defined(KTE_TESTS)
|
#if defined(KTE_TESTS)
|
||||||
// Test-only introspection hook.
|
// Test-only introspection hook.
|
||||||
const UndoTree &TreeForTests() const
|
const UndoTree &TreeForTests() const
|
||||||
@@ -111,9 +134,26 @@ private:
|
|||||||
|
|
||||||
void update_dirty_flag();
|
void update_dirty_flag();
|
||||||
|
|
||||||
|
// Hold a large committed text as storage spans (see UndoNode::spans).
|
||||||
|
void try_convert_to_spans(UndoNode *node);
|
||||||
|
|
||||||
|
// Enforce the byte budget (outside groups).
|
||||||
|
void maybe_prune();
|
||||||
|
|
||||||
|
void prune_to(std::size_t target);
|
||||||
|
|
||||||
|
// The saved state's node is being dropped: no state in history matches
|
||||||
|
// the file any more.
|
||||||
|
void forget_saved_state();
|
||||||
|
|
||||||
|
std::size_t bytes_ = 0; // approximate bytes held by committed history
|
||||||
|
std::size_t budget_ = 0;
|
||||||
|
|
||||||
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
||||||
|
|
||||||
std::uint64_t active_group_id_ = 0;
|
std::uint64_t active_group_id_ = 0;
|
||||||
|
int group_depth_ = 0; // nesting depth of BeginGroup/EndGroup
|
||||||
|
bool base_unsaved_ = false; // see mark_base_unsaved()
|
||||||
std::uint64_t next_group_id_ = 1;
|
std::uint64_t next_group_id_ = 1;
|
||||||
|
|
||||||
Buffer *buf_;
|
Buffer *buf_;
|
||||||
|
|||||||
+30
-2
@@ -1,3 +1,31 @@
|
|||||||
// Placeholder translation unit for UndoTree struct definition.
|
// Undo logic is implemented in UndoSystem; this file only owns node lifetime.
|
||||||
// Undo logic is implemented in UndoSystem.
|
|
||||||
#include "UndoTree.h"
|
#include "UndoTree.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
FreeUndoForest(UndoNode *first)
|
||||||
|
{
|
||||||
|
// Nodes form a first-child/next-sibling tree whose depth equals the
|
||||||
|
// length of the edit history, so walk it with an explicit stack.
|
||||||
|
std::vector<UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
UndoNode *node = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
if (node->child)
|
||||||
|
stack.push_back(node->child);
|
||||||
|
if (node->next)
|
||||||
|
stack.push_back(node->next);
|
||||||
|
delete node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
UndoTree::~UndoTree()
|
||||||
|
{
|
||||||
|
FreeUndoForest(root);
|
||||||
|
delete pending;
|
||||||
|
}
|
||||||
|
|||||||
+12
@@ -7,4 +7,16 @@ struct UndoTree {
|
|||||||
UndoNode *current = nullptr; // current state of buffer
|
UndoNode *current = nullptr; // current state of buffer
|
||||||
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
||||||
UndoNode *pending = nullptr; // in-progress batch (detached)
|
UndoNode *pending = nullptr; // in-progress batch (detached)
|
||||||
|
|
||||||
|
// Frees the entire node graph (root's subtree/branches plus any detached
|
||||||
|
// pending node). `current`/`saved` are aliases into that same graph, not
|
||||||
|
// separately owned. Without this, closing/reloading/resetting a buffer
|
||||||
|
// (which replaces or destroys its UndoTree) leaks every UndoNode ever
|
||||||
|
// created for it.
|
||||||
|
~UndoTree();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Frees `first`, its whole child subtree, and every node on its `next`
|
||||||
|
// (sibling) chain. Iterative, so history depth cannot overflow the stack.
|
||||||
|
void FreeUndoForest(UndoNode *first);
|
||||||
|
|||||||
+31
-4
@@ -1,6 +1,6 @@
|
|||||||
cmake_minimum_required(VERSION 3.15)
|
cmake_minimum_required(VERSION 3.15)
|
||||||
|
|
||||||
# Fix up a macOS .app bundle by copying non-Qt dylibs into
|
# Fix up a macOS .app bundle by copying non-system dylibs into
|
||||||
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
|
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
@@ -29,9 +29,8 @@ set(DIRS
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
|
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
|
||||||
# libs it references recursively. Qt frameworks already live in the bundle after
|
# libs it references recursively, copying every non-system dylib (e.g. Homebrew
|
||||||
# macdeployqt; this step is primarily for non-Qt dylibs (glib, icu, pcre2, zstd,
|
# SDL2, freetype, pcre2) into Contents/Frameworks; SDL3 is handled below.
|
||||||
# dbus, etc.).
|
|
||||||
# fixup_bundle often fails if copied libraries are read-only.
|
# fixup_bundle often fails if copied libraries are read-only.
|
||||||
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
|
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
|
||||||
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
|
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
|
||||||
@@ -44,6 +43,34 @@ execute_process(COMMAND chmod -R u+w "${APP_DIR}/Contents/Frameworks")
|
|||||||
|
|
||||||
fixup_bundle("${APP_DIR}" "" "${DIRS}")
|
fixup_bundle("${APP_DIR}" "" "${DIRS}")
|
||||||
|
|
||||||
|
# Homebrew's sdl2 is sdl2-compat, which dlopen()s SDL3 at startup instead of
|
||||||
|
# linking it, so fixup_bundle never sees it. Without SDL3 in the bundle kge
|
||||||
|
# stops at launch with "Failed loading SDL3 library". sdl2-compat looks for
|
||||||
|
# @loader_path/libSDL3.dylib first; put SDL3 there.
|
||||||
|
set(SDL2_IN_BUNDLE "${APP_DIR}/Contents/Frameworks/libSDL2-2.0.0.dylib")
|
||||||
|
if (EXISTS "${SDL2_IN_BUNDLE}")
|
||||||
|
file(STRINGS "${SDL2_IN_BUNDLE}" SDL2_COMPAT_REFS REGEX "libSDL3\\.dylib")
|
||||||
|
if (SDL2_COMPAT_REFS)
|
||||||
|
find_file(SDL3_DYLIB NAMES libSDL3.0.dylib libSDL3.dylib
|
||||||
|
PATHS "/opt/homebrew/opt/sdl3/lib" "/opt/homebrew/lib" "/usr/local/opt/sdl3/lib" "/usr/local/lib"
|
||||||
|
NO_DEFAULT_PATH)
|
||||||
|
if (NOT SDL3_DYLIB)
|
||||||
|
message(FATAL_ERROR "libSDL2 in the bundle is sdl2-compat, which needs SDL3 at run time, "
|
||||||
|
"but libSDL3 was not found (brew install sdl3)")
|
||||||
|
endif ()
|
||||||
|
file(REAL_PATH "${SDL3_DYLIB}" SDL3_REAL)
|
||||||
|
set(SDL3_IN_BUNDLE "${APP_DIR}/Contents/Frameworks/libSDL3.dylib")
|
||||||
|
message(STATUS "Bundling SDL3 for sdl2-compat: ${SDL3_REAL}")
|
||||||
|
file(COPY_FILE "${SDL3_REAL}" "${SDL3_IN_BUNDLE}")
|
||||||
|
execute_process(COMMAND chmod u+w "${SDL3_IN_BUNDLE}")
|
||||||
|
execute_process(COMMAND /usr/bin/install_name_tool -id "@loader_path/libSDL3.dylib" "${SDL3_IN_BUNDLE}"
|
||||||
|
RESULT_VARIABLE SDL3_ID_RESULT)
|
||||||
|
if (NOT SDL3_ID_RESULT EQUAL 0)
|
||||||
|
message(FATAL_ERROR "install_name_tool -id failed on ${SDL3_IN_BUNDLE}")
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
|
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
|
||||||
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
|
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
|
||||||
# for it to be allowed to run.
|
# for it to be allowed to run.
|
||||||
|
|||||||
+45
-23
@@ -1,18 +1,17 @@
|
|||||||
{
|
{
|
||||||
pkgs ? import <nixpkgs> {},
|
lib,
|
||||||
lib ? pkgs.lib,
|
|
||||||
stdenv,
|
stdenv,
|
||||||
cmake,
|
cmake,
|
||||||
|
pkg-config,
|
||||||
ncurses,
|
ncurses,
|
||||||
|
pcre2,
|
||||||
SDL2,
|
SDL2,
|
||||||
libGL,
|
libGL,
|
||||||
xorg,
|
xorg,
|
||||||
kdePackages,
|
|
||||||
qt6Packages ? kdePackages.qt6Packages,
|
|
||||||
installShellFiles,
|
installShellFiles,
|
||||||
|
copyDesktopItems,
|
||||||
|
makeDesktopItem,
|
||||||
graphical ? false,
|
graphical ? false,
|
||||||
graphical-qt ? false,
|
|
||||||
...
|
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
cmakeContent = builtins.readFile ./CMakeLists.txt;
|
cmakeContent = builtins.readFile ./CMakeLists.txt;
|
||||||
@@ -23,34 +22,58 @@ let
|
|||||||
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
|
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
|
||||||
in
|
in
|
||||||
stdenv.mkDerivation {
|
stdenv.mkDerivation {
|
||||||
pname = "kte";
|
pname = if graphical then "kge" else "kte";
|
||||||
inherit version;
|
inherit version;
|
||||||
|
|
||||||
src = lib.cleanSource ./.;
|
src = lib.cleanSource ./.;
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
cmake
|
cmake
|
||||||
ncurses
|
pkg-config
|
||||||
installShellFiles
|
installShellFiles
|
||||||
]
|
] ++ lib.optionals graphical [
|
||||||
++ lib.optionals graphical [
|
copyDesktopItems
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = [
|
||||||
|
ncurses
|
||||||
|
pcre2 # regex engine (optional; std::regex without it)
|
||||||
|
] ++ lib.optionals graphical [
|
||||||
SDL2
|
SDL2
|
||||||
libGL
|
libGL
|
||||||
xorg.libX11
|
xorg.libX11
|
||||||
]
|
|
||||||
++ lib.optionals graphical-qt [
|
|
||||||
kdePackages.qt6ct
|
|
||||||
qt6Packages.qtbase
|
|
||||||
qt6Packages.wrapQtAppsHook
|
|
||||||
];
|
];
|
||||||
|
|
||||||
cmakeFlags = [
|
cmakeFlags = [
|
||||||
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
|
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
|
||||||
"-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}"
|
|
||||||
"-DCMAKE_BUILD_TYPE=Debug"
|
"-DCMAKE_BUILD_TYPE=Debug"
|
||||||
"-DKTE_STATIC_LINK=OFF"
|
"-DKTE_STATIC_LINK=OFF"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
desktopItems = lib.optionals graphical [
|
||||||
|
(makeDesktopItem {
|
||||||
|
name = "kge";
|
||||||
|
desktopName = "kge";
|
||||||
|
genericName = "Text Editor";
|
||||||
|
comment = "kyle's graphical text editor";
|
||||||
|
exec = "kge %F";
|
||||||
|
icon = "kge";
|
||||||
|
terminal = false;
|
||||||
|
categories = [ "Utility" "TextEditor" "Development" ];
|
||||||
|
mimeTypes = [
|
||||||
|
"text/plain"
|
||||||
|
"text/x-c"
|
||||||
|
"text/x-c++"
|
||||||
|
"text/x-python"
|
||||||
|
"text/x-go"
|
||||||
|
"text/x-rust"
|
||||||
|
"application/json"
|
||||||
|
"text/markdown"
|
||||||
|
"text/x-shellscript"
|
||||||
|
];
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
|
|
||||||
@@ -59,14 +82,7 @@ stdenv.mkDerivation {
|
|||||||
installManPage ../docs/kte.1
|
installManPage ../docs/kte.1
|
||||||
|
|
||||||
${lib.optionalString graphical ''
|
${lib.optionalString graphical ''
|
||||||
mkdir -p $out/bin
|
|
||||||
|
|
||||||
${if graphical-qt then ''
|
|
||||||
cp kge $out/bin/kge-qt
|
|
||||||
'' else ''
|
|
||||||
cp kge $out/bin/kge
|
cp kge $out/bin/kge
|
||||||
''}
|
|
||||||
|
|
||||||
installManPage ../docs/kge.1
|
installManPage ../docs/kge.1
|
||||||
|
|
||||||
mkdir -p $out/share/icons/hicolor/256x256/apps
|
mkdir -p $out/share/icons/hicolor/256x256/apps
|
||||||
@@ -75,4 +91,10 @@ stdenv.mkDerivation {
|
|||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "kyle's text editor" + lib.optionalString graphical " (graphical)";
|
||||||
|
platforms = lib.platforms.unix;
|
||||||
|
mainProgram = if graphical then "kge" else "kte";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ kte follows a clean separation of concerns with three main layers:
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────┐
|
┌─────────────────────────────────────────┐
|
||||||
│ Frontend Layer (Terminal/ImGui/Qt) │
|
│ Frontend Layer (Terminal/ImGui) │
|
||||||
│ - TerminalFrontend / ImGuiFrontend │
|
│ - TerminalFrontend / ImGuiFrontend │
|
||||||
│ - InputHandler + Renderer interfaces │
|
│ - InputHandler + Renderer interfaces │
|
||||||
└─────────────────────────────────────────┘
|
└─────────────────────────────────────────┘
|
||||||
@@ -154,7 +154,6 @@ 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`)
|
||||||
|
|
||||||
@@ -196,7 +195,6 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,678 @@
|
|||||||
|
# Bug and Performance Audit
|
||||||
|
|
||||||
|
**Project:** kte (Kyle's Text Editor)
|
||||||
|
**Date:** 2026-09-23
|
||||||
|
**Auditor:** Claude Code (automated review, four parallel reviewers plus
|
||||||
|
spot verification)
|
||||||
|
**Baseline:** `d8d2ae8` (debug build clean, `kte_tests` 0 failures)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
The tree was split into four review areas: core text model (PieceTable,
|
||||||
|
Buffer, OptimizedSearch), undo and swap (UndoSystem/Tree/Node, Swap,
|
||||||
|
ErrorRecovery, SyscallWrappers), commands (Command.cc, KKeymap), and
|
||||||
|
frontends/syntax (Terminal, ImGui, Qt, Editor, `syntax/`). Every finding
|
||||||
|
below was traced to a concrete scenario. Findings marked **[repro]** were
|
||||||
|
reproduced with a small test program; the rest were confirmed by reading
|
||||||
|
the code.
|
||||||
|
|
||||||
|
Finding IDs are stable and are referenced by fix commits:
|
||||||
|
|
||||||
|
- **M** — memory safety (crash, out-of-bounds, use-after-free, data race)
|
||||||
|
- **D** — data integrity (undo, swap journal, text corruption)
|
||||||
|
- **B** — incorrect behaviour
|
||||||
|
- **P** — performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M — Memory safety
|
||||||
|
|
||||||
|
### M1. PieceTable assignment keeps a stale line index **[repro]**
|
||||||
|
|
||||||
|
`PieceTable.cc:47-98`. Copy and move assignment copy `pieces_`, `add_`,
|
||||||
|
and `total_size_`, but leave the destination's `line_index_` and
|
||||||
|
`line_index_dirty_` untouched. `Editor::CloseBuffer` erases from
|
||||||
|
`std::vector<Buffer>`, which move-assigns every later buffer into the
|
||||||
|
previous slot.
|
||||||
|
|
||||||
|
Scenario: open A (`"line1\nline2\nline3\n"`), open B (`"x"`), render
|
||||||
|
both, close A. Slot 0 now holds B's text with A's index: `LineCount()`
|
||||||
|
returns 4 and `GetLineView(1)` builds a view over bytes 6–12 of a 1-byte
|
||||||
|
buffer.
|
||||||
|
|
||||||
|
Fix: mark the line index dirty (and clear it) in both assignment
|
||||||
|
operators.
|
||||||
|
|
||||||
|
### M2. Background highlighter thread races buffer mutation
|
||||||
|
|
||||||
|
`syntax/HighlighterEngine.cc:184-249`. `PrefetchViewport` (called from
|
||||||
|
the terminal and ImGui renderers) queues rows for a worker thread which
|
||||||
|
calls `buf.GetLineString`, walking `pieces_` and reading `add_`. The main
|
||||||
|
thread's `PieceTable::Insert`/`Delete` mutate both without any shared
|
||||||
|
lock; `add_.append` may reallocate. The worker also stores a raw
|
||||||
|
`Buffer*` (`pending_.buf`) which dangles when `AddBuffer`'s `push_back`
|
||||||
|
reallocates or `CloseBuffer`'s `erase` shifts buffers.
|
||||||
|
|
||||||
|
Fix: remove the background warm-up, or feed it an immutable snapshot.
|
||||||
|
|
||||||
|
### M3. Per-window SwapManager leaves dangling recorders (ImGui multi-window)
|
||||||
|
|
||||||
|
`ImGuiFrontend.cc:381-385`, `Editor.cc:205-213`. Each window's `Editor`
|
||||||
|
owns its own `SwapManager` but shares the buffer vector. A buffer created
|
||||||
|
from a secondary window keeps a `swap_rec_` pointing into that window's
|
||||||
|
manager; closing the window destroys it, and the next edit in the main
|
||||||
|
window calls into freed memory. `Rehome` from the wrong manager also
|
||||||
|
returns `nullptr`, silently disabling journaling for the primary's
|
||||||
|
buffers.
|
||||||
|
|
||||||
|
Fix: share one `SwapManager` across windows, and detach every buffer an
|
||||||
|
`Editor` attached when that `Editor` is destroyed.
|
||||||
|
|
||||||
|
### M4. Recursive undo-tree free overflows the stack **[repro]**
|
||||||
|
|
||||||
|
`UndoTree.cc:12` (`free_node_graph`), also `UndoSystem.cc:354`
|
||||||
|
(`is_descendant`, `dfs_find_parent`). Recursion depth equals history
|
||||||
|
length. About 300k nodes segfaults a debug build on an 8 MB stack;
|
||||||
|
replace-all over ~150k matches (two nodes each) followed by close or
|
||||||
|
reload is enough.
|
||||||
|
|
||||||
|
Fix: free iteratively with an explicit stack.
|
||||||
|
|
||||||
|
### M5. Jump-to-mark places the cursor past the end of the buffer
|
||||||
|
|
||||||
|
`Command.cc:3720`. Marks are not adjusted on edits and
|
||||||
|
`cmd_jump_to_mark` does not clamp. After deleting lines below a mark,
|
||||||
|
C-k j puts the cursor on a nonexistent row; `cmd_move_left` and
|
||||||
|
`cmd_move_up` (`:3866`, `:3988`) then index `rows[y]` out of bounds.
|
||||||
|
|
||||||
|
Fix: clamp in `cmd_jump_to_mark` and in `compute_mark_region`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D — Data integrity
|
||||||
|
|
||||||
|
### D1. Smart-newline undo deletes the wrong text **[repro]**
|
||||||
|
|
||||||
|
`Command.cc:3139-3146`. The indent is inserted and the cursor moved past
|
||||||
|
it before `u->Begin(UndoType::Insert)`; `UndoSystem::Begin` records the
|
||||||
|
cursor position, so the node is placed `indent.size()` columns too far
|
||||||
|
right. `" foobar"` + SmartNewline + two undos yields
|
||||||
|
`" foo next"` (the following line's text is spliced in and `bar`
|
||||||
|
is lost).
|
||||||
|
|
||||||
|
Fix: `Begin` at `new_x`, then insert, then move the cursor; group the
|
||||||
|
newline and the indent.
|
||||||
|
|
||||||
|
### D2. Cursor column left past end-of-line corrupts undo
|
||||||
|
|
||||||
|
`cmd_scroll_up`/`cmd_scroll_down` (`Command.cc:4178`, `:4215`, bound to
|
||||||
|
the mouse wheel), replace-all's cursor restore (`:2580`), and
|
||||||
|
`cmd_jump_to_mark` call `SetCursor` without clamping x. `delete_text`
|
||||||
|
does not clamp; it treats the excess as crossing the newline.
|
||||||
|
Backspace then joins lines with an empty (dropped) undo node, and
|
||||||
|
typing followed by undo deletes a newline instead of the typed
|
||||||
|
character.
|
||||||
|
|
||||||
|
Fix: clamp x to the line length in `Buffer::SetCursor`.
|
||||||
|
|
||||||
|
### D3. `delete_row` on a final line without trailing newline leaves a stray `\n`
|
||||||
|
|
||||||
|
`Buffer.cc:803`. Affects `delete_region` (C-w, M-d, M-Backspace), regex
|
||||||
|
replace (`Command.cc:2996`), reflow (`:4884`), and the kill ring.
|
||||||
|
`"foo\nbar"` + M-d at end of `foo` yields `"foo\n"`.
|
||||||
|
|
||||||
|
### D4. Phantom empty row after a trailing newline
|
||||||
|
|
||||||
|
`PieceTable::LineCount()` counts the empty row after a final `\n`.
|
||||||
|
C-k C-d on that row records a `DeleteRow("")` whose undo inserts a
|
||||||
|
line (`Command.cc:3516`); regex replace of `^` matches it and appends
|
||||||
|
an extra line (`:2985`).
|
||||||
|
|
||||||
|
### D5. Missing or wrong undo grouping
|
||||||
|
|
||||||
|
- Plain replace-all (`Command.cc:2537-2577`) has no group: N
|
||||||
|
replacements need 2N undos, and the final Insert stays pending so the
|
||||||
|
next typed characters coalesce into it.
|
||||||
|
- Visual-line newline (`:3043-3075`) records no undo at all.
|
||||||
|
|
||||||
|
### D6. Reflow on a blank line merges the adjacent paragraphs
|
||||||
|
|
||||||
|
`Command.cc:4592`. `"aaa\n\nbbb"` with the cursor on the blank line and
|
||||||
|
ESC q gives `"aaa bbb"`.
|
||||||
|
|
||||||
|
### D7. Swap journal is fragile exactly where it matters
|
||||||
|
|
||||||
|
- `Swap.cc:1344-1373`: a torn final record (the power-loss case) makes
|
||||||
|
the whole file unreadable instead of replaying the valid prefix.
|
||||||
|
- `Swap.cc:1134-1157`, `:1187`, `:1224`: after any dropped record
|
||||||
|
(breaker open, >16 MB payload, partial write) later records still
|
||||||
|
replay against a different base state; a partial write leaves bytes
|
||||||
|
that later records are appended behind.
|
||||||
|
- `Swap.cc:1040` vs `:1187`: checkpoints allow 4 GB but the record
|
||||||
|
length is 24 bits; buffers over 16 MB are never checkpointed and the
|
||||||
|
journal grows without bound.
|
||||||
|
- `Swap.cc:644`, `:708`: a failed header write leaves an fd with no
|
||||||
|
header; answering N to "delete corrupt swap?" appends new edits to the
|
||||||
|
corrupt file.
|
||||||
|
|
||||||
|
### D8. Circuit breaker failure window never expires
|
||||||
|
|
||||||
|
`ErrorRecovery.cc:77`. `last_failure_time_` is updated before
|
||||||
|
`IsWindowExpired()` reads it, so elapsed time is always zero; five
|
||||||
|
failures hours apart still open the breaker.
|
||||||
|
|
||||||
|
### D9. Stale syntax highlighting after undo-to-clean or reload
|
||||||
|
|
||||||
|
`Buffer.h:391-400`. `version_` is bumped only in `SetDirty(true)`. Undo
|
||||||
|
back to the saved node calls `SetDirty(false)`, and `OpenFromFile`
|
||||||
|
(reload) never bumps it, so `HighlighterEngine` keeps serving spans for
|
||||||
|
the old text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B — Behaviour
|
||||||
|
|
||||||
|
### B1. `set_escdelay` is compiled out
|
||||||
|
|
||||||
|
`TerminalFrontend.cc:46`. `set_escdelay` is a function in ncurses, not a
|
||||||
|
macro, so `#ifdef set_escdelay` is always false and ESC waits the
|
||||||
|
1000 ms default.
|
||||||
|
|
||||||
|
### B2. Terminal resize runs C-g
|
||||||
|
|
||||||
|
`TerminalInputHandler.cc:167-171` maps `KEY_RESIZE` to
|
||||||
|
`CommandId::Refresh`, the cancel handler: it clears the mark, cancels
|
||||||
|
prompts (including the swap-recovery prompt, so the file is never
|
||||||
|
opened), and resets the universal argument.
|
||||||
|
|
||||||
|
### B3. ESC-as-Meta flag is not cleared on most paths
|
||||||
|
|
||||||
|
`TerminalInputHandler.cc:56-279`. ESC followed by an arrow, Enter, Tab,
|
||||||
|
or a control chord leaves `esc_meta` set; the next plain `d` runs M-d.
|
||||||
|
Same in `ImGuiInputHandler.cc:43-73`.
|
||||||
|
|
||||||
|
### B4. C-h shadows Backspace in the terminal
|
||||||
|
|
||||||
|
The generic control lookup (`TerminalInputHandler.cc:264`) maps ^H to
|
||||||
|
SearchReplace before the Backspace branch (`:307`) runs.
|
||||||
|
|
||||||
|
### B5. Byte-based editing splits UTF-8
|
||||||
|
|
||||||
|
`Command.cc:3259`, `:3352`, `:3861-3947`. Backspace, delete, and
|
||||||
|
horizontal motion step by byte: Backspace on `é` leaves a lone `0xC3`,
|
||||||
|
and C-b/C-f land inside code points.
|
||||||
|
|
||||||
|
### B6. Horizontal scroll counts bytes, renderer counts cells
|
||||||
|
|
||||||
|
`compute_render_x` (`Command.cc:52-60`) counts bytes while
|
||||||
|
`TerminalRenderer.cc:269-322` skips display cells: a long CJK line
|
||||||
|
scrolls to blank with the cursor off-screen.
|
||||||
|
|
||||||
|
### B7. GUI draws non-ASCII text as replacement glyphs
|
||||||
|
|
||||||
|
Highlighters emit one span per non-ASCII byte and `ImGuiRenderer.cc`
|
||||||
|
(~510) / `QtFrontend.cc` (~333) decode each span separately.
|
||||||
|
|
||||||
|
### B8. ImGui drops the character after Ctrl+V
|
||||||
|
|
||||||
|
`ImGuiInputHandler.cc:362`, `:382`, `:401` set a suppress-next-text flag
|
||||||
|
for chords that produce no text event.
|
||||||
|
|
||||||
|
### B9. Control characters mis-measured in the terminal
|
||||||
|
|
||||||
|
`TerminalRenderer.cc:314`, `:364`, `:413` count C0 controls as width 1;
|
||||||
|
ncurses draws them as `^X`.
|
||||||
|
|
||||||
|
### B10. Qt tab width disagrees with the command layer
|
||||||
|
|
||||||
|
`QtFrontend.cc:138` uses 4; `Command.cc` uses 8.
|
||||||
|
|
||||||
|
### B11. Smart newline edits the old buffer while a prompt is open
|
||||||
|
|
||||||
|
`Command.cc:3131`. With a prompt active, `cmd_newline` accepts it and
|
||||||
|
the saved indent is inserted through a possibly stale `Buffer*`.
|
||||||
|
|
||||||
|
### B12. Line count stale after merged appends **[repro]**
|
||||||
|
|
||||||
|
`PieceTable.cc:220-260`. `addPieceBack`/`addPieceFront` merge paths
|
||||||
|
skip `InvalidateLineIndex()`. Latent today.
|
||||||
|
|
||||||
|
### B13. Syntax highlighter edge cases
|
||||||
|
|
||||||
|
Go raw strings and Rust multi-line strings do not carry state across
|
||||||
|
lines; shell `$#` is highlighted as a comment; a C++ `#define` line
|
||||||
|
opening `/*` does not carry the comment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P — Performance
|
||||||
|
|
||||||
|
### P1. Whole-file `Rows()` rebuild per frame
|
||||||
|
|
||||||
|
Renderers call `buf->Rows()` every frame (`TerminalRenderer.cc:45`,
|
||||||
|
`ImGuiRenderer.cc:69`, `QtFrontend.cc:477`), and
|
||||||
|
`ensure_cursor_visible` (`Command.cc:91`) after every command. After an
|
||||||
|
edit this rebuilds a `std::string` per line, each via two `GetRange`
|
||||||
|
calls.
|
||||||
|
|
||||||
|
### P2. Whole-buffer materialization for one line
|
||||||
|
|
||||||
|
`Buffer::GetLineView` (`Buffer.cc:633`) calls `content_.Data()`, which
|
||||||
|
copies the entire file; the line index is rebuilt byte-by-byte after
|
||||||
|
every edit.
|
||||||
|
|
||||||
|
### P3. Re-highlight from row 0 on every keystroke
|
||||||
|
|
||||||
|
`SetDirty(true)` calls `InvalidateFrom(0)`; stateful highlighters then
|
||||||
|
re-lex from the top of the file to the viewport.
|
||||||
|
|
||||||
|
### P4. Quadratic region commands
|
||||||
|
|
||||||
|
Replace-all, `delete_region`, indent/unindent, reflow, and repeated
|
||||||
|
backspace re-fetch `Rows()` after each mutation inside their loops.
|
||||||
|
|
||||||
|
### P5. Opening a file checkpoints into a journal that is then deleted
|
||||||
|
|
||||||
|
`Swap.cc:493` snapshots, writes, and fsyncs the whole file into
|
||||||
|
`unnamed-N.swp` before removing it.
|
||||||
|
|
||||||
|
### P6. Boyer–Moore bad-character shift degenerates to 1
|
||||||
|
|
||||||
|
`OptimizedSearch.cc:63`, `:96`: when the mismatched byte is absent from
|
||||||
|
the pattern the shift is 1 instead of `j`. Test-only today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended order
|
||||||
|
|
||||||
|
1. M1 (one-line fix)
|
||||||
|
2. M2 (remove or snapshot the warm-up thread)
|
||||||
|
3. D2 + M5 (clamp in `SetCursor` and jump-to-mark)
|
||||||
|
4. D1 (smart-newline undo order)
|
||||||
|
5. D7 (accept a torn final swap record)
|
||||||
|
6. P1/P2 (`Nrows()` + `GetLineString` in hot paths)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix status
|
||||||
|
|
||||||
|
Updated as fixes land on `claude/funny-fermat-gj2vm5`.
|
||||||
|
|
||||||
|
| ID | Status |
|
||||||
|
|----|--------|
|
||||||
|
| M1 | fixed: assignment operators invalidate the line index |
|
||||||
|
| M2 | fixed: background warm-up thread removed; visible rows highlighted synchronously |
|
||||||
|
| M3 | fixed: editors sharing buffers share the owner's SwapManager |
|
||||||
|
| M4 | fixed: iterative free/search; `clear()` also leaked root siblings, now frees all |
|
||||||
|
| M5 | fixed: jump-to-mark clamps; move up/down clamp the row (move-down also underflowed) |
|
||||||
|
| D1 | fixed: `Begin` before the cursor moves; newline + indent grouped |
|
||||||
|
| D2 | fixed for scroll up/down and replace-all restore |
|
||||||
|
| D3 | fixed: kill-region, regex replace, reflow and kill-line edit byte ranges in place |
|
||||||
|
| D4 | fixed: regex replace skips the row after a final newline (a zero-width pattern used to hang the editor); kill-line there is a no-op |
|
||||||
|
| D5 | fixed: replace-all one undo group; visual-line newline recorded as one group |
|
||||||
|
| D6 | fixed: reflow on a blank line is a no-op |
|
||||||
|
| D7 | fixed: torn tail replays the valid prefix; lost records mark a gap resynced by checkpoint; partial writes truncated; header failure closes; large inserts split; oversize checkpoints skipped; declined corrupt swap moved to `.corrupt` |
|
||||||
|
| D8 | fixed: window checked before recording the failure |
|
||||||
|
| D9 | fixed: every text mutation bumps the version (including undo to the saved state); reload and replace also clear undo/highlighting |
|
||||||
|
| B1 | fixed: guard on `NCURSES_VERSION` |
|
||||||
|
| B2 | fixed: `KEY_RESIZE` issues no command |
|
||||||
|
| B3 | fixed: ESC-meta flag consumed on every key; ESC ^H and ESC Enter reach their bindings |
|
||||||
|
| B4 | not changed: C-h is a deliberate binding (search & replace); terminals whose terminfo `kbs` is ^H are translated to KEY_BACKSPACE by ncurses |
|
||||||
|
| B5 | fixed: backspace/delete/left/right step over whole UTF-8 characters; up/down snap to a boundary |
|
||||||
|
| B6 | fixed: horizontal scroll and mouse column use display cells (mbrtowc/wcwidth) |
|
||||||
|
| B7 | fixed: spans are widened to whole UTF-8 characters before the GUIs draw them (`SanitizeSpans`) |
|
||||||
|
| B8 | fixed: a text-event suppression lasts only until the next key press |
|
||||||
|
| B9 | fixed: C0 controls and DEL drawn and measured as two-cell caret notation (`TermWidth.h`) |
|
||||||
|
| B10 | fixed: one tab width (`kte::kTabWidth`); the Qt frontend has since been removed |
|
||||||
|
| B13 | open (highlighter edge cases) |
|
||||||
|
| B11 | fixed: SmartNewline with a prompt open delegates to Newline |
|
||||||
|
| B12 | fixed: merged appends invalidate the line index |
|
||||||
|
| P1 | fixed: `ensure_cursor_visible` and the terminal/ImGui renderers no longer call `Rows()` |
|
||||||
|
| P2 | fixed: line index updated in place on insert/delete (14.7 ms -> 0.28 ms per keystroke on a 38 MB buffer) |
|
||||||
|
| P3 | fixed: highlighting invalidated from the edited row (46 ms -> 0.14 ms per keystroke near the end of a 100k-line C++ file) |
|
||||||
|
| P4 | fixed: replace-all, delete-region, indent/unindent and visual-line edits each apply as one edit |
|
||||||
|
| P5 | fixed: no throwaway checkpoint on filename change |
|
||||||
|
| P6 | moot: `OptimizedSearch` was test-only and has been removed |
|
||||||
|
|
||||||
|
Regression tests: `tests/test_audit_regressions.cc`. Each test fails on the
|
||||||
|
unfixed code (M4 by crashing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-up review rounds
|
||||||
|
|
||||||
|
After the first batch of fixes, further review rounds (an adversarial
|
||||||
|
review of each batch of changes, plus fresh reviews of areas the first
|
||||||
|
audit covered lightly) found further defects. All of the following are
|
||||||
|
fixed on this branch, each with a regression test that fails on the code
|
||||||
|
before the fix (unless noted).
|
||||||
|
|
||||||
|
**Crash / hang**
|
||||||
|
- Opening a directory (bad_alloc exit), a FIFO (open blocked forever), or
|
||||||
|
a device node (later replaced by a regular file on save).
|
||||||
|
- Filesystem exceptions (ENAMETOOLONG, EACCES, deleted cwd) and any other
|
||||||
|
exception escaping a command terminated the editor.
|
||||||
|
- Regex search/replace, and renderer match highlighting, overflowed the
|
||||||
|
stack on long lines (std::regex recursion); a zero-width regex replace
|
||||||
|
hung the editor (D4).
|
||||||
|
- Piped (non-tty) stdin was executed as commands, then spun at full CPU.
|
||||||
|
|
||||||
|
**Data loss / corruption**
|
||||||
|
- Recovery deleted the swap it was about to replay when another named
|
||||||
|
buffer was current (pre-existing).
|
||||||
|
- Recovered sessions appended behind a torn final record.
|
||||||
|
- Reload left the old journal (discarded edits came back on recovery);
|
||||||
|
reload discarded unsaved edits without confirmation and "reloaded" a
|
||||||
|
deleted file as empty.
|
||||||
|
- Two buffers (duplicate open, save-as onto an open file) or two
|
||||||
|
sessions shared one journal; closing/saving one deleted the other's
|
||||||
|
live journal. Journals are now flock()ed; a session never removes a
|
||||||
|
journal it does not own; unnamed journals include the pid; distinct
|
||||||
|
paths no longer map to one swap name.
|
||||||
|
- Journals now record the base file's size, mtime and CRC-32 and are
|
||||||
|
not replayed onto a file that changed afterwards.
|
||||||
|
- Quit checked only the current buffer; Enter at "Recover?" / "Save
|
||||||
|
changes?" took the destructive branch; the close prompt's save
|
||||||
|
overwrote on-disk changes; save-as overwrote other files silently.
|
||||||
|
- Kill-line with a count, stale marks in kill-region, commands running
|
||||||
|
behind an open prompt (read-only bypass, wrong buffer closed), paste
|
||||||
|
into a prompt, nested undo groups, UTF-8 splitting in word motions.
|
||||||
|
- Save: fsync retried after failure (could report success for lost
|
||||||
|
data); new files got 0600; symlinks replaced; hard links split;
|
||||||
|
owner and setuid/setgid bits lost; umask race with the writer thread.
|
||||||
|
- Writer-thread fsync raced with closes (fd reuse; kept a closed
|
||||||
|
journal's lock alive); crc32 table init race; Close() retried EINTR.
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- Motion/delete commands rebuilt every line after an edit (80-90 ms per
|
||||||
|
keystroke on 500k lines; C-u 3000 C-d took 273 s, now 0.25 s).
|
||||||
|
- Line index invalidated by consolidation; incremental index added.
|
||||||
|
- Idle redraw every 16 ms rescanned long lines (one core busy); paste
|
||||||
|
handled one key per frame; RowsView retained unbounded copies.
|
||||||
|
|
||||||
|
**Fourth round (stress testing)**
|
||||||
|
- Undo/redo of multi-line text left the cursor past the end of the
|
||||||
|
line; later edits and their undo records went astray and
|
||||||
|
delete-word-prev read out of bounds (ASan).
|
||||||
|
- A recovered buffer looked clean after one edit and its undo; closing
|
||||||
|
it discarded the recovered text and journal. Save-and-quit did not
|
||||||
|
record the saved state; stale visual-line selections pointed past the
|
||||||
|
end of the buffer; open undo groups survived an exception.
|
||||||
|
- A reload that ran out of memory emptied the buffer, and the next save
|
||||||
|
truncated the file. Exceptions from deferred opens ended the editor.
|
||||||
|
A leftover hard-link copy blocked every later save; a file in a
|
||||||
|
read-only directory could not be saved.
|
||||||
|
- The journal writer's periodic fsync held the lock every keystroke
|
||||||
|
needs (typing froze on slow disks); every open and save re-read the
|
||||||
|
whole file under that lock; two ThreadSanitizer races.
|
||||||
|
- Replace-all, yank and repeat counts issued one edit per match, line
|
||||||
|
or repetition (quadratic: minutes for 229k matches, now 0.14 s).
|
||||||
|
- With LANG unset, multibyte input was dropped. Renderer caches and the
|
||||||
|
reload confirmation were keyed on buffer addresses, which are reused
|
||||||
|
after a close; a reload confirmation survived other commands; a swap
|
||||||
|
recovery prompt from a deferred open was not drawn until a key.
|
||||||
|
|
||||||
|
**Fifth round (review of the fourth round's changes)**
|
||||||
|
- The save fallbacks added in round four (write in place when no temp
|
||||||
|
file or hard-link copy can be made) also fired on ENOSPC/EDQUOT/EIO,
|
||||||
|
truncating the file and then failing: the file on disk was left
|
||||||
|
corrupt. They now apply only when the directory refuses a new name
|
||||||
|
(EACCES, EPERM, ENAMETOOLONG); otherwise the save fails and the file
|
||||||
|
is untouched.
|
||||||
|
- Whole-buffer replace recorded the entire old and new text for undo on
|
||||||
|
every run (about 150 MB more per replace-all on a 50 MB file); only
|
||||||
|
the span between the common prefix and suffix is now recorded.
|
||||||
|
- Regex search silently never found matches on lines over 20,000 bytes;
|
||||||
|
the skip now applies only to search-as-you-type (at 2,000 bytes, where
|
||||||
|
the worst case is well under a second), says so in the status line,
|
||||||
|
and moving to the next/previous match searches every line.
|
||||||
|
- After a lost journal record, the recovery checkpoint was requested
|
||||||
|
only by later edits, at most once a second; if the first attempt
|
||||||
|
failed and the user then stopped typing, the journal stayed behind
|
||||||
|
indefinitely. The editor loop now retries it each second while idle
|
||||||
|
(this was also why SwapReplay_LostRecord_ResyncsWithCheckpoint was
|
||||||
|
flaky under load).
|
||||||
|
- Undoing C-k C-k (and redoing an inserted row) journaled the row and
|
||||||
|
its newline as two records; a checkpoint triggered by the first
|
||||||
|
already contained the newline, so recovery silently produced an
|
||||||
|
extra blank line.
|
||||||
|
- Visual-line yank with the cursor moved out of the selection (C-k a,
|
||||||
|
Enter) left it past the end of a line or the buffer, and text typed
|
||||||
|
there could not be undone. Every command now leaves the cursor
|
||||||
|
inside the buffer (checked in the command dispatcher).
|
||||||
|
- ENABLE_ASAN instrumented only C sources, i.e. none of the project;
|
||||||
|
it now applies to C++ as well. (Earlier "ASan clean" results in this
|
||||||
|
audit came from that uninstrumented build; the full suite has since
|
||||||
|
passed under a correctly instrumented ASan build with leak checks,
|
||||||
|
and the stress fuzzers ran with ASan and UBSan.)
|
||||||
|
|
||||||
|
**Sixth round (real terminal, file I/O, recovery end to end)**
|
||||||
|
- UTF-8 typed or pasted over a slow link lost characters: with the
|
||||||
|
input batch read at zero timeout, ncurses gave up on a character split
|
||||||
|
across reads and discarded the rest as invalid. kte now assembles
|
||||||
|
UTF-8 itself and waits for continuation bytes. (This regression came
|
||||||
|
from this audit's own input batching.)
|
||||||
|
- Visual-line (multi-line) insert, delete, backspace and newline used
|
||||||
|
the cursor's byte column on every selected line, splitting multibyte
|
||||||
|
characters on the others; the column is now carried in characters.
|
||||||
|
- Journal failures reached only the error log: a session locked out of
|
||||||
|
the journal by another kte (which only happens when the other opened
|
||||||
|
the file before this one's first edit), or whose journal writes
|
||||||
|
failed, silently had no or partial crash recovery. The status line now
|
||||||
|
says so (once per failure).
|
||||||
|
- Basenames over about 233 bytes produced a swap name over NAME_MAX, so
|
||||||
|
such files were never journaled. (The first fix capped the name at
|
||||||
|
exactly 255 bytes, which broke compaction's ".tmp" sibling and let
|
||||||
|
the journal grow without bound; it now leaves room for it.)
|
||||||
|
|
||||||
|
**Seventh round (highlighters, region and paragraph commands, buffers)**
|
||||||
|
- Indent-region on a selection ending in the empty row after a final
|
||||||
|
newline added a tab-only last line (empty lines are now skipped).
|
||||||
|
- Reflow emitted a prefix-only line before a first word longer than the
|
||||||
|
width (a blank line before a long URL, a bullet split from its text),
|
||||||
|
adding another on every reflow; joined paragraphs separated by a
|
||||||
|
whitespace-only line; and moved the CRs of CRLF lines into the middle
|
||||||
|
of lines.
|
||||||
|
- A counted backspace/delete that joined lines took several undos.
|
||||||
|
- The C++ highlighter scanned to the end of the line for a raw-string
|
||||||
|
'(' at every R", quadratic on long lines.
|
||||||
|
- Fuzzing every highlighter (incremental vs full), 20,000 random command
|
||||||
|
sequences, and 6,300 random buffer-list sessions found nothing else.
|
||||||
|
|
||||||
|
**Eighth round (reflow review; large-file performance)**
|
||||||
|
- Reflow's CRLF handling applied only when every line had a CR, so the
|
||||||
|
last paragraph of a CRLF file without a final newline still got CRs
|
||||||
|
mid-line; wrapping could start a line with "-" or "1.", which the next
|
||||||
|
reflow read as a list.
|
||||||
|
- Large files (26-105 MB): the stateful highlighter cached spans for
|
||||||
|
every row above the viewport (854 MB for a 78 MB C++ file after
|
||||||
|
jumping to the end; now 197 MB); search built and kept a string per
|
||||||
|
line on every keystroke after an edit (now one scan of the
|
||||||
|
materialized text: 42 -> 28 ms per keystroke on 26 MB); indent,
|
||||||
|
unindent and visual-line insert/delete/backspace/newline/yank made one
|
||||||
|
piece-table edit per row (1.1 s per keystroke with 5,000 rows
|
||||||
|
selected; now 4 ms, as one edit).
|
||||||
|
|
||||||
|
**Ninth round (review of the eighth round's changes)**
|
||||||
|
- No serious findings. Highlighting (cached vs fresh), search (against
|
||||||
|
the old per-line code, 3,200 random buffers) and the row-range edits
|
||||||
|
(differential against the previous commit, ~88k random steps: text
|
||||||
|
identical after every step; undo-all and journal replay exact) all
|
||||||
|
agreed. Redo-all after heavy branching can end on an older branch of
|
||||||
|
the undo tree (a state that did exist), as before.
|
||||||
|
|
||||||
|
**Follow-up: undo memory**
|
||||||
|
- Undo texts of 4 KiB or more are held as references to the piece
|
||||||
|
table's (append-only) storage instead of copies, verified byte for
|
||||||
|
byte when converted; undo/redo re-splice the stored bytes instead of
|
||||||
|
copying them into storage again. A replace-all session on a 26 MB file
|
||||||
|
ends at 181 MB instead of 332 MB; undo/redo cycles of a 6.6 MB kill
|
||||||
|
stay at 75 MB instead of growing by 6.6 MB each.
|
||||||
|
- History has a byte budget (default max(64 MiB, twice the buffer)):
|
||||||
|
over it, branches off the current path, then the oldest edits (whole
|
||||||
|
undo groups), then the redo line are dropped. If the saved state goes,
|
||||||
|
the buffer stays modified until the next save.
|
||||||
|
- replace_all_bytes clears undo history (it was recorded against, and
|
||||||
|
may reference, the previous content).
|
||||||
|
|
||||||
|
**Follow-up: search**
|
||||||
|
- Search finds the next match from the search origin (then from the
|
||||||
|
current match) and stops there; it no longer collects every match in
|
||||||
|
the buffer on every keystroke and next/previous. The i/N count stops
|
||||||
|
at 1,000 matches (and, for regex, after 4 MiB). On 26 MB: plain
|
||||||
|
keystroke 30 ms -> 0.1 ms, next 24 -> 3 ms, regex keystroke 434 ->
|
||||||
|
1.6 ms. Previous-match scans backwards in 1 MiB forward-searched
|
||||||
|
blocks (string_view::rfind: 511 ms -> 6 ms on 105 MB).
|
||||||
|
- Semantics change: the first match is the first one at or after the
|
||||||
|
cursor (it was the first in the buffer), and Left/Right also step
|
||||||
|
through matches in the regex replace prompt; Up/Down in a regex prompt
|
||||||
|
use the regex.
|
||||||
|
|
||||||
|
**Follow-up: journals for large buffers**
|
||||||
|
- Buffers over 16 MiB are checkpointed as CHKPT_BEGIN (size, CRC-32),
|
||||||
|
data chunks and CHKPT_END; replay applies the checkpoint only when it
|
||||||
|
is complete and its CRC matches, and treats an unfinished one as a
|
||||||
|
torn tail. Such buffers could not be checkpointed at all: a lost
|
||||||
|
record left the journal permanently behind and it was never
|
||||||
|
compacted. The journal header is now version 2 (version 1 journals
|
||||||
|
are still read; older kte builds will not read version 2).
|
||||||
|
- Checkpoint thresholds scale with the buffer: after edits amounting to
|
||||||
|
half its size, on the timer only after a sixteenth, compaction at
|
||||||
|
twice its size, gap retries spaced by size. A checkpoint that would
|
||||||
|
compact is written straight into the compacted journal.
|
||||||
|
- The recovery prompt keeps the content it replayed for the preview;
|
||||||
|
answering y installs it instead of replaying the journal again.
|
||||||
|
- The CRC of a base file over 64 MiB is computed by the journal writer
|
||||||
|
thread (it was skipped), so a touched but unchanged large file no
|
||||||
|
longer makes its journal unusable for recovery.
|
||||||
|
|
||||||
|
**Follow-up: regex engine**
|
||||||
|
- kte::Regex (RegexEngine.h) wraps PCRE2 with its JIT when libpcre2-8
|
||||||
|
is found (KTE_USE_PCRE2, default ON), std::regex otherwise. Search,
|
||||||
|
regex replace-all and the terminal and ImGui renderers use it (the Qt
|
||||||
|
frontend's highlighting still calls std::regex). PCRE2 has a match
|
||||||
|
limit, so catastrophic backtracking ("^(a+)+$") stops with a status
|
||||||
|
note instead of hanging, and does not recurse on the C stack, so its
|
||||||
|
per-keystroke line limit is 1 MiB instead of 2,000 bytes. Replacement
|
||||||
|
strings keep std::regex_replace semantics with either engine.
|
||||||
|
- On 26 MB: regex keystroke 1.6 -> 0.14 ms, next 62 -> 6 ms,
|
||||||
|
replace-all 929 -> 438 ms; a rarely matching regex over 105 MB:
|
||||||
|
1.55 s -> 0.10 s per keystroke.
|
||||||
|
- Only one checkpoint per journal is queued at a time; whole-file edits
|
||||||
|
in a row on a large buffer each queued a full copy of it.
|
||||||
|
- Review of this work found, and these are fixed: with PCRE2, '.'
|
||||||
|
matched the CR of CRLF lines (regex replace-all deleted it) — PCRE2
|
||||||
|
now uses the ANYCRLF newline convention and $ matches only at the end;
|
||||||
|
$0, out-of-range $n and $nn in replacements now follow libstdc++;
|
||||||
|
plain search visits overlapping matches in both directions (the count
|
||||||
|
could read "2/1"); the recovery prompt replays again if the journal
|
||||||
|
changed while it was open; a version 1 journal continued after
|
||||||
|
recovery gets a chunked checkpoint only by being rewritten as
|
||||||
|
version 2.
|
||||||
|
|
||||||
|
**Known limitations (not fixed)**
|
||||||
|
- Without PCRE2 (std::regex builds), catastrophic regex backtracking
|
||||||
|
can still take very long; std::regex has no limit.
|
||||||
|
- Without PCRE2 on libc++ (macOS), '^' also matches where a search
|
||||||
|
resumes inside a line (libc++ ignores match_prev_avail, and
|
||||||
|
match_not_bol with it, for '^'), and '\b' never matches at the end of
|
||||||
|
a line. Search_NextPrevVisitReferenceMatches fails in that
|
||||||
|
configuration; the default macOS build uses PCRE2.
|
||||||
|
- Typing in a multi-megabyte single line costs O(line length) per
|
||||||
|
keystroke (cursor column computed by scanning the line).
|
||||||
|
- Highlighter edge cases B13 are unchanged. (B7, B8 and B10 were fixed
|
||||||
|
in the debt pass below, once the GUIs could be built and run.)
|
||||||
|
- Journals are created at the first edit, so a second kte that opens
|
||||||
|
the file before the first one edits it is not warned at open; the
|
||||||
|
session that is locked out is told at its first edit instead.
|
||||||
|
- A regex that matches rarely or not at all scans the whole buffer
|
||||||
|
(0.1 s on 105 MB with PCRE2, 1.6 s with std::regex).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debt and performance pass (2026-09-24)
|
||||||
|
|
||||||
|
After the merge of the fixes above. Both GUI frontends (ImGui and Qt) were
|
||||||
|
built in this environment and checked by screenshot under Xvfb; the ImGui
|
||||||
|
renderer changes are pixel-identical to before on a sample with tabs,
|
||||||
|
UTF-8, syntax colouring and search highlights.
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- Typing near the top of a large file: each edit shifted every later line
|
||||||
|
start. The shift is now pending and applied on read; piece lookup
|
||||||
|
binary-searches prefix sums instead of walking the piece list. Typing on
|
||||||
|
line 10 of a 1M-line file, with a frame's worth of line reads per
|
||||||
|
keystroke: 0.52 -> 0.017 ms.
|
||||||
|
- A loaded file is adopted as the piece table's original storage: the
|
||||||
|
first keystroke no longer copies the whole file (140 ms at 200 MB), and
|
||||||
|
the read buffer is not copied again. Save writes the pieces instead of
|
||||||
|
materializing a second full copy that then stayed resident. An unedited
|
||||||
|
(single-piece) buffer is viewed in place; `GetLineView` no longer copies
|
||||||
|
the whole buffer after an edit when the line lies within one piece.
|
||||||
|
- Line reads decide "ends with a newline" from the line index instead of
|
||||||
|
fetching the last byte; the index is rebuilt with `memchr`.
|
||||||
|
- ImGui measured every span and the cursor from column 0 of the line
|
||||||
|
(quadratic on long lines); the terminal renderer rescanned spans per
|
||||||
|
byte; Qt compiled a `std::regex` per visible line per frame. All now
|
||||||
|
linear, with search-match ranges computed by one shared
|
||||||
|
`SearchHighlight`.
|
||||||
|
- The highlighter engine returns cached spans by reference and no longer
|
||||||
|
`dynamic_cast`s per call.
|
||||||
|
|
||||||
|
**Bugs found on the way**
|
||||||
|
- Qt: operators were drawn in the dark background colour (invisible);
|
||||||
|
tabs were 4 columns against the command layer's 8; text after a
|
||||||
|
multi-byte character was placed one column per byte; the editor's
|
||||||
|
column count was computed three different ways from painting and the
|
||||||
|
cell width was rounded, so on long lines the cursor drifted off the
|
||||||
|
text. Unhighlighted text between spans is now drawn.
|
||||||
|
- B7, B8 (above).
|
||||||
|
|
||||||
|
**Debt removed**
|
||||||
|
- `Buffer::Rows()` and the `Line` wrapper (tests use `GetLineString` or a
|
||||||
|
test-only `LinesForTests()`); `OptimizedSearch`, `UndoNodePool.h`, the
|
||||||
|
unused swap SPLIT/JOIN writers and several unused Editor/Buffer/PieceTable
|
||||||
|
members; mutexes that guarded only reads in single-threaded classes
|
||||||
|
(the claim that PieceTable reads were thread-safe was false).
|
||||||
|
- Consolidated: filetype detection and highlighter installation
|
||||||
|
(`Buffer::ApplyDetectedFiletype`, `InstallFiletypeHighlighter`), whole
|
||||||
|
buffer copies (`Buffer::Bytes`), character measurement
|
||||||
|
(`kte::MeasureChar`, `DisplayColumns`), span sanitizing
|
||||||
|
(`kte::SanitizeSpans`), word motion scans, the search reset, and the
|
||||||
|
regex engine's stack policy (`kte::Regex::RunGuarded`).
|
||||||
|
|
||||||
|
**Left as is**
|
||||||
|
- The C-k / ESC-meta / universal-argument key state machine is still
|
||||||
|
written once per frontend; unifying it is a behavioural change best done
|
||||||
|
with its own tests.
|
||||||
|
- The highlighter keeps one line state per row (about 40 bytes each) and
|
||||||
|
highlighters copy each line; both are modest next to the file itself.
|
||||||
|
- A long run of backspaces grows its undo record by prepending (quadratic
|
||||||
|
only in the length of one typed run).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-up: macOS build and Qt removal (2026-09-24)
|
||||||
|
|
||||||
|
The gate had only run on Linux (as root). On macOS it failed three
|
||||||
|
tests and the app bundle could not start.
|
||||||
|
|
||||||
|
- kge stopped at launch with "Failed loading SDL3 library": Homebrew's
|
||||||
|
sdl2 is sdl2-compat, which dlopen()s SDL3, and the bundle fixup copies
|
||||||
|
only linked libraries. SDL3 is now bundled when sdl2-compat is.
|
||||||
|
- Saving lost setuid/setgid when not running as root: writing the data
|
||||||
|
clears them, and the mode was applied before the write. Ownership and
|
||||||
|
mode now go on after the data; in-place writes restore the mode.
|
||||||
|
- Two tests used libc++'s std::regex as the reference, which misses
|
||||||
|
'\b' at the end of the subject and ignores match_prev_avail for '^'.
|
||||||
|
- After C-k, pressing Shift (for C-k C, C-k ^ or a shifted symbol) or
|
||||||
|
pressing Ctrl again (C-k C-d) ended the prefix as an unknown command
|
||||||
|
in kge; modifier keys alone are now ignored there (checked with
|
||||||
|
synthetic SDL key events against kge's objects).
|
||||||
|
- The Qt frontend was removed: unused, out of make-app-release since
|
||||||
|
March, not built by the gate, and its bundle could not start (no
|
||||||
|
platform plugin). Every renderer and input change had to be made in
|
||||||
|
it separately.
|
||||||
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
Based on the project structure and the presence of files like
|
|
||||||
`imgui.ini`, `GUIFrontend.h`, and `TerminalFrontend.h`, here is an
|
|
||||||
analysis of the difficulty and challenges involved in adding a GTK or Qt
|
|
||||||
version of the GUI.
|
|
||||||
|
|
||||||
### **Executive Summary: Difficulty Level - Moderate**
|
|
||||||
|
|
||||||
The project is well-architected for this task. It already supports
|
|
||||||
multiple frontends (Terminal vs. GUI), meaning the "Core Logic" (
|
|
||||||
Buffers, Syntax, Commands) is successfully decoupled from the "View" (
|
|
||||||
Rendering/Input). However, the specific move from an **Immediate Mode**
|
|
||||||
GUI (likely Dear ImGui, implied by `imgui.ini` and standard naming
|
|
||||||
patterns) to a **Retained Mode** GUI (Qt/GTK) introduces specific
|
|
||||||
architectural frictions regarding the event loop and state management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **1. Architectural Analysis**
|
|
||||||
|
|
||||||
The existence of abstract interfaces—likely `Frontend`, `Renderer`, and
|
|
||||||
`InputHandler`—is the biggest asset here.
|
|
||||||
|
|
||||||
* **Current State:**
|
|
||||||
* **Abstract Layer:** `Frontend.h`, `Renderer.h`, `InputHandler.h`
|
|
||||||
likely define the contract.
|
|
||||||
* **Implementations:**
|
|
||||||
* `Terminal*` files implement the TUI (likely ncurses or VT100).
|
|
||||||
* `GUI*` files (currently ImGui) implement the graphical
|
|
||||||
version.
|
|
||||||
* **The Path Forward:**
|
|
||||||
* You would create `QtFrontend`, `QtRenderer`, `QtInputHandler` (or
|
|
||||||
GTK equivalents).
|
|
||||||
* Because the core logic (`Editor.cc`, `Buffer.cc`) calls these
|
|
||||||
interfaces, you theoretically don't need to touch the core text
|
|
||||||
manipulation code.
|
|
||||||
|
|
||||||
### **2. Key Challenges**
|
|
||||||
|
|
||||||
#### **A. The Event Loop Inversion (Main Challenge)**
|
|
||||||
|
|
||||||
* **Current (ImGui):** Typically, the application owns the loop:
|
|
||||||
`while (running) { HandleInput(); Update(); Render(); }`. The
|
|
||||||
application explicitly tells the GUI to draw every frame.
|
|
||||||
* **Target (Qt/GTK):** The framework owns the loop: `app.exec()` or
|
|
||||||
`gtk_main()`. The framework calls *you* when events happen.
|
|
||||||
* **Difficulty:** You will need to refactor `main.cc` or the entry point
|
|
||||||
to hand over control to the Qt/GTK application object. The Editor's "
|
|
||||||
tick" function might need to be connected to a timer or an idle event
|
|
||||||
in the new framework to ensure logic updates happen.
|
|
||||||
|
|
||||||
#### **B. Rendering Paradigm: Canvas vs. Widgets**
|
|
||||||
|
|
||||||
* **The "Easy" Way (Custom Canvas):**
|
|
||||||
* Implement the `QtRenderer` by subclassing `QWidget` and overriding
|
|
||||||
`paintEvent`.
|
|
||||||
* Use `QPainter` (or Cairo in GTK) to draw text, cursors, and
|
|
||||||
selections exactly where the `Renderer` interface says to.
|
|
||||||
* **Pros:** Keeps the code similar to the current ImGui/Terminal
|
|
||||||
renderers.
|
|
||||||
* **Cons:** You lose native accessibility and some native "feel" (
|
|
||||||
scrolling physics, native text context menus).
|
|
||||||
* **The "Hard" Way (Native Widgets):**
|
|
||||||
* Trying to map an internal `Buffer` directly to a `QTextEdit` or
|
|
||||||
`GtkTextView`.
|
|
||||||
* **Difficulty:** This is usually very hard because the Editor core
|
|
||||||
likely manages its own cursor, selection, and syntax highlighting.
|
|
||||||
Syncing that internal state with a complex native widget often
|
|
||||||
leads to conflicts.
|
|
||||||
* **Recommendation:** Stick to the "Custom Canvas" approach (drawing
|
|
||||||
text manually on a surface) to preserve the custom editor
|
|
||||||
behavior (vim-like modes, specific syntax highlighting).
|
|
||||||
|
|
||||||
#### **C. Input Handling**
|
|
||||||
|
|
||||||
* **Challenge:** Mapping Qt/GTK key events to the internal `Keymap`.
|
|
||||||
* **Detail:** ImGui and Terminal libraries often provide raw scancodes
|
|
||||||
or simple chars. Qt/GTK provide complex Event objects. You will need a
|
|
||||||
translation layer in `QtInputHandler::keyPressEvent` that converts
|
|
||||||
`Qt::Key_Escape` -> `KKey::Escape` (or your internal equivalent).
|
|
||||||
|
|
||||||
### **3. Portability of Assets**
|
|
||||||
|
|
||||||
#### **Themes (Colors)**
|
|
||||||
|
|
||||||
* **Feasibility:** High.
|
|
||||||
* **Approach:** `GUITheme.h` likely contains structs with RGB/Hex
|
|
||||||
values. Qt supports stylesheets (QSS) and GTK uses CSS. You can write
|
|
||||||
a converter that reads your current theme configuration and generates
|
|
||||||
a CSS string to apply to your window, or simply use the RGB values
|
|
||||||
directly in your custom `QPainter`/Cairo drawing logic.
|
|
||||||
|
|
||||||
#### **Fonts**
|
|
||||||
|
|
||||||
* **Feasibility:** Moderate.
|
|
||||||
* **Approach:**
|
|
||||||
* **ImGui:** Usually loads a TTF into a texture atlas.
|
|
||||||
* **Qt/GTK:** Uses the system font engine (Freetype/Pango).
|
|
||||||
* **Challenge:** You won't use the texture atlas anymore. You will
|
|
||||||
simply request a font family and size (e.g.,
|
|
||||||
`QFont("JetBrains Mono", 12)`). You may need to ensure your custom
|
|
||||||
renderer calculates character width/height metrics correctly using
|
|
||||||
`QFontMetrics` (Qt) or `PangoLayout` (GTK) to align the grid
|
|
||||||
correctly.
|
|
||||||
|
|
||||||
### **4. Summary Recommendation**
|
|
||||||
|
|
||||||
If you proceed, **Qt** is generally considered easier to integrate with
|
|
||||||
C++ projects than GTK (which is C-based, though `gtkmm` exists).
|
|
||||||
|
|
||||||
1. **Create a `QtFrontend`** class inheriting from `Frontend`.
|
|
||||||
2. **Create a `QtWindow`** class inheriting from `QWidget`.
|
|
||||||
3. **Implement `QtRenderer`** that holds a pointer to the `QtWindow`.
|
|
||||||
When the core calls `DrawText()`, `QtRenderer` should queue that
|
|
||||||
command or draw directly to the widget's paint buffer.
|
|
||||||
4. **Refactor `main.cc`** to instantiate `QApplication` instead of the
|
|
||||||
current manual loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Note (2025-12): The Qt frontend defers all key processing to the
|
|
||||||
existing command subsystem and keymaps, mirroring the ImGui path. There
|
|
||||||
are no Qt-only keybindings; `QtInputHandler` translates Qt key events
|
|
||||||
into the shared keymap flow (C-k prefix, Ctrl chords, ESC/Meta,
|
|
||||||
universal-argument digits, printable insertion).
|
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
outputs =
|
outputs = { self, nixpkgs, ... }:
|
||||||
inputs@{ self, nixpkgs, ... }:
|
|
||||||
let
|
let
|
||||||
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
|
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
|
||||||
pkgsFor = system: import nixpkgs { inherit system; };
|
pkgsFor = system: import nixpkgs { inherit system; };
|
||||||
@@ -13,9 +12,26 @@
|
|||||||
packages = eachSystem (system: rec {
|
packages = eachSystem (system: rec {
|
||||||
default = kte;
|
default = kte;
|
||||||
full = kge;
|
full = kge;
|
||||||
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; graphical-qt = false; };
|
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; };
|
||||||
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; };
|
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; };
|
||||||
qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
devShells = eachSystem (system:
|
||||||
|
let pkgs = pkgsFor system;
|
||||||
|
in {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
inputsFrom = [ self.packages.${system}.kge ];
|
||||||
|
packages = with pkgs; [ gdb valgrind ];
|
||||||
|
};
|
||||||
|
terminal = pkgs.mkShell {
|
||||||
|
inputsFrom = [ self.packages.${system}.kte ];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
overlays.default = final: prev: {
|
||||||
|
kte = self.packages.${final.system}.kte;
|
||||||
|
kge = self.packages.${final.system}.kge;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=kge
|
||||||
|
GenericName=Text Editor
|
||||||
|
Comment=kyle's graphical text editor
|
||||||
|
Exec=/usr/local/bin/kge %F
|
||||||
|
TryExec=/usr/local/bin/kge
|
||||||
|
Icon=kge
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=true
|
||||||
|
StartupWMClass=kge
|
||||||
|
Categories=Utility;TextEditor;
|
||||||
|
MimeType=text/plain;text/x-c;text/x-c++;text/x-python;text/x-go;text/x-rust;application/json;text/markdown;text/x-shellscript;
|
||||||
|
Keywords=text;editor;code;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <clocale>
|
#include <clocale>
|
||||||
|
#include <langinfo.h>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
#include <random>
|
#include <random>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
|
#include <filesystem>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
@@ -23,12 +25,8 @@
|
|||||||
#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
|
||||||
@@ -112,10 +110,33 @@ 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();
|
||||||
@@ -255,7 +276,18 @@ main(int argc, char *argv[])
|
|||||||
// Fall through: not a +number, treat as filename starting with '+'
|
// Fall through: not a +number, treat as filename starting with '+'
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string path = arg;
|
// Resolve to absolute path now, before any
|
||||||
|
// chdir (macOS GUI changes CWD to HOME before
|
||||||
|
// deferred opens are processed).
|
||||||
|
std::string path = arg;
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(path);
|
||||||
|
if (p.is_relative()) {
|
||||||
|
path = std::filesystem::absolute(p).string();
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
// Fall through with original path
|
||||||
|
}
|
||||||
editor.RequestOpenFile(path, pending_line);
|
editor.RequestOpenFile(path, pending_line);
|
||||||
pending_line = 0; // consumed (if set)
|
pending_line = 0; // consumed (if set)
|
||||||
}
|
}
|
||||||
@@ -280,6 +312,20 @@ main(int argc, char *argv[])
|
|||||||
fe = std::make_unique<TerminalFrontend>();
|
fe = std::make_unique<TerminalFrontend>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guarantee Shutdown() runs on every exit path from here on (normal
|
||||||
|
// return, early return, or exception unwinding e.g. from Init/Execute/
|
||||||
|
// Step) so a crash never leaves the terminal in raw/cbreak mode.
|
||||||
|
struct FrontendShutdownGuard {
|
||||||
|
Frontend *fe;
|
||||||
|
|
||||||
|
|
||||||
|
~FrontendShutdownGuard()
|
||||||
|
{
|
||||||
|
if (fe)
|
||||||
|
fe->Shutdown();
|
||||||
|
}
|
||||||
|
} shutdown_guard{fe.get()};
|
||||||
|
|
||||||
#if defined(KTE_BUILD_GUI) && defined(__APPLE__)
|
#if defined(KTE_BUILD_GUI) && defined(__APPLE__)
|
||||||
if (use_gui) {
|
if (use_gui) {
|
||||||
/* likely using the .app, so need to cd */
|
/* likely using the .app, so need to cd */
|
||||||
@@ -308,8 +354,6 @@ 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();
|
||||||
|
|||||||
@@ -14,19 +14,3 @@ 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 ..
|
|
||||||
|
|||||||
@@ -163,11 +163,14 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
|||||||
if (c == 'R' && i + 1 < n && s[i + 1] == '"') {
|
if (c == 'R' && i + 1 < n && s[i + 1] == '"') {
|
||||||
int k = i + 2;
|
int k = i + 2;
|
||||||
std::string delim;
|
std::string delim;
|
||||||
while (k < n && s[k] != '(') {
|
// A raw-string delimiter is at most 16 characters (and has no
|
||||||
|
// spaces, backslashes or parentheses); scanning the rest of the
|
||||||
|
// line for '(' at every R" made long lines quadratic.
|
||||||
|
while (k < n && s[k] != '(' && delim.size() <= 16) {
|
||||||
delim.push_back(s[k]);
|
delim.push_back(s[k]);
|
||||||
++k;
|
++k;
|
||||||
}
|
}
|
||||||
if (k < n && s[k] == '(') {
|
if (k < n && s[k] == '(' && delim.size() <= 16) {
|
||||||
int body_start = k + 1;
|
int body_start = k + 1;
|
||||||
std::string needle = ")" + delim + "\"";
|
std::string needle = ")" + delim + "\"";
|
||||||
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
||||||
|
|||||||
+35
-2
@@ -46,14 +46,45 @@ GoHighlighter::GoHighlighter()
|
|||||||
void
|
void
|
||||||
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
|
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;
|
return state;
|
||||||
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];
|
||||||
@@ -82,7 +113,8 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
|||||||
}
|
}
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
push(out, i, n, TokenKind::Comment);
|
push(out, i, n, TokenKind::Comment);
|
||||||
break;
|
state.in_block_comment = true;
|
||||||
|
return state;
|
||||||
} else {
|
} else {
|
||||||
push(out, i, j, TokenKind::Comment);
|
push(out, i, j, TokenKind::Comment);
|
||||||
i = j;
|
i = j;
|
||||||
@@ -152,5 +184,6 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
|||||||
push(out, i, i + 1, TokenKind::Default);
|
push(out, i, i + 1, TokenKind::Default);
|
||||||
++i;
|
++i;
|
||||||
}
|
}
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
@@ -5,12 +5,17 @@
|
|||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
class GoHighlighter final : public LanguageHighlighter {
|
class GoHighlighter final : public StatefulHighlighter {
|
||||||
public:
|
public:
|
||||||
GoHighlighter();
|
GoHighlighter();
|
||||||
|
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||||
|
|
||||||
|
LineState HighlightLineStateful(const Buffer &buf,
|
||||||
|
int row,
|
||||||
|
const LineState &prev,
|
||||||
|
std::vector<HighlightSpan> &out) const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
std::unordered_set<std::string> types_;
|
std::unordered_set<std::string> types_;
|
||||||
|
|||||||
+77
-165
@@ -1,194 +1,122 @@
|
|||||||
#include "HighlighterEngine.h"
|
#include "HighlighterEngine.h"
|
||||||
#include "../Buffer.h"
|
#include "../Buffer.h"
|
||||||
#include "LanguageHighlighter.h"
|
#include "LanguageHighlighter.h"
|
||||||
#include <thread>
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
HighlighterEngine::HighlighterEngine() = default;
|
HighlighterEngine::HighlighterEngine() = default;
|
||||||
|
|
||||||
|
|
||||||
HighlighterEngine::~HighlighterEngine()
|
HighlighterEngine::~HighlighterEngine() = default;
|
||||||
{
|
|
||||||
// stop background worker
|
|
||||||
if (worker_running_.load()) {
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
|
||||||
worker_running_.store(false);
|
|
||||||
has_request_ = true; // wake it up to exit
|
|
||||||
}
|
|
||||||
cv_.notify_one();
|
|
||||||
if (worker_.joinable())
|
|
||||||
worker_.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl)
|
HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
|
||||||
hl_ = std::move(hl);
|
hl_ = std::move(hl);
|
||||||
cache_.clear();
|
clear_caches_locked();
|
||||||
state_cache_.clear();
|
|
||||||
state_last_contig_.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
LineHighlight
|
void
|
||||||
|
HighlighterEngine::clear_caches_locked() const
|
||||||
|
{
|
||||||
|
cache_.clear();
|
||||||
|
states_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
HighlighterEngine::invalidate_from_locked(int row) const
|
||||||
|
{
|
||||||
|
if (row < 0)
|
||||||
|
row = 0;
|
||||||
|
cache_.erase(cache_.lower_bound(row), cache_.end());
|
||||||
|
if (states_.size() > static_cast<std::size_t>(row))
|
||||||
|
states_.resize(static_cast<std::size_t>(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const LineHighlight &
|
||||||
HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const
|
HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const
|
||||||
{
|
{
|
||||||
std::unique_lock<std::mutex> lock(mtx_);
|
if (!have_version_ || buf_version != version_) {
|
||||||
auto it = cache_.find(row);
|
// Content changed without OnEdit: nothing cached can be trusted.
|
||||||
if (it != cache_.end() && it->second.version == buf_version) {
|
clear_caches_locked();
|
||||||
return it->second; // return by value (copy)
|
version_ = buf_version;
|
||||||
|
have_version_ = true;
|
||||||
}
|
}
|
||||||
|
if (auto it = cache_.find(row); it != cache_.end())
|
||||||
|
return it->second;
|
||||||
|
|
||||||
// We'll compute into a local result to avoid exposing references to cache
|
|
||||||
LineHighlight result;
|
LineHighlight result;
|
||||||
result.version = buf_version;
|
result.version = buf_version;
|
||||||
result.spans.clear();
|
if (hl_ && row >= 0 && !hl_->Stateful()) {
|
||||||
|
hl_->HighlightLine(buf, row, result.spans);
|
||||||
if (!hl_) {
|
} else if (hl_ && row >= 0) {
|
||||||
// Cache empty result and return it
|
const auto *stateful = static_cast<const StatefulHighlighter *>(hl_.get());
|
||||||
cache_[row] = result;
|
// Continue from the last row with a known end state (or the row
|
||||||
return result;
|
// just above the target, if that is known), caching every row's
|
||||||
|
// spans and state along the way so neighbouring rows are cache hits.
|
||||||
|
const int known = static_cast<int>(states_.size()) - 1;
|
||||||
|
const int start = std::min(known, row - 1);
|
||||||
|
StatefulHighlighter::LineState state = (start >= 0) ? states_[static_cast<std::size_t>(start)]
|
||||||
|
: StatefulHighlighter::LineState{};
|
||||||
|
const int nrows = static_cast<int>(buf.Nrows());
|
||||||
|
for (int r = start + 1; r <= row && r < nrows; ++r) {
|
||||||
|
LineHighlight lh;
|
||||||
|
lh.version = buf_version;
|
||||||
|
state = stateful->HighlightLineStateful(buf, r, state, lh.spans);
|
||||||
|
if (static_cast<std::size_t>(r) == states_.size())
|
||||||
|
states_.push_back(state);
|
||||||
|
else
|
||||||
|
states_[static_cast<std::size_t>(r)] = state;
|
||||||
|
if (r == row)
|
||||||
|
result = std::move(lh);
|
||||||
|
else if (row - r <= kCacheNear)
|
||||||
|
cache_.insert_or_assign(r, std::move(lh));
|
||||||
|
// Rows further above only needed their end state (kept in
|
||||||
|
// states_): caching their spans too held about 10x the file size
|
||||||
|
// in memory after jumping to the end of a large file.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trim first: the entry returned must survive until the next call.
|
||||||
|
trim_cache_locked(row);
|
||||||
|
return cache_.insert_or_assign(row, std::move(result)).first->second;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy shared_ptr-like raw pointer for use outside critical sections
|
|
||||||
LanguageHighlighter *hl_ptr = hl_.get();
|
|
||||||
bool is_stateful = dynamic_cast<StatefulHighlighter *>(hl_ptr) != nullptr;
|
|
||||||
|
|
||||||
if (!is_stateful) {
|
void
|
||||||
// Stateless fast path: we can release the lock while computing to reduce contention
|
HighlighterEngine::trim_cache_locked(const int row) const
|
||||||
lock.unlock();
|
{
|
||||||
hl_ptr->HighlightLine(buf, row, result.spans);
|
if (cache_.size() <= kCacheMax)
|
||||||
// Update cache and return
|
return;
|
||||||
std::lock_guard<std::mutex> gl(mtx_);
|
cache_.erase(cache_.begin(), cache_.lower_bound(row - kCacheNear));
|
||||||
cache_[row] = result;
|
cache_.erase(cache_.upper_bound(row + kCacheNear), cache_.end());
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stateful path: we need to walk from a known previous state. Keep lock while consulting caches,
|
|
||||||
// but release during heavy computation.
|
|
||||||
auto *stateful = static_cast<StatefulHighlighter *>(hl_ptr);
|
|
||||||
|
|
||||||
StatefulHighlighter::LineState prev_state;
|
|
||||||
int start_row = -1;
|
|
||||||
if (!state_cache_.empty()) {
|
|
||||||
// linear search over map (unordered), track best candidate
|
|
||||||
int best = -1;
|
|
||||||
for (const auto &kv: state_cache_) {
|
|
||||||
int r = kv.first;
|
|
||||||
// Only use cached state if it's for the current version and row still exists
|
|
||||||
if (r <= row - 1 && kv.second.version == buf_version) {
|
|
||||||
// Validate that the cached row index is still valid in the buffer
|
|
||||||
if (r >= 0 && static_cast<std::size_t>(r) < buf.Nrows()) {
|
|
||||||
if (r > best)
|
|
||||||
best = r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (best >= 0) {
|
|
||||||
start_row = best;
|
|
||||||
prev_state = state_cache_.at(best).state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We'll compute states and the target line's spans without holding the lock for most of the work.
|
|
||||||
// Create a local copy of prev_state and iterate rows; we will update caches under lock.
|
|
||||||
lock.unlock();
|
|
||||||
StatefulHighlighter::LineState cur_state = prev_state;
|
|
||||||
for (int r = start_row + 1; r <= row; ++r) {
|
|
||||||
std::vector<HighlightSpan> tmp;
|
|
||||||
std::vector<HighlightSpan> &out = (r == row) ? result.spans : tmp;
|
|
||||||
auto next_state = stateful->HighlightLineStateful(buf, r, cur_state, out);
|
|
||||||
// Update state cache for r
|
|
||||||
std::lock_guard<std::mutex> gl(mtx_);
|
|
||||||
StateEntry se;
|
|
||||||
se.version = buf_version;
|
|
||||||
se.state = next_state;
|
|
||||||
state_cache_[r] = se;
|
|
||||||
cur_state = next_state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store in cache and return by value
|
|
||||||
lock.lock();
|
|
||||||
cache_[row] = result;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
HighlighterEngine::InvalidateFrom(int row)
|
HighlighterEngine::InvalidateFrom(int row)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
invalidate_from_locked(row);
|
||||||
if (cache_.empty())
|
|
||||||
return;
|
|
||||||
// Simple implementation: erase all rows >= row
|
|
||||||
for (auto it = cache_.begin(); it != cache_.end();) {
|
|
||||||
if (it->first >= row)
|
|
||||||
it = cache_.erase(it);
|
|
||||||
else
|
|
||||||
++it;
|
|
||||||
}
|
|
||||||
if (!state_cache_.empty()) {
|
|
||||||
for (auto it = state_cache_.begin(); it != state_cache_.end();) {
|
|
||||||
if (it->first >= row)
|
|
||||||
it = state_cache_.erase(it);
|
|
||||||
else
|
|
||||||
++it;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
HighlighterEngine::ensure_worker_started() const
|
HighlighterEngine::OnEdit(int row, std::uint64_t buf_version)
|
||||||
{
|
{
|
||||||
if (worker_running_.load())
|
if (have_version_)
|
||||||
return;
|
invalidate_from_locked(row);
|
||||||
worker_running_.store(true);
|
version_ = buf_version;
|
||||||
worker_ = std::thread([this]() {
|
have_version_ = true;
|
||||||
this->worker_loop();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void
|
|
||||||
HighlighterEngine::worker_loop() const
|
|
||||||
{
|
|
||||||
std::unique_lock<std::mutex> lock(mtx_);
|
|
||||||
while (worker_running_.load()) {
|
|
||||||
cv_.wait(lock, [this]() {
|
|
||||||
return has_request_ || !worker_running_.load();
|
|
||||||
});
|
|
||||||
if (!worker_running_.load())
|
|
||||||
break;
|
|
||||||
WarmRequest req = pending_;
|
|
||||||
has_request_ = false;
|
|
||||||
// Copy locals then release lock while computing
|
|
||||||
lock.unlock();
|
|
||||||
if (req.buf) {
|
|
||||||
int start = std::max(0, req.start_row);
|
|
||||||
int end = std::max(start, req.end_row);
|
|
||||||
int skip_f = std::min(req.skip_first, req.skip_last);
|
|
||||||
int skip_l = std::max(req.skip_first, req.skip_last);
|
|
||||||
for (int r = start; r <= end; ++r) {
|
|
||||||
// Avoid touching rows that the foreground just computed/drew.
|
|
||||||
if (r >= skip_f && r <= skip_l)
|
|
||||||
continue;
|
|
||||||
// Compute line; GetLine is thread-safe and will refresh caches.
|
|
||||||
(void) this->GetLine(*req.buf, r, req.version);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lock.lock();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version,
|
HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version,
|
||||||
int warm_margin) const
|
int /*warm_margin*/) const
|
||||||
{
|
{
|
||||||
if (row_count <= 0)
|
if (row_count <= 0)
|
||||||
return;
|
return;
|
||||||
@@ -204,21 +132,5 @@ HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_co
|
|||||||
for (int r = start; r <= end; ++r) {
|
for (int r = start; r <= end; ++r) {
|
||||||
(void) GetLine(buf, r, buf_version);
|
(void) GetLine(buf, r, buf_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue background warm-around
|
|
||||||
int warm_start = std::max(0, start - warm_margin);
|
|
||||||
int warm_end = std::min(max_rows - 1, end + warm_margin);
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
|
||||||
pending_.buf = &buf;
|
|
||||||
pending_.version = buf_version;
|
|
||||||
pending_.start_row = warm_start;
|
|
||||||
pending_.end_row = warm_end;
|
|
||||||
pending_.skip_first = start;
|
|
||||||
pending_.skip_last = end;
|
|
||||||
has_request_ = true;
|
|
||||||
}
|
|
||||||
ensure_worker_started();
|
|
||||||
cv_.notify_one();
|
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
+34
-46
@@ -1,14 +1,11 @@
|
|||||||
// HighlighterEngine.h - caching layer for per-line highlights
|
// HighlighterEngine.h - caching layer for per-line highlights (main thread
|
||||||
|
// only, like the Buffer it reads)
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <unordered_map>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <mutex>
|
|
||||||
#include <condition_variable>
|
|
||||||
#include <atomic>
|
|
||||||
#include <thread>
|
|
||||||
|
|
||||||
#include "../Highlight.h"
|
#include "../Highlight.h"
|
||||||
#include "LanguageHighlighter.h"
|
#include "LanguageHighlighter.h"
|
||||||
@@ -24,14 +21,20 @@ public:
|
|||||||
|
|
||||||
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
|
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
|
||||||
|
|
||||||
// Retrieve highlights for a given line and buffer version.
|
// Highlights for a line at a buffer version, computed if not cached.
|
||||||
// Returns a copy to avoid lifetime issues across threads/renderers.
|
// The reference is valid until the next call on this engine (a later
|
||||||
// If cache is stale, recompute using the current highlighter.
|
// lookup may evict the entry); copy it to keep it longer.
|
||||||
LineHighlight GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
|
const LineHighlight &GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
|
||||||
|
|
||||||
// Invalidate cached lines from row (inclusive)
|
// Invalidate cached lines from row (inclusive)
|
||||||
void InvalidateFrom(int row);
|
void InvalidateFrom(int row);
|
||||||
|
|
||||||
|
// The buffer changed at `row` and is now at `buf_version`. Highlighting
|
||||||
|
// of a row depends only on the rows above it, so rows before `row` stay
|
||||||
|
// cached. A version change that arrives without OnEdit (a path that
|
||||||
|
// changed content without reporting it) still clears the whole cache.
|
||||||
|
void OnEdit(int row, std::uint64_t buf_version);
|
||||||
|
|
||||||
|
|
||||||
bool HasHighlighter() const
|
bool HasHighlighter() const
|
||||||
{
|
{
|
||||||
@@ -39,52 +42,37 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Phase 3: viewport-first prefetch and background warming
|
// Compute highlights for the visible range so drawing hits the cache.
|
||||||
// Compute only the visible range now, and enqueue a background warm-around task.
|
// There is deliberately no background warming: a worker thread would read
|
||||||
// warm_margin: how many extra lines above/below to warm in the background.
|
// the Buffer while the main thread edits it (and could outlive a moved
|
||||||
|
// Buffer), and the PieceTable is not safe for concurrent read and write.
|
||||||
|
// warm_margin is accepted for source compatibility and ignored.
|
||||||
void PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version,
|
void PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version,
|
||||||
int warm_margin = 200) const;
|
int warm_margin = 200) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unique_ptr<LanguageHighlighter> hl_;
|
std::unique_ptr<LanguageHighlighter> hl_;
|
||||||
// Simple cache by row index (mutable to allow caching in const GetLine)
|
// Cached spans by row, valid for version_ (ordered so InvalidateFrom can
|
||||||
mutable std::unordered_map<int, LineHighlight> cache_;
|
// drop a tail cheaply).
|
||||||
|
mutable std::map<int, LineHighlight> cache_;
|
||||||
|
|
||||||
// For stateful highlighters, remember per-line state (state after finishing that row)
|
// For stateful highlighters: states_[r] is the state after row r, for
|
||||||
struct StateEntry {
|
// every row 0..states_.size()-1 (always a contiguous prefix).
|
||||||
std::uint64_t version{0};
|
mutable std::vector<StatefulHighlighter::LineState> states_;
|
||||||
// Using the interface type; forward-declare via header
|
|
||||||
StatefulHighlighter::LineState state;
|
|
||||||
};
|
|
||||||
|
|
||||||
mutable std::unordered_map<int, StateEntry> state_cache_;
|
// Buffer version the caches describe.
|
||||||
|
mutable std::uint64_t version_{0};
|
||||||
|
mutable bool have_version_{false};
|
||||||
|
|
||||||
// Track best known contiguous state row for a given version to avoid O(n) scans
|
void clear_caches_locked() const;
|
||||||
mutable std::unordered_map<std::uint64_t, int> state_last_contig_;
|
|
||||||
|
|
||||||
// Thread-safety for caches and background worker state
|
void invalidate_from_locked(int row) const;
|
||||||
mutable std::mutex mtx_;
|
|
||||||
|
|
||||||
// Background warmer
|
// Span cache bounds: rows within kCacheNear of a requested row are
|
||||||
struct WarmRequest {
|
// cached; past kCacheMax entries, those far from the current row go.
|
||||||
const Buffer *buf{nullptr};
|
static constexpr int kCacheNear = 1024;
|
||||||
std::uint64_t version{0};
|
static constexpr std::size_t kCacheMax = 16384;
|
||||||
int start_row{0};
|
|
||||||
int end_row{0}; // inclusive
|
|
||||||
// Visible rows to skip touching in the background (inclusive range).
|
|
||||||
// These are computed synchronously by PrefetchViewport.
|
|
||||||
int skip_first{0};
|
|
||||||
int skip_last{-1};
|
|
||||||
};
|
|
||||||
|
|
||||||
mutable std::condition_variable cv_;
|
void trim_cache_locked(int row) const;
|
||||||
mutable std::thread worker_;
|
|
||||||
mutable std::atomic<bool> worker_running_{false};
|
|
||||||
mutable bool has_request_{false};
|
|
||||||
mutable WarmRequest pending_{};
|
|
||||||
|
|
||||||
void ensure_worker_started() const;
|
|
||||||
|
|
||||||
void worker_loop() const;
|
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -55,31 +55,26 @@ 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()));
|
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
|
||||||
push(out, 0, end, TokenKind::String);
|
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.in_raw_string = false;
|
||||||
state.raw_delim.clear();
|
state.raw_delim.clear();
|
||||||
// Continue parsing remainder as a separate small loop
|
// Resume the normal tokenizer at the closing delimiter's end, on the
|
||||||
int base = end;
|
// same (unmodified) `s`/`n`, so anything after it - including a new
|
||||||
// original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
|
// triple-quoted string opening on this same line - is re-scanned
|
||||||
// For simplicity, mark rest as Default
|
// instead of being dumped into a single opaque Default span.
|
||||||
if (n > 0)
|
i = end;
|
||||||
push(out, base, base + n, TokenKind::Default);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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];
|
||||||
|
|||||||
@@ -47,11 +47,42 @@ 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;
|
return state;
|
||||||
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') {
|
||||||
@@ -79,7 +110,8 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
|
|||||||
}
|
}
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
push(out, i, n, TokenKind::Comment);
|
push(out, i, n, TokenKind::Comment);
|
||||||
break;
|
state.in_block_comment = true;
|
||||||
|
return state;
|
||||||
} else {
|
} else {
|
||||||
push(out, i, j, TokenKind::Comment);
|
push(out, i, j, TokenKind::Comment);
|
||||||
i = j;
|
i = j;
|
||||||
@@ -140,5 +172,6 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
|
|||||||
push(out, i, i + 1, TokenKind::Default);
|
push(out, i, i + 1, TokenKind::Default);
|
||||||
++i;
|
++i;
|
||||||
}
|
}
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
@@ -5,12 +5,17 @@
|
|||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
class RustHighlighter final : public LanguageHighlighter {
|
class RustHighlighter final : public StatefulHighlighter {
|
||||||
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_;
|
||||||
|
|||||||
@@ -47,12 +47,42 @@ 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;
|
return state;
|
||||||
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') {
|
||||||
@@ -68,7 +98,7 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
|||||||
push(out, i, n, TokenKind::Comment);
|
push(out, i, n, TokenKind::Comment);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// simple block comment on same line: /* ... */
|
// block comment: /* ... */ (may span multiple lines)
|
||||||
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;
|
||||||
@@ -82,7 +112,8 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
|||||||
}
|
}
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
push(out, i, n, TokenKind::Comment);
|
push(out, i, n, TokenKind::Comment);
|
||||||
break;
|
state.in_block_comment = true;
|
||||||
|
return state;
|
||||||
} else {
|
} else {
|
||||||
push(out, i, j, TokenKind::Comment);
|
push(out, i, j, TokenKind::Comment);
|
||||||
i = j;
|
i = j;
|
||||||
@@ -151,5 +182,6 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
|||||||
push(out, i, i + 1, TokenKind::Default);
|
push(out, i, i + 1, TokenKind::Default);
|
||||||
++i;
|
++i;
|
||||||
}
|
}
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
@@ -5,12 +5,17 @@
|
|||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
class SqlHighlighter final : public LanguageHighlighter {
|
class SqlHighlighter final : public StatefulHighlighter {
|
||||||
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
@@ -94,7 +94,7 @@ public:
|
|||||||
[[nodiscard]] std::string
|
[[nodiscard]] std::string
|
||||||
Text() const
|
Text() const
|
||||||
{
|
{
|
||||||
const auto &rows = Buf().Rows();
|
const auto &rows = Buf().LinesForTests();
|
||||||
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]);
|
||||||
|
|||||||
+16
-1
@@ -1,17 +1,32 @@
|
|||||||
#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()
|
main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
using namespace std::chrono;
|
using namespace std::chrono;
|
||||||
auto ® = ktet::registry();
|
auto ® = 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
@@ -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 (comparing old Rows() vs new GetLineString/GetLineView)
|
* - Iteration patterns (GetLineString vs 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,26 +240,6 @@ 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";
|
||||||
@@ -267,16 +247,8 @@ 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 sum1 = 0, sum2 = 0, sum3 = 0;
|
std::size_t 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
|
||||||
{
|
{
|
||||||
@@ -290,14 +262,15 @@ 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) {
|
||||||
sum3 += buf.GetLineView(i).size();
|
const auto v = buf.GetLineView(i);
|
||||||
|
sum3 += v.size();
|
||||||
|
newlines += !v.empty() && v.back() == '\n';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sum1 and sum2 should match (both strip newlines)
|
// GetLineView includes the newlines GetLineString strips.
|
||||||
ASSERT_EQ(sum1, sum2);
|
ASSERT_EQ(sum3, sum2 + newlines);
|
||||||
// sum3 includes newlines, so it will be larger
|
EXPECT_TRUE(newlines > 0);
|
||||||
EXPECT_TRUE(sum3 > sum2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.Rows();
|
const auto &rows = b.LinesForTests();
|
||||||
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());
|
||||||
|
|
||||||
|
|||||||
@@ -108,3 +108,78 @@ 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 "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.Rows();
|
const auto &rows = b.LinesForTests();
|
||||||
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);
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ 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{};
|
||||||
|
|||||||
@@ -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
|
||||||
* Buffer::Rows() to direct PieceTable operations using Nrows(), GetLineString(),
|
* the removed 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 Rows()
|
* - Consistency: GetLineString vs GetLineView vs LinesForTests()
|
||||||
* - 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 Rows()
|
// Consistency Tests: GetLineString vs GetLineView vs LinesForTests()
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
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.Rows();
|
const auto &rows = buf.LinesForTests();
|
||||||
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 Rows() both strip newlines
|
// GetLineString and LinesForTests() 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.Rows();
|
const auto &rows = buf.LinesForTests();
|
||||||
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 Rows() both strip newlines
|
// GetLineString and LinesForTests() 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.Rows();
|
const auto &rows2 = buf.LinesForTests();
|
||||||
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.Rows();
|
const auto &rows = buf.LinesForTests();
|
||||||
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.Rows();
|
const auto &rows = buf.LinesForTests();
|
||||||
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 Rows() both strip newlines
|
// GetLineString and LinesForTests() both strip newlines
|
||||||
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,17 +414,21 @@ TEST (Migration_EmptyBufferCheck_Pattern)
|
|||||||
|
|
||||||
TEST (Migration_SyntaxHighlighter_Pattern)
|
TEST (Migration_SyntaxHighlighter_Pattern)
|
||||||
{
|
{
|
||||||
// Test the pattern used in syntax highlighters
|
// Test the pattern used in syntax highlighters: loop Nrows(), fetch each
|
||||||
|
// 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);
|
||||||
// Successfully accessed line - size() is always valid for std::string
|
ASSERT_EQ(line, expected[row]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Tests for SplitPasteIntoCommands: pasted text must turn embedded line
|
||||||
|
// breaks into Newline commands so InsertText never receives a '\r' or '\n'.
|
||||||
|
#include "Test.h"
|
||||||
|
|
||||||
|
#include "PasteSplit.h"
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Assert that no InsertText command carries an embedded newline/carriage
|
||||||
|
// return, mirroring the rejection in Command.cc's InsertText handler.
|
||||||
|
void
|
||||||
|
assert_no_newlines_in_inserts(const std::vector<MappedInput> &cmds)
|
||||||
|
{
|
||||||
|
for (const auto &c : cmds) {
|
||||||
|
if (c.id == CommandId::InsertText) {
|
||||||
|
ASSERT_TRUE(c.arg.find('\n') == std::string::npos);
|
||||||
|
ASSERT_TRUE(c.arg.find('\r') == std::string::npos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_PlainTextNoNewline)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("hello world");
|
||||||
|
ASSERT_EQ(cmds.size(), 1u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText);
|
||||||
|
ASSERT_TRUE(cmds[0].arg == "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_UnixNewlines)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("a\nb");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 3u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Regression: CRLF clipboards left a '\r' in the InsertText segment, which
|
||||||
|
// InsertText rejected with "InsertText arg must not contain newlines".
|
||||||
|
TEST(PasteSplit_WindowsCRLF)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("a\r\nb");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 3u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Regression: a bare '\r' (classic Mac / some macOS apps) must also break lines.
|
||||||
|
TEST(PasteSplit_BareCR)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("a\rb");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 3u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_BlankLinesPreserved)
|
||||||
|
{
|
||||||
|
// Two line breaks between "a" and "b" => one empty line.
|
||||||
|
auto cmds = SplitPasteIntoCommands("a\n\nb");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 4u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[3].id == CommandId::InsertText && cmds[3].arg == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_TrailingNewline)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("abc\n");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 2u);
|
||||||
|
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "abc");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(PasteSplit_MultilineCRLF)
|
||||||
|
{
|
||||||
|
auto cmds = SplitPasteIntoCommands("line1\r\nline2\r\nline3");
|
||||||
|
assert_no_newlines_in_inserts(cmds);
|
||||||
|
ASSERT_EQ(cmds.size(), 5u);
|
||||||
|
ASSERT_TRUE(cmds[0].arg == "line1");
|
||||||
|
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
|
||||||
|
ASSERT_TRUE(cmds[2].arg == "line2");
|
||||||
|
ASSERT_TRUE(cmds[3].id == CommandId::Newline);
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -197,3 +197,60 @@ TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic)
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.Rows()) {
|
for (const auto &r: buf.LinesForTests()) {
|
||||||
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->Rows();
|
const auto &rows = buf->LinesForTests();
|
||||||
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)
|
||||||
|
|||||||
@@ -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.Rows()) {
|
for (const auto &r: buf.LinesForTests()) {
|
||||||
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->Rows();
|
const auto &rows = buf->LinesForTests();
|
||||||
ASSERT_TRUE(!rows.empty());
|
ASSERT_TRUE(!rows.empty());
|
||||||
const std::string dump = to_string_rows(*buf);
|
const std::string dump = to_string_rows(*buf);
|
||||||
|
|
||||||
|
|||||||
@@ -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.Rows()) {
|
for (const auto &r: buf.LinesForTests()) {
|
||||||
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->Rows().size() > 1);
|
ASSERT_TRUE(buf->Nrows() > 1);
|
||||||
|
|
||||||
// Undo reflow
|
// Undo reflow
|
||||||
ASSERT_TRUE(Execute(ed, "undo", "", 1));
|
ASSERT_TRUE(Execute(ed, "undo", "", 1));
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
#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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -101,6 +101,36 @@ 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;
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
|
|
||||||
#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>
|
||||||
@@ -35,7 +37,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.Rows();
|
const auto &rows = b.LinesForTests();
|
||||||
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);
|
||||||
@@ -164,7 +166,9 @@ 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);
|
||||||
|
|
||||||
// Default answer (empty) is 'no' => discard.
|
// An explicit 'n' discards. (An empty answer cancels instead; see
|
||||||
|
// 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);
|
||||||
@@ -278,3 +282,426 @@ 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());
|
||||||
|
}
|
||||||
|
|||||||
+118
-4
@@ -6,6 +6,9 @@
|
|||||||
#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>
|
||||||
|
|
||||||
@@ -29,7 +32,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.Rows();
|
const auto &rows = b.LinesForTests();
|
||||||
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);
|
||||||
@@ -103,7 +106,10 @@ TEST(SwapReplay_RecordFlushReopenReplay_ExactBytesMatch)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
TEST(SwapReplay_TruncatedLog_FailsSafely)
|
// A crash mid-append leaves a torn final record. Replay keeps every complete
|
||||||
|
// 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());
|
||||||
@@ -113,10 +119,13 @@ TEST(SwapReplay_TruncatedLog_FailsSafely)
|
|||||||
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);
|
||||||
@@ -131,8 +140,11 @@ TEST(SwapReplay_TruncatedLog_FailsSafely)
|
|||||||
Buffer b2;
|
Buffer b2;
|
||||||
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||||
std::string rerr;
|
std::string rerr;
|
||||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b2, trunc_path, rerr), false);
|
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, trunc_path, rerr));
|
||||||
ASSERT_EQ(rerr.empty(), false);
|
ASSERT_TRUE(rerr.find("incomplete record") != std::string::npos);
|
||||||
|
// 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());
|
||||||
@@ -225,3 +237,105 @@ 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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,9 +123,11 @@ 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) 1);
|
ASSERT_EQ(read_le32(bytes.data() + 8), (std::uint32_t) 2); // v2: chunked checkpoints
|
||||||
// flags currently 0
|
// flags bit 0: the base file's identity (size, mtime) is recorded, since
|
||||||
ASSERT_EQ(read_le32(bytes.data() + 12), (std::uint32_t) 0);
|
// the buffer is file-backed; bit 1: its content CRC-32 too.
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
+461
-155
File diff suppressed because it is too large
Load Diff
@@ -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.Rows()) {
|
for (const auto &r: buf.LinesForTests()) {
|
||||||
out += static_cast<std::string>(r);
|
out += static_cast<std::string>(r);
|
||||||
out.push_back('\n');
|
out.push_back('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user