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 | ||
|
|
23f04e4357 | ||
|
|
0585edad9e | ||
|
|
8712ea673d | ||
|
|
3148e16cf8 | ||
|
|
34eaa72033 | ||
|
|
f49f1698f4 | ||
|
|
f4b3188069 | ||
|
|
2571ab79c1 | ||
|
|
d768e56727 | ||
|
|
11c523ad52 | ||
|
|
c261261e26 | ||
|
|
27dcb41857 | ||
|
|
bc3433e988 | ||
|
|
690c51b0f3 | ||
|
|
0d87bc0b25 | ||
|
|
daeeecb342 | ||
|
|
a428b204a0 | ||
|
|
a21409e689 | ||
|
|
b0b5b55dce | ||
|
|
422b27b1ba | ||
|
|
9485d2aa24 | ||
|
|
8a6b7851d5 | ||
|
|
8ec0d6ac41 | ||
|
|
337b585ba0 | ||
|
|
95a588b0df | ||
|
|
199d7a20f7 |
+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).
|
||||||
@@ -7,10 +7,20 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
#include "SwapRecorder.h"
|
#include "SwapRecorder.h"
|
||||||
#include "UndoSystem.h"
|
#include "UndoSystem.h"
|
||||||
#include "UndoTree.h"
|
#include "UndoTree.h"
|
||||||
|
#include "ErrorHandler.h"
|
||||||
|
#include "SyscallWrappers.h"
|
||||||
|
#include "ErrorRecovery.h"
|
||||||
// For reconstructing highlighter state on copies
|
// For reconstructing highlighter state on copies
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "syntax/HighlighterRegistry.h"
|
||||||
#include "syntax/NullHighlighter.h"
|
#include "syntax/NullHighlighter.h"
|
||||||
@@ -24,6 +34,317 @@ Buffer::Buffer()
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Buffer::stat_identity(const std::string &path, FileIdentity &out)
|
||||||
|
{
|
||||||
|
struct stat st{};
|
||||||
|
if (::stat(path.c_str(), &st) != 0) {
|
||||||
|
out.valid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out.valid = true;
|
||||||
|
// Use nanosecond timestamp when available.
|
||||||
|
std::uint64_t ns = 0;
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
ns = static_cast<std::uint64_t>(st.st_mtimespec.tv_sec) * 1000000000ull
|
||||||
|
+ static_cast<std::uint64_t>(st.st_mtimespec.tv_nsec);
|
||||||
|
#else
|
||||||
|
ns = static_cast<std::uint64_t>(st.st_mtim.tv_sec) * 1000000000ull
|
||||||
|
+ static_cast<std::uint64_t>(st.st_mtim.tv_nsec);
|
||||||
|
#endif
|
||||||
|
out.mtime_ns = ns;
|
||||||
|
out.size = static_cast<std::uint64_t>(st.st_size);
|
||||||
|
out.dev = static_cast<std::uint64_t>(st.st_dev);
|
||||||
|
out.ino = static_cast<std::uint64_t>(st.st_ino);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Buffer::current_disk_identity(FileIdentity &out) const
|
||||||
|
{
|
||||||
|
if (!is_file_backed_ || filename_.empty()) {
|
||||||
|
out.valid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return stat_identity(filename_, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Buffer::ExternallyModifiedOnDisk() const
|
||||||
|
{
|
||||||
|
if (!is_file_backed_ || filename_.empty())
|
||||||
|
return false;
|
||||||
|
FileIdentity now{};
|
||||||
|
if (!current_disk_identity(now)) {
|
||||||
|
// If the file vanished, treat as modified when we previously had an identity.
|
||||||
|
return on_disk_identity_.valid;
|
||||||
|
}
|
||||||
|
if (!on_disk_identity_.valid)
|
||||||
|
return false;
|
||||||
|
return now.mtime_ns != on_disk_identity_.mtime_ns
|
||||||
|
|| now.size != on_disk_identity_.size
|
||||||
|
|| now.dev != on_disk_identity_.dev
|
||||||
|
|| now.ino != on_disk_identity_.ino;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Buffer::RefreshOnDiskIdentity()
|
||||||
|
{
|
||||||
|
FileIdentity id{};
|
||||||
|
if (current_disk_identity(id))
|
||||||
|
on_disk_identity_ = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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
|
||||||
|
write_all_fd(int fd, const ByteChunks &chunks, std::string &err)
|
||||||
|
{
|
||||||
|
for (const std::string_view chunk: chunks) {
|
||||||
|
std::size_t off = 0;
|
||||||
|
while (off < chunk.size()) {
|
||||||
|
ssize_t n = ::write(fd, chunk.data() + off, chunk.size() - off);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR)
|
||||||
|
continue;
|
||||||
|
err = std::string("Write failed: ") + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
off += static_cast<std::size_t>(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
best_effort_fsync_dir(const std::string &path)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(path);
|
||||||
|
std::filesystem::path dir = p.parent_path();
|
||||||
|
if (dir.empty())
|
||||||
|
return;
|
||||||
|
int dfd = kte::syscall::Open(dir.c_str(), O_RDONLY);
|
||||||
|
if (dfd < 0)
|
||||||
|
return;
|
||||||
|
(void) kte::syscall::Fsync(dfd);
|
||||||
|
(void) kte::syscall::Close(dfd);
|
||||||
|
} catch (...) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The process umask, read once during static initialisation (before any
|
||||||
|
// thread exists): reading it later needs umask(0) + umask(old), which briefly
|
||||||
|
// changes it for the swap writer thread creating files concurrently.
|
||||||
|
static const mode_t g_process_umask = [] {
|
||||||
|
const mode_t m = ::umask(0);
|
||||||
|
(void) ::umask(m);
|
||||||
|
return m;
|
||||||
|
}();
|
||||||
|
|
||||||
|
|
||||||
|
// Rewrite `path` in place (truncate, write, fsync). Used for files with
|
||||||
|
// several hard links, which a temp-file-and-rename save would split.
|
||||||
|
static bool
|
||||||
|
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.
|
||||||
|
std::filesystem::path p(path);
|
||||||
|
std::filesystem::path dir = p.parent_path();
|
||||||
|
std::string base = p.filename().string();
|
||||||
|
std::filesystem::path tmpl = dir / ("." + base + ".kte.tmp.XXXXXX");
|
||||||
|
std::string tmpl_s = tmpl.string();
|
||||||
|
|
||||||
|
// mkstemp requires a mutable buffer.
|
||||||
|
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
|
||||||
|
buf.push_back('\0');
|
||||||
|
|
||||||
|
// Retry on transient errors for temp file creation
|
||||||
|
int fd = -1;
|
||||||
|
auto mkstemp_fn = [&]() -> bool {
|
||||||
|
// Reset buffer for each retry attempt
|
||||||
|
buf.assign(tmpl_s.begin(), tmpl_s.end());
|
||||||
|
buf.push_back('\0');
|
||||||
|
fd = kte::syscall::Mkstemp(buf.data());
|
||||||
|
return fd >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) {
|
||||||
|
const int saved_errno = errno;
|
||||||
|
// No temp file in the directory because the directory is not
|
||||||
|
// writable (the file is): an existing regular file can still be
|
||||||
|
// written in place, as editors traditionally do. Not on ENOSPC and
|
||||||
|
// the like: the in-place write would truncate the file and then fail.
|
||||||
|
if (fd < 0 && dst_exists && S_ISREG(dst_st.st_mode) && may_write_in_place_after(saved_errno)) {
|
||||||
|
err.clear();
|
||||||
|
return write_in_place(path, data, err);
|
||||||
|
}
|
||||||
|
if (fd < 0) {
|
||||||
|
err = std::string("Failed to create temp file for save: ") + std::strerror(saved_errno) + err;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::string tmp_path(buf.data());
|
||||||
|
|
||||||
|
bool ok = write_all_fd(fd, data, err);
|
||||||
|
// Ownership and mode go on after the data: without privilege, both
|
||||||
|
// chown and write clear setuid/setgid, so the mode is applied last.
|
||||||
|
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);
|
||||||
|
} else if (ok) {
|
||||||
|
// mkstemp creates 0600; a new file gets the usual 0666 & ~umask.
|
||||||
|
(void) kte::syscall::Fchmod(fd, 0666 & ~g_process_umask);
|
||||||
|
}
|
||||||
|
// Never retry fsync: after a writeback error Linux may mark the pages
|
||||||
|
// clean, so a second fsync can succeed although the data was lost, and
|
||||||
|
// the rename below would then replace a good file with a bad one.
|
||||||
|
if (ok && kte::syscall::Fsync(fd) != 0) {
|
||||||
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
|
||||||
|
err = std::string("rename failed: ") + std::strerror(errno);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
(void) ::unlink(tmp_path.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
best_effort_fsync_dir(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Buffer::Buffer(const std::string &path)
|
Buffer::Buffer(const std::string &path)
|
||||||
{
|
{
|
||||||
std::string err;
|
std::string err;
|
||||||
@@ -36,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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -86,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>();
|
||||||
@@ -109,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,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);
|
||||||
@@ -168,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);
|
||||||
@@ -228,49 +544,97 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|||||||
const std::string norm = normalize_path(path);
|
const std::string norm = normalize_path(path);
|
||||||
// If the file doesn't exist, initialize an empty, non-file-backed buffer
|
// If the file doesn't exist, initialize an empty, non-file-backed buffer
|
||||||
// with the provided filename. Do not touch the filesystem until Save/SaveAs.
|
// with the provided filename. Do not touch the filesystem until Save/SaveAs.
|
||||||
if (!std::filesystem::exists(norm)) {
|
std::error_code exists_ec;
|
||||||
rows_.clear();
|
const bool exists = std::filesystem::exists(norm, exists_ec);
|
||||||
nrows_ = 0;
|
if (exists_ec) {
|
||||||
|
// e.g. name too long, or a parent directory we may not search
|
||||||
|
err = "Cannot open " + norm + ": " + exists_ec.message();
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!exists) {
|
||||||
filename_ = norm;
|
filename_ = norm;
|
||||||
is_file_backed_ = false;
|
is_file_backed_ = false;
|
||||||
|
is_virtual_ = false;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
|
||||||
// Reset cursor/viewport state
|
// Reset cursor/viewport state
|
||||||
curx_ = cury_ = rx_ = 0;
|
curx_ = cury_ = 0;
|
||||||
rowoffs_ = coloffs_ = 0;
|
rowoffs_ = coloffs_ = 0;
|
||||||
mark_set_ = false;
|
mark_set_ = false;
|
||||||
mark_curx_ = mark_cury_ = 0;
|
mark_curx_ = mark_cury_ = 0;
|
||||||
|
|
||||||
// Empty PieceTable
|
// Empty PieceTable
|
||||||
content_.Clear();
|
content_.Clear();
|
||||||
rows_cache_dirty_ = true;
|
// History recorded against the previous content no longer applies.
|
||||||
|
if (undo_sys_)
|
||||||
|
undo_sys_->clear();
|
||||||
|
MarkContentChanged();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::ifstream in(norm, std::ios::in | std::ios::binary);
|
// Open non-blocking so a named pipe cannot hang the editor, then accept
|
||||||
if (!in) {
|
// regular files only: a directory reads as garbage (or throws), a FIFO
|
||||||
err = "Failed to open file: " + norm;
|
// blocks, and saving over a device node would replace it with a file.
|
||||||
|
int oflags = O_RDONLY | O_NONBLOCK;
|
||||||
|
#ifdef O_CLOEXEC
|
||||||
|
oflags |= O_CLOEXEC;
|
||||||
|
#endif
|
||||||
|
const int fd = kte::syscall::Open(norm.c_str(), oflags);
|
||||||
|
if (fd < 0) {
|
||||||
|
err = "Failed to open file: " + norm + ": " + std::strerror(errno);
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
struct stat st{};
|
||||||
|
if (kte::syscall::Fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
|
||||||
|
err = S_ISDIR(st.st_mode) ? ("Is a directory: " + norm) : ("Not a regular file: " + norm);
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read entire file into PieceTable as-is
|
// Read to EOF in chunks (the file may change size while we read).
|
||||||
std::string data;
|
std::string data;
|
||||||
in.seekg(0, std::ios::end);
|
try {
|
||||||
auto sz = in.tellg();
|
data.reserve(static_cast<std::size_t>(st.st_size));
|
||||||
if (sz > 0) {
|
char chunk[1 << 16];
|
||||||
data.resize(static_cast<std::size_t>(sz));
|
for (;;) {
|
||||||
in.seekg(0, std::ios::beg);
|
const ssize_t n = ::read(fd, chunk, sizeof(chunk));
|
||||||
in.read(data.data(), static_cast<std::streamsize>(data.size()));
|
if (n > 0) {
|
||||||
|
data.append(chunk, static_cast<std::size_t>(n));
|
||||||
|
} else if (n == 0) {
|
||||||
|
break;
|
||||||
|
} else if (errno != EINTR) {
|
||||||
|
err = "Failed to read file: " + norm + ": " + std::strerror(errno);
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (const std::bad_alloc &) {
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
err = "File too large to load: " + norm;
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
// The bytes become the piece table's original storage, uncopied. If that
|
||||||
|
// runs out of memory the buffer keeps its old text (clearing first left
|
||||||
|
// an empty buffer that a later save wrote over the file).
|
||||||
|
try {
|
||||||
|
content_.AdoptOriginal(std::move(data));
|
||||||
|
} catch (const std::bad_alloc &) {
|
||||||
|
err = "File too large to load: " + norm;
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
content_.Clear();
|
|
||||||
if (!data.empty())
|
|
||||||
content_.Append(data.data(), data.size());
|
|
||||||
rows_cache_dirty_ = true;
|
|
||||||
nrows_ = 0; // not used under PieceTable
|
|
||||||
filename_ = norm;
|
filename_ = norm;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
|
is_virtual_ = false;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
RefreshOnDiskIdentity();
|
||||||
|
|
||||||
// Reset/initialize undo system for this loaded file
|
// Reset/initialize undo system for this loaded file
|
||||||
if (!undo_tree_)
|
if (!undo_tree_)
|
||||||
@@ -279,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;
|
||||||
@@ -297,22 +662,16 @@ 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;
|
||||||
}
|
}
|
||||||
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
|
ByteChunks chunks;
|
||||||
if (!out) {
|
content_.ForEachChunk([&](const char *d, std::size_t n) {
|
||||||
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
chunks.emplace_back(d, n);
|
||||||
return false;
|
});
|
||||||
}
|
if (!atomic_write_file(filename_, chunks, err)) {
|
||||||
// Stream the content directly from the piece table to avoid relying on
|
kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
|
||||||
// full materialization, which may yield an empty pointer when size > 0.
|
|
||||||
if (content_.Size() > 0) {
|
|
||||||
content_.WriteToStream(out);
|
|
||||||
}
|
|
||||||
// Ensure data hits the OS buffers
|
|
||||||
out.flush();
|
|
||||||
if (!out.good()) {
|
|
||||||
err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Update observed on-disk identity after a successful save.
|
||||||
|
const_cast<Buffer *>(this)->RefreshOnDiskIdentity();
|
||||||
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
||||||
// to decide when to flip dirty flag after successful save.
|
// to decide when to flip dirty flag after successful save.
|
||||||
return true;
|
return true;
|
||||||
@@ -341,26 +700,20 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
|||||||
out_path = path;
|
out_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to the given path
|
ByteChunks chunks;
|
||||||
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc);
|
content_.ForEachChunk([&](const char *d, std::size_t n) {
|
||||||
if (!out) {
|
chunks.emplace_back(d, n);
|
||||||
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
});
|
||||||
return false;
|
if (!atomic_write_file(out_path, chunks, err)) {
|
||||||
}
|
kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
|
||||||
// Stream content without forcing full materialization
|
|
||||||
if (content_.Size() > 0) {
|
|
||||||
content_.WriteToStream(out);
|
|
||||||
}
|
|
||||||
// Ensure data hits the OS buffers
|
|
||||||
out.flush();
|
|
||||||
if (!out.good()) {
|
|
||||||
err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
|
||||||
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();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -437,6 +801,25 @@ Buffer::content_LineCount_() const
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(KTE_TESTS)
|
||||||
|
std::string
|
||||||
|
Buffer::BytesForTests() const
|
||||||
|
{
|
||||||
|
return Bytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<std::string>
|
||||||
|
Buffer::LinesForTests() const
|
||||||
|
{
|
||||||
|
std::vector<std::string> out;
|
||||||
|
for (std::size_t i = 0; i < Nrows(); ++i)
|
||||||
|
out.push_back(GetLineString(i));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
Buffer::delete_text(int row, int col, std::size_t len)
|
Buffer::delete_text(int row, int col, std::size_t len)
|
||||||
{
|
{
|
||||||
@@ -447,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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -507,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));
|
||||||
}
|
}
|
||||||
@@ -526,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);
|
||||||
}
|
}
|
||||||
@@ -542,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,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);
|
||||||
}
|
}
|
||||||
@@ -581,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
/*
|
/*
|
||||||
* Buffer.h - editor buffer representing an open document
|
* Buffer.h - editor buffer representing an open document
|
||||||
|
*
|
||||||
|
* Buffer is the central document model in kte. Each Buffer represents one open file
|
||||||
|
* or scratch document and manages:
|
||||||
|
*
|
||||||
|
* - Content storage: Uses PieceTable for efficient text operations
|
||||||
|
* - Cursor state: Current position (curx_, cury_)
|
||||||
|
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
|
||||||
|
* - File backing: Optional association with a file on disk
|
||||||
|
* - Undo/Redo: Integrated UndoSystem for operation history
|
||||||
|
* - Syntax highlighting: Optional HighlighterEngine for language-aware coloring
|
||||||
|
* - Swap/crash recovery: Integration with SwapRecorder for journaling
|
||||||
|
* - Dirty tracking: Modification state for save prompts
|
||||||
|
*
|
||||||
|
* Key concepts:
|
||||||
|
*
|
||||||
|
* 1. Cursor coordinates:
|
||||||
|
* - (curx_, cury_): Logical character position in the document
|
||||||
|
*
|
||||||
|
* 2. File backing:
|
||||||
|
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
|
||||||
|
* - File identity tracking detects external modifications
|
||||||
|
*
|
||||||
|
* 3. Content access:
|
||||||
|
* - Nrows(), GetLineString(): per-line access
|
||||||
|
* - GetLineView(): line access via string_view (zero-copy within a piece)
|
||||||
|
* - Direct PieceTable access for new editing operations
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <filesystem>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
@@ -14,7 +44,26 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include "syntax/HighlighterEngine.h"
|
#include "syntax/HighlighterEngine.h"
|
||||||
#include "Highlight.h"
|
#include "Highlight.h"
|
||||||
#include <mutex>
|
|
||||||
|
// Edit mode determines which font class is used for a buffer.
|
||||||
|
enum class EditMode { Code, Writing };
|
||||||
|
|
||||||
|
// Detect edit mode from a filename's extension.
|
||||||
|
inline EditMode
|
||||||
|
DetectEditMode(const std::string &filename)
|
||||||
|
{
|
||||||
|
std::string ext = std::filesystem::path(filename).extension().string();
|
||||||
|
std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) {
|
||||||
|
return static_cast<char>(std::tolower(c));
|
||||||
|
});
|
||||||
|
static const std::unordered_set<std::string> writing_exts = {
|
||||||
|
".txt", ".md", ".markdown", ".rst", ".org",
|
||||||
|
".tex", ".adoc", ".asciidoc",
|
||||||
|
};
|
||||||
|
if (writing_exts.count(ext))
|
||||||
|
return EditMode::Writing;
|
||||||
|
return EditMode::Code;
|
||||||
|
}
|
||||||
|
|
||||||
// Forward declaration for swap journal integration
|
// Forward declaration for swap journal integration
|
||||||
namespace kte {
|
namespace kte {
|
||||||
@@ -42,6 +91,14 @@ public:
|
|||||||
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
||||||
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
|
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
|
||||||
|
|
||||||
|
// External modification detection.
|
||||||
|
// Returns true if the file on disk differs from the last observed identity recorded
|
||||||
|
// on open/save.
|
||||||
|
[[nodiscard]] bool ExternallyModifiedOnDisk() const;
|
||||||
|
|
||||||
|
// Refresh the stored on-disk identity to match current stat (used after open/save).
|
||||||
|
void RefreshOnDiskIdentity();
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
[[nodiscard]] std::size_t Curx() const
|
[[nodiscard]] std::size_t Curx() const
|
||||||
{
|
{
|
||||||
@@ -55,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_();
|
||||||
@@ -79,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);
|
||||||
@@ -254,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
|
||||||
{
|
{
|
||||||
@@ -271,6 +175,7 @@ public:
|
|||||||
{
|
{
|
||||||
filename_ = name;
|
filename_ = name;
|
||||||
is_file_backed_ = false;
|
is_file_backed_ = false;
|
||||||
|
is_virtual_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -280,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_;
|
||||||
@@ -312,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;
|
||||||
@@ -327,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -406,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -437,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
|
||||||
{
|
{
|
||||||
@@ -444,6 +380,35 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Edit mode (code vs writing)
|
||||||
|
[[nodiscard]] EditMode GetEditMode() const
|
||||||
|
{
|
||||||
|
return edit_mode_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void SetEditMode(EditMode m)
|
||||||
|
{
|
||||||
|
edit_mode_ = m;
|
||||||
|
edit_mode_detected_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void ToggleEditMode()
|
||||||
|
{
|
||||||
|
edit_mode_ = (edit_mode_ == EditMode::Code)
|
||||||
|
? EditMode::Writing
|
||||||
|
: EditMode::Code;
|
||||||
|
edit_mode_detected_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool EditModeDetected() const
|
||||||
|
{
|
||||||
|
return edit_mode_detected_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetSyntaxEnabled(bool on)
|
void SetSyntaxEnabled(bool on)
|
||||||
{
|
{
|
||||||
syntax_enabled_ = on;
|
syntax_enabled_ = on;
|
||||||
@@ -456,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;
|
||||||
@@ -487,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)
|
||||||
{
|
{
|
||||||
@@ -514,35 +507,103 @@ 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();
|
||||||
|
|
||||||
[[nodiscard]] const UndoSystem *Undo() const;
|
[[nodiscard]] const UndoSystem *Undo() const;
|
||||||
|
|
||||||
|
#if defined(KTE_TESTS)
|
||||||
|
// 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;
|
||||||
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct FileIdentity {
|
||||||
|
bool valid = false;
|
||||||
|
std::uint64_t mtime_ns = 0;
|
||||||
|
std::uint64_t size = 0;
|
||||||
|
std::uint64_t dev = 0;
|
||||||
|
std::uint64_t ino = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] static bool stat_identity(const std::string &path, FileIdentity &out);
|
||||||
|
|
||||||
|
[[nodiscard]] bool current_disk_identity(FileIdentity &out) const;
|
||||||
|
|
||||||
|
mutable FileIdentity on_disk_identity_{};
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -555,13 +616,25 @@ private:
|
|||||||
std::unique_ptr<struct UndoTree> undo_tree_;
|
std::unique_ptr<struct UndoTree> undo_tree_;
|
||||||
std::unique_ptr<UndoSystem> undo_sys_;
|
std::unique_ptr<UndoSystem> undo_sys_;
|
||||||
|
|
||||||
|
// Edit mode (code vs writing)
|
||||||
|
EditMode edit_mode_ = EditMode::Code;
|
||||||
|
bool edit_mode_detected_ = false; // true after initial auto-detection
|
||||||
|
|
||||||
// Syntax/highlighting state
|
// Syntax/highlighting state
|
||||||
std::uint64_t version_ = 0; // increment on edits
|
std::uint64_t version_ = 0; // increment on edits
|
||||||
|
|
||||||
|
// Record a content change starting at `row`: bump the version and let the
|
||||||
|
// highlighter drop cached rows from `row` down (rows above are unaffected).
|
||||||
|
void edited_at_(int row)
|
||||||
|
{
|
||||||
|
++version_;
|
||||||
|
if (highlighter_)
|
||||||
|
highlighter_->OnEdit(row < 0 ? 0 : row, version_);
|
||||||
|
}
|
||||||
bool syntax_enabled_ = true;
|
bool syntax_enabled_ = true;
|
||||||
|
bool syntax_user_override_ = false; // true once user explicitly set syntax state via a command
|
||||||
std::string filetype_;
|
std::string filetype_;
|
||||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||||
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
||||||
kte::SwapRecorder *swap_rec_ = nullptr;
|
kte::SwapRecorder *swap_rec_ = nullptr;
|
||||||
|
|
||||||
mutable std::mutex buffer_mutex_;
|
|
||||||
};
|
};
|
||||||
+131
-37
@@ -4,16 +4,17 @@ project(kte)
|
|||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(KTE_VERSION "1.6.3")
|
set(KTE_VERSION "1.13.0")
|
||||||
|
|
||||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||||
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
||||||
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
|
|
||||||
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
||||||
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
||||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
||||||
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
||||||
|
option(KTE_STATIC_LINK "Enable static linking on Linux" OFF)
|
||||||
|
option(KTE_USE_PCRE2 "Use PCRE2 (libpcre2-8) for regex search when found; std::regex otherwise" ON)
|
||||||
|
|
||||||
# Optionally enable AddressSanitizer (ASan)
|
# Optionally enable AddressSanitizer (ASan)
|
||||||
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
|
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
|
||||||
@@ -21,6 +22,8 @@ option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
|
|||||||
if (ENABLE_ASAN)
|
if (ENABLE_ASAN)
|
||||||
message(STATUS "ASan enabled")
|
message(STATUS "ASan enabled")
|
||||||
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
|
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
|
||||||
|
# The project is C++; without this only C sources were instrumented.
|
||||||
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
|
||||||
# Ensure the sanitizer is linked too (especially important on some platforms)
|
# Ensure the sanitizer is linked too (especially important on some platforms)
|
||||||
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
|
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
|
||||||
endif ()
|
endif ()
|
||||||
@@ -39,7 +42,6 @@ if (MSVC)
|
|||||||
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
|
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
|
||||||
else ()
|
else ()
|
||||||
add_compile_options(
|
add_compile_options(
|
||||||
"-static"
|
|
||||||
"-Wall"
|
"-Wall"
|
||||||
"-Wextra"
|
"-Wextra"
|
||||||
"-Werror"
|
"-Werror"
|
||||||
@@ -51,6 +53,7 @@ else ()
|
|||||||
)
|
)
|
||||||
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
||||||
add_compile_options("-stdlib=libc++")
|
add_compile_options("-stdlib=libc++")
|
||||||
|
add_link_options("-stdlib=libc++")
|
||||||
else ()
|
else ()
|
||||||
# nothing special for gcc at the moment
|
# nothing special for gcc at the moment
|
||||||
endif ()
|
endif ()
|
||||||
@@ -63,15 +66,69 @@ endif ()
|
|||||||
|
|
||||||
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
||||||
|
|
||||||
|
# Regex engine (see RegexEngine.h). Linked by name (-lpcre2-8) so that a
|
||||||
|
# static build picks up the static library.
|
||||||
|
if (KTE_USE_PCRE2)
|
||||||
|
find_package(PkgConfig QUIET)
|
||||||
|
if (PKG_CONFIG_FOUND)
|
||||||
|
pkg_check_modules(PCRE2 QUIET libpcre2-8)
|
||||||
|
endif ()
|
||||||
|
if (PCRE2_FOUND)
|
||||||
|
message(STATUS "Regex engine: PCRE2 ${PCRE2_VERSION}")
|
||||||
|
add_compile_definitions(KTE_USE_PCRE2)
|
||||||
|
include_directories(${PCRE2_INCLUDE_DIRS})
|
||||||
|
link_directories(${PCRE2_LIBRARY_DIRS})
|
||||||
|
link_libraries(${PCRE2_LIBRARIES})
|
||||||
|
else ()
|
||||||
|
message(STATUS "Regex engine: std::regex (libpcre2-8 not found)")
|
||||||
|
endif ()
|
||||||
|
else ()
|
||||||
|
message(STATUS "Regex engine: std::regex")
|
||||||
|
endif ()
|
||||||
|
|
||||||
if (BUILD_GUI)
|
if (BUILD_GUI)
|
||||||
include(cmake/imgui.cmake)
|
include(cmake/imgui.cmake)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
# NCurses for terminal mode
|
# NCurses for terminal mode
|
||||||
set(CURSES_NEED_NCURSES)
|
set(CURSES_NEED_NCURSES TRUE)
|
||||||
set(CURSES_NEED_WIDE)
|
set(CURSES_NEED_WIDE TRUE)
|
||||||
|
|
||||||
|
# macOS ships only a narrow-char ncurses in the SDK, and Homebrew's wide-char
|
||||||
|
# build is keg-only, so FindCurses can't see it without a hint.
|
||||||
|
if (APPLE)
|
||||||
|
find_program(BREW_EXECUTABLE brew)
|
||||||
|
if (BREW_EXECUTABLE)
|
||||||
|
execute_process(COMMAND "${BREW_EXECUTABLE}" --prefix ncurses
|
||||||
|
OUTPUT_VARIABLE HOMEBREW_NCURSES_PREFIX
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||||
|
ERROR_QUIET)
|
||||||
|
if (HOMEBREW_NCURSES_PREFIX AND IS_DIRECTORY "${HOMEBREW_NCURSES_PREFIX}")
|
||||||
|
message(STATUS "Using Homebrew ncursesw: ${HOMEBREW_NCURSES_PREFIX}")
|
||||||
|
list(PREPEND CMAKE_PREFIX_PATH "${HOMEBREW_NCURSES_PREFIX}")
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
find_package(Curses REQUIRED)
|
find_package(Curses REQUIRED)
|
||||||
include_directories(${CURSES_INCLUDE_DIR})
|
|
||||||
|
# FindCurses exports CURSES_INCLUDE_DIRS; the singular CURSES_INCLUDE_DIR was
|
||||||
|
# never set, so this silently expanded to nothing and the compiler fell back
|
||||||
|
# to whatever curses header happened to be on the default include path.
|
||||||
|
include_directories(${CURSES_INCLUDE_DIRS})
|
||||||
|
|
||||||
|
# The wide-char entry points we rely on (get_wch, etc.) are declared only when
|
||||||
|
# NCURSES_WIDECHAR is set; the header defaults it to 0 unless a feature-test
|
||||||
|
# macro says otherwise. We always link the wide library, so ask for them.
|
||||||
|
add_compile_definitions(NCURSES_WIDECHAR=1)
|
||||||
|
|
||||||
|
# On Alpine Linux, CMake's FindCurses looks in wrong paths
|
||||||
|
# Manually find the correct ncurses library
|
||||||
|
if (EXISTS "/etc/alpine-release")
|
||||||
|
find_library(NCURSESW_LIB NAMES ncursesw PATHS /usr/lib /lib REQUIRED)
|
||||||
|
set(CURSES_LIBRARIES ${NCURSESW_LIB})
|
||||||
|
message(STATUS "Alpine Linux detected, using ncurses at: ${NCURSESW_LIB}")
|
||||||
|
endif ()
|
||||||
|
|
||||||
set(SYNTAX_SOURCES
|
set(SYNTAX_SOURCES
|
||||||
syntax/GoHighlighter.cc
|
syntax/GoHighlighter.cc
|
||||||
@@ -105,17 +162,6 @@ if (BUILD_GUI)
|
|||||||
set(GUI_SOURCES
|
set(GUI_SOURCES
|
||||||
GUIConfig.cc
|
GUIConfig.cc
|
||||||
)
|
)
|
||||||
if (KTE_USE_QT)
|
|
||||||
find_package(Qt6 COMPONENTS Widgets REQUIRED)
|
|
||||||
set(GUI_SOURCES
|
|
||||||
${GUI_SOURCES}
|
|
||||||
QtFrontend.cc
|
|
||||||
QtInputHandler.cc
|
|
||||||
QtRenderer.cc
|
|
||||||
)
|
|
||||||
# Expose preprocessor switch so sources can exclude ImGui-specific code
|
|
||||||
add_compile_definitions(KTE_USE_QT)
|
|
||||||
else ()
|
|
||||||
set(GUI_SOURCES
|
set(GUI_SOURCES
|
||||||
${GUI_SOURCES}
|
${GUI_SOURCES}
|
||||||
${FONT_SOURCES}
|
${FONT_SOURCES}
|
||||||
@@ -124,7 +170,6 @@ if (BUILD_GUI)
|
|||||||
ImGuiRenderer.cc
|
ImGuiRenderer.cc
|
||||||
)
|
)
|
||||||
endif ()
|
endif ()
|
||||||
endif ()
|
|
||||||
|
|
||||||
set(COMMON_SOURCES
|
set(COMMON_SOURCES
|
||||||
PieceTable.cc
|
PieceTable.cc
|
||||||
@@ -134,6 +179,12 @@ set(COMMON_SOURCES
|
|||||||
HelpText.cc
|
HelpText.cc
|
||||||
KKeymap.cc
|
KKeymap.cc
|
||||||
Swap.cc
|
Swap.cc
|
||||||
|
ErrorHandler.cc
|
||||||
|
SyscallWrappers.cc
|
||||||
|
ErrorRecovery.cc
|
||||||
|
RegexGuard.cc
|
||||||
|
RegexEngine.cc
|
||||||
|
SearchHighlight.cc
|
||||||
TerminalInputHandler.cc
|
TerminalInputHandler.cc
|
||||||
TerminalRenderer.cc
|
TerminalRenderer.cc
|
||||||
TerminalFrontend.cc
|
TerminalFrontend.cc
|
||||||
@@ -194,6 +245,8 @@ set(FONT_HEADERS
|
|||||||
fonts/FontList.h
|
fonts/FontList.h
|
||||||
fonts/B612Mono.h
|
fonts/B612Mono.h
|
||||||
fonts/BrassMono.h
|
fonts/BrassMono.h
|
||||||
|
fonts/CrimsonPro.h
|
||||||
|
fonts/ETBook.h
|
||||||
fonts/BrassMonoCode.h
|
fonts/BrassMonoCode.h
|
||||||
fonts/FiraCode.h
|
fonts/FiraCode.h
|
||||||
fonts/Go.h
|
fonts/Go.h
|
||||||
@@ -205,6 +258,7 @@ set(FONT_HEADERS
|
|||||||
fonts/IosevkaExtended.h
|
fonts/IosevkaExtended.h
|
||||||
fonts/ShareTech.h
|
fonts/ShareTech.h
|
||||||
fonts/SpaceMono.h
|
fonts/SpaceMono.h
|
||||||
|
fonts/Spectral.h
|
||||||
fonts/Syne.h
|
fonts/Syne.h
|
||||||
fonts/Triplicate.h
|
fonts/Triplicate.h
|
||||||
fonts/Unispace.h
|
fonts/Unispace.h
|
||||||
@@ -228,6 +282,9 @@ set(COMMON_HEADERS
|
|||||||
TestInputHandler.h
|
TestInputHandler.h
|
||||||
TestRenderer.h
|
TestRenderer.h
|
||||||
TestFrontend.h
|
TestFrontend.h
|
||||||
|
TextSpan.h
|
||||||
|
RegexEngine.h
|
||||||
|
SearchHighlight.h
|
||||||
UndoNode.h
|
UndoNode.h
|
||||||
UndoTree.h
|
UndoTree.h
|
||||||
UndoSystem.h
|
UndoSystem.h
|
||||||
@@ -241,14 +298,6 @@ if (BUILD_GUI)
|
|||||||
GUIConfig.h
|
GUIConfig.h
|
||||||
)
|
)
|
||||||
|
|
||||||
if (KTE_USE_QT)
|
|
||||||
set(GUI_HEADERS
|
|
||||||
${GUI_HEADERS}
|
|
||||||
QtFrontend.h
|
|
||||||
QtInputHandler.h
|
|
||||||
QtRenderer.h
|
|
||||||
)
|
|
||||||
else ()
|
|
||||||
set(GUI_HEADERS
|
set(GUI_HEADERS
|
||||||
${GUI_HEADERS}
|
${GUI_HEADERS}
|
||||||
${THEME_HEADERS}
|
${THEME_HEADERS}
|
||||||
@@ -259,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
|
||||||
@@ -274,6 +322,11 @@ endif ()
|
|||||||
|
|
||||||
target_link_libraries(kte ${CURSES_LIBRARIES})
|
target_link_libraries(kte ${CURSES_LIBRARIES})
|
||||||
|
|
||||||
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
|
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||||
|
target_link_options(kte PRIVATE -static)
|
||||||
|
endif ()
|
||||||
|
|
||||||
if (KTE_ENABLE_TREESITTER)
|
if (KTE_ENABLE_TREESITTER)
|
||||||
# Users can provide their own tree-sitter include/lib via cache variables
|
# Users can provide their own tree-sitter include/lib via cache variables
|
||||||
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
|
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
|
||||||
@@ -308,14 +361,24 @@ if (BUILD_TESTS)
|
|||||||
tests/test_swap_recorder.cc
|
tests/test_swap_recorder.cc
|
||||||
tests/test_swap_writer.cc
|
tests/test_swap_writer.cc
|
||||||
tests/test_swap_replay.cc
|
tests/test_swap_replay.cc
|
||||||
|
tests/test_swap_edge_cases.cc
|
||||||
tests/test_swap_recovery_prompt.cc
|
tests/test_swap_recovery_prompt.cc
|
||||||
tests/test_swap_cleanup.cc
|
tests/test_swap_cleanup.cc
|
||||||
|
tests/test_swap_cleanup2.cc
|
||||||
|
tests/test_swap_git_editor.cc
|
||||||
tests/test_piece_table.cc
|
tests/test_piece_table.cc
|
||||||
tests/test_search.cc
|
|
||||||
tests/test_search_replace_flow.cc
|
tests/test_search_replace_flow.cc
|
||||||
tests/test_reflow_paragraph.cc
|
tests/test_reflow_paragraph.cc
|
||||||
|
tests/test_reflow_indented_bullets.cc
|
||||||
tests/test_undo.cc
|
tests/test_undo.cc
|
||||||
tests/test_visual_line_mode.cc
|
tests/test_visual_line_mode.cc
|
||||||
|
tests/test_benchmarks.cc
|
||||||
|
tests/test_migration_coverage.cc
|
||||||
|
tests/test_smart_newline.cc
|
||||||
|
tests/test_reflow_undo.cc
|
||||||
|
tests/test_syntax_highlighting.cc
|
||||||
|
tests/test_paste_split.cc
|
||||||
|
tests/test_audit_regressions.cc
|
||||||
|
|
||||||
# minimal engine sources required by Buffer
|
# minimal engine sources required by Buffer
|
||||||
PieceTable.cc
|
PieceTable.cc
|
||||||
@@ -324,9 +387,14 @@ if (BUILD_TESTS)
|
|||||||
Command.cc
|
Command.cc
|
||||||
HelpText.cc
|
HelpText.cc
|
||||||
Swap.cc
|
Swap.cc
|
||||||
|
ErrorHandler.cc
|
||||||
|
SyscallWrappers.cc
|
||||||
|
ErrorRecovery.cc
|
||||||
|
RegexGuard.cc
|
||||||
|
RegexEngine.cc
|
||||||
|
SearchHighlight.cc
|
||||||
KKeymap.cc
|
KKeymap.cc
|
||||||
SwapRecorder.h
|
SwapRecorder.h
|
||||||
OptimizedSearch.cc
|
|
||||||
UndoNode.cc
|
UndoNode.cc
|
||||||
UndoTree.cc
|
UndoTree.cc
|
||||||
UndoSystem.cc
|
UndoSystem.cc
|
||||||
@@ -348,6 +416,14 @@ if (BUILD_TESTS)
|
|||||||
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
|
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
|
||||||
endif ()
|
endif ()
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
|
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||||
|
target_link_options(kte_tests PRIVATE -static)
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
enable_testing()
|
||||||
|
add_test(NAME kte_tests COMMAND kte_tests)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
if (BUILD_GUI)
|
if (BUILD_GUI)
|
||||||
@@ -375,16 +451,14 @@ if (BUILD_GUI)
|
|||||||
|
|
||||||
)
|
)
|
||||||
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
|
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
|
||||||
if (KTE_USE_QT)
|
|
||||||
target_compile_definitions(kge PRIVATE KTE_USE_QT=1)
|
|
||||||
endif ()
|
|
||||||
if (KTE_UNDO_DEBUG)
|
if (KTE_UNDO_DEBUG)
|
||||||
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
|
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
|
||||||
endif ()
|
endif ()
|
||||||
if (KTE_USE_QT)
|
|
||||||
target_link_libraries(kge ${CURSES_LIBRARIES} Qt6::Widgets)
|
|
||||||
else ()
|
|
||||||
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
||||||
|
|
||||||
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
|
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||||
|
target_link_options(kge PRIVATE -static)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
# On macOS, build kge as a proper .app bundle
|
# On macOS, build kge as a proper .app bundle
|
||||||
@@ -440,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)
|
||||||
@@ -451,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 ()
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# kge Configuration
|
||||||
|
|
||||||
|
kge loads configuration from `~/.config/kte/kge.toml`. If no TOML file is
|
||||||
|
found, it falls back to the legacy `kge.ini` format.
|
||||||
|
|
||||||
|
## TOML Format
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[window]
|
||||||
|
fullscreen = false
|
||||||
|
columns = 80
|
||||||
|
rows = 42
|
||||||
|
|
||||||
|
[font]
|
||||||
|
# Default font and size
|
||||||
|
name = "default"
|
||||||
|
size = 18.0
|
||||||
|
# Font used in code mode (monospace)
|
||||||
|
code = "default"
|
||||||
|
# Font used in writing mode (proportional)
|
||||||
|
writing = "crimsonpro"
|
||||||
|
|
||||||
|
[appearance]
|
||||||
|
theme = "nord"
|
||||||
|
# "dark" or "light" for themes with variants
|
||||||
|
background = "dark"
|
||||||
|
|
||||||
|
[editor]
|
||||||
|
syntax = true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
### `[window]`
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|--------------|------|---------|---------------------------------|
|
||||||
|
| `fullscreen` | bool | false | Start in fullscreen mode |
|
||||||
|
| `columns` | int | 80 | Initial window width in columns |
|
||||||
|
| `rows` | int | 42 | Initial window height in rows |
|
||||||
|
|
||||||
|
### `[font]`
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----------|--------|--------------|------------------------------------------|
|
||||||
|
| `name` | string | "default" | Default font loaded at startup |
|
||||||
|
| `size` | float | 18.0 | Font size in pixels |
|
||||||
|
| `code` | string | "default" | Font for code mode (monospace) |
|
||||||
|
| `writing` | string | "crimsonpro" | Font for writing mode (proportional) |
|
||||||
|
|
||||||
|
### `[appearance]`
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|--------------|--------|---------|-----------------------------------------|
|
||||||
|
| `theme` | string | "nord" | Color theme |
|
||||||
|
| `background` | string | "dark" | Background mode: "dark" or "light" |
|
||||||
|
|
||||||
|
### `[editor]`
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|----------|------|---------|------------------------------|
|
||||||
|
| `syntax` | bool | true | Enable syntax highlighting |
|
||||||
|
|
||||||
|
## Edit Modes
|
||||||
|
|
||||||
|
kge has two edit modes that control which font is used:
|
||||||
|
|
||||||
|
- **code** — Uses the monospace font (`font.code`). Default for source files.
|
||||||
|
- **writing** — Uses the proportional font (`font.writing`). Auto-detected
|
||||||
|
for `.txt`, `.md`, `.markdown`, `.rst`, `.org`, `.tex`, `.adoc`, and
|
||||||
|
`.asciidoc` files.
|
||||||
|
|
||||||
|
Toggle with `C-k m` or `: mode [code|writing]`.
|
||||||
|
|
||||||
|
## Available Fonts
|
||||||
|
|
||||||
|
### Monospace
|
||||||
|
|
||||||
|
b612, berkeley, berkeley-bold, brassmono, brassmono-bold, brassmonocode,
|
||||||
|
brassmonocode-bold, fira, go, ibm, idealist, inconsolata, inconsolataex,
|
||||||
|
iosevka, iosevkaex, sharetech, space, syne, triplicate, unispace
|
||||||
|
|
||||||
|
### Proportional (Serif)
|
||||||
|
|
||||||
|
crimsonpro, etbook, spectral
|
||||||
|
|
||||||
|
## Available Themes
|
||||||
|
|
||||||
|
amber, eink, everforest, gruvbox, kanagawa-paper, lcars, leuchtturm, nord,
|
||||||
|
old-book, orbital, plan9, solarized, tufte, weyland-yutani, zenburn
|
||||||
|
|
||||||
|
Themes with light/dark variants: eink, gruvbox, leuchtturm, old-book,
|
||||||
|
solarized. Set `background = "light"` or use `: background light`.
|
||||||
|
|
||||||
|
## Migrating from kge.ini
|
||||||
|
|
||||||
|
If you have an existing `kge.ini`, kge will still read it but prints a
|
||||||
|
notice to stderr suggesting migration. To migrate, create `kge.toml` in the
|
||||||
|
same directory (`~/.config/kte/`) using the format above. The TOML file
|
||||||
|
takes priority when both exist.
|
||||||
|
|
||||||
|
The INI keys map to TOML as follows:
|
||||||
|
|
||||||
|
| INI key | TOML equivalent |
|
||||||
|
|---------------|--------------------------|
|
||||||
|
| `fullscreen` | `window.fullscreen` |
|
||||||
|
| `columns` | `window.columns` |
|
||||||
|
| `rows` | `window.rows` |
|
||||||
|
| `font` | `font.name` |
|
||||||
|
| `font_size` | `font.size` |
|
||||||
|
| `theme` | `appearance.theme` |
|
||||||
|
| `background` | `appearance.background` |
|
||||||
|
| `syntax` | `editor.syntax` |
|
||||||
|
|
||||||
|
New keys `font.code` and `font.writing` have no INI equivalent (the INI
|
||||||
|
parser accepts `code_font` and `writing_font` if needed).
|
||||||
+1764
-1014
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)
|
||||||
@@ -38,6 +37,7 @@ enum class CommandId {
|
|||||||
// Editing
|
// Editing
|
||||||
InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
|
InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
|
||||||
Newline, // insert a newline at cursor
|
Newline, // insert a newline at cursor
|
||||||
|
SmartNewline, // insert a newline with auto-indent (Shift-Enter)
|
||||||
Backspace, // delete char before cursor (may join lines)
|
Backspace, // delete char before cursor (may join lines)
|
||||||
DeleteChar, // delete char at cursor (may join lines)
|
DeleteChar, // delete char at cursor (may join lines)
|
||||||
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
|
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
|
||||||
@@ -110,6 +110,14 @@ enum class CommandId {
|
|||||||
SetOption, // generic ":set key=value" (v1: filetype=<lang>)
|
SetOption, // generic ":set key=value" (v1: filetype=<lang>)
|
||||||
// Viewport control
|
// Viewport control
|
||||||
CenterOnCursor, // center the viewport on the current cursor line (C-k k)
|
CenterOnCursor, // center the viewport on the current cursor line (C-k k)
|
||||||
|
// GUI: open a new editor window sharing the same buffer list
|
||||||
|
NewWindow,
|
||||||
|
// GUI: font size controls
|
||||||
|
FontZoomIn,
|
||||||
|
FontZoomOut,
|
||||||
|
FontZoomReset,
|
||||||
|
// Edit mode (code/writing)
|
||||||
|
ToggleEditMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Minimal Dockerfile for building and testing kte on Linux
|
||||||
|
# This container provides a build environment with all dependencies.
|
||||||
|
# Mount the source tree at /kte when running the container.
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
g++ \
|
||||||
|
cmake \
|
||||||
|
make \
|
||||||
|
ncurses-dev \
|
||||||
|
pcre2-dev \
|
||||||
|
pkgconf \
|
||||||
|
sdl2-dev \
|
||||||
|
mesa-dev \
|
||||||
|
freetype-dev \
|
||||||
|
libx11-dev \
|
||||||
|
libxext-dev
|
||||||
|
|
||||||
|
# Set working directory where source will be mounted
|
||||||
|
WORKDIR /kte
|
||||||
|
|
||||||
|
# Default command: build and run tests
|
||||||
|
# Add DirectFB include path for SDL2 compatibility on Alpine
|
||||||
|
CMD ["sh", "-c", "cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON -DCMAKE_CXX_FLAGS='-I/usr/include/directfb' && cmake --build build --target kte && cmake --build build --target kge && cmake --build build --target kte_tests && ./build/kte_tests"]
|
||||||
@@ -1,28 +1,21 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "ErrorHandler.h"
|
||||||
#include "syntax/CppHighlighter.h"
|
#include "syntax/CppHighlighter.h"
|
||||||
#include "syntax/NullHighlighter.h"
|
|
||||||
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
static std::string
|
// Universal-argument repeat counts are capped here (well below INT_MAX).
|
||||||
buffer_bytes_via_views(const Buffer &b)
|
constexpr int kMaxUArgCount = 1000000;
|
||||||
{
|
} // namespace
|
||||||
const auto &rows = b.Rows();
|
|
||||||
std::string out;
|
|
||||||
for (std::size_t i = 0; i < rows.size(); i++) {
|
|
||||||
auto v = b.GetLineView(i);
|
|
||||||
out.append(v.data(), v.size());
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
static void
|
static void
|
||||||
apply_pending_line(Editor &ed, const std::size_t line1)
|
apply_pending_line(Editor &ed, const std::size_t line1)
|
||||||
{
|
{
|
||||||
@@ -44,6 +37,46 @@ apply_pending_line(Editor &ed, const std::size_t line1)
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Same normalization as Buffer::OpenFromFile (expand "~", canonical path if
|
||||||
|
// the file exists, else absolute), so a path can be compared with the
|
||||||
|
// Filename() of open buffers.
|
||||||
|
std::string
|
||||||
|
normalize_open_path(const std::string &in)
|
||||||
|
{
|
||||||
|
std::string expanded = in;
|
||||||
|
if (!expanded.empty() && expanded[0] == '~') {
|
||||||
|
const char *home = std::getenv("HOME");
|
||||||
|
if (home && expanded.size() >= 2 && (expanded[1] == '/' || expanded[1] == '\\'))
|
||||||
|
expanded = std::string(home) + expanded.substr(1);
|
||||||
|
else if (home && expanded.size() == 1)
|
||||||
|
expanded = std::string(home);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(expanded);
|
||||||
|
if (std::filesystem::exists(p))
|
||||||
|
return std::filesystem::canonical(p).string();
|
||||||
|
return std::filesystem::absolute(p).string();
|
||||||
|
} catch (...) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
Editor::FindOpenBuffer(const std::string &path) const
|
||||||
|
{
|
||||||
|
const std::string norm = normalize_open_path(path);
|
||||||
|
const auto &bufs = Buffers();
|
||||||
|
for (std::size_t i = 0; i < bufs.size(); ++i) {
|
||||||
|
if (!bufs[i].Filename().empty() && bufs[i].Filename() == norm)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return static_cast<std::size_t>(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Editor::Editor()
|
Editor::Editor()
|
||||||
{
|
{
|
||||||
swap_ = std::make_unique<kte::SwapManager>();
|
swap_ = std::make_unique<kte::SwapManager>();
|
||||||
@@ -69,20 +102,22 @@ Editor::SetStatus(const std::string &message)
|
|||||||
Buffer *
|
Buffer *
|
||||||
Editor::CurrentBuffer()
|
Editor::CurrentBuffer()
|
||||||
{
|
{
|
||||||
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
|
auto &bufs = Buffers();
|
||||||
|
if (bufs.empty() || curbuf_ >= bufs.size()) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
return &buffers_[curbuf_];
|
return &bufs[curbuf_];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const Buffer *
|
const Buffer *
|
||||||
Editor::CurrentBuffer() const
|
Editor::CurrentBuffer() const
|
||||||
{
|
{
|
||||||
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
|
const auto &bufs = Buffers();
|
||||||
|
if (bufs.empty() || curbuf_ >= bufs.size()) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
return &buffers_[curbuf_];
|
return &bufs[curbuf_];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -117,8 +152,9 @@ Editor::DisplayNameFor(const Buffer &buf) const
|
|||||||
|
|
||||||
// Prepare list of other buffer paths
|
// Prepare list of other buffer paths
|
||||||
std::vector<std::vector<std::filesystem::path> > others;
|
std::vector<std::vector<std::filesystem::path> > others;
|
||||||
others.reserve(buffers_.size());
|
const auto &bufs = Buffers();
|
||||||
for (const auto &b: buffers_) {
|
others.reserve(bufs.size());
|
||||||
|
for (const auto &b: bufs) {
|
||||||
if (&b == &buf)
|
if (&b == &buf)
|
||||||
continue;
|
continue;
|
||||||
if (b.Filename().empty())
|
if (b.Filename().empty())
|
||||||
@@ -161,78 +197,94 @@ Editor::DisplayNameFor(const Buffer &buf) const
|
|||||||
std::size_t
|
std::size_t
|
||||||
Editor::AddBuffer(const Buffer &buf)
|
Editor::AddBuffer(const Buffer &buf)
|
||||||
{
|
{
|
||||||
buffers_.push_back(buf);
|
auto &bufs = Buffers();
|
||||||
// Attach swap recorder
|
// push_back may reallocate the vector's storage, moving every existing
|
||||||
if (swap_) {
|
// Buffer to a new address. Drain any in-flight swap records first so the
|
||||||
swap_->Attach(&buffers_.back());
|
// writer thread never dereferences an address that's about to move, then
|
||||||
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
|
// rehome each attached buffer that actually moved.
|
||||||
|
if (Swap() && !bufs.empty())
|
||||||
|
Swap()->Flush();
|
||||||
|
std::vector<Buffer *> old_addrs;
|
||||||
|
old_addrs.reserve(bufs.size());
|
||||||
|
for (auto &b: bufs)
|
||||||
|
old_addrs.push_back(&b);
|
||||||
|
bufs.push_back(buf);
|
||||||
|
if (Swap()) {
|
||||||
|
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
|
||||||
|
Buffer *new_addr = &bufs[i];
|
||||||
|
if (new_addr != old_addrs[i])
|
||||||
|
bufs[i].SetSwapRecorder(Swap()->Rehome(old_addrs[i], new_addr));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
Swap()->Attach(&bufs.back());
|
||||||
|
bufs.back().SetSwapRecorder(Swap()->RecorderFor(&bufs.back()));
|
||||||
|
}
|
||||||
|
if (bufs.size() == 1) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
}
|
}
|
||||||
return buffers_.size() - 1;
|
return bufs.size() - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
std::size_t
|
std::size_t
|
||||||
Editor::AddBuffer(Buffer &&buf)
|
Editor::AddBuffer(Buffer &&buf)
|
||||||
{
|
{
|
||||||
buffers_.push_back(std::move(buf));
|
auto &bufs = Buffers();
|
||||||
if (swap_) {
|
if (Swap() && !bufs.empty())
|
||||||
swap_->Attach(&buffers_.back());
|
Swap()->Flush();
|
||||||
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
|
std::vector<Buffer *> old_addrs;
|
||||||
|
old_addrs.reserve(bufs.size());
|
||||||
|
for (auto &b: bufs)
|
||||||
|
old_addrs.push_back(&b);
|
||||||
|
bufs.push_back(std::move(buf));
|
||||||
|
if (Swap()) {
|
||||||
|
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
|
||||||
|
Buffer *new_addr = &bufs[i];
|
||||||
|
if (new_addr != old_addrs[i])
|
||||||
|
bufs[i].SetSwapRecorder(Swap()->Rehome(old_addrs[i], new_addr));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
Swap()->Attach(&bufs.back());
|
||||||
|
bufs.back().SetSwapRecorder(Swap()->RecorderFor(&bufs.back()));
|
||||||
|
}
|
||||||
|
if (bufs.size() == 1) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
}
|
}
|
||||||
return buffers_.size() - 1;
|
return bufs.size() - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
Editor::OpenFile(const std::string &path, std::string &err)
|
Editor::OpenFile(const std::string &path, std::string &err)
|
||||||
{
|
{
|
||||||
// If there is exactly one unnamed, empty, clean buffer, reuse it instead
|
// A file that is already open is switched to, not opened again: two
|
||||||
// of creating a new one.
|
// buffers for one file would share (and corrupt) one swap journal, and
|
||||||
if (buffers_.size() == 1) {
|
// closing either would delete the other's journal.
|
||||||
Buffer &cur = buffers_[curbuf_];
|
if (const std::size_t open_idx = FindOpenBuffer(path); open_idx != static_cast<std::size_t>(-1)) {
|
||||||
|
SwitchTo(open_idx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the current buffer is an unnamed, empty, clean scratch buffer, reuse
|
||||||
|
// it instead of creating a new one.
|
||||||
|
auto &bufs_ref = Buffers();
|
||||||
|
if (!bufs_ref.empty() && curbuf_ < bufs_ref.size()) {
|
||||||
|
Buffer &cur = bufs_ref[curbuf_];
|
||||||
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
||||||
const bool clean = !cur.Dirty();
|
const bool clean = !cur.Dirty();
|
||||||
const auto &rows = cur.Rows();
|
const std::size_t nrows = cur.Nrows();
|
||||||
const bool rows_empty = rows.empty();
|
const bool rows_empty = (nrows == 0);
|
||||||
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0);
|
const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
|
||||||
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
||||||
bool ok = cur.OpenFromFile(path, err);
|
bool ok = cur.OpenFromFile(path, err);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
return false;
|
return false;
|
||||||
// Ensure swap recorder is attached for this buffer
|
// Ensure swap recorder is attached for this buffer
|
||||||
if (swap_) {
|
if (Swap()) {
|
||||||
swap_->Attach(&cur);
|
Swap()->Attach(&cur);
|
||||||
cur.SetSwapRecorder(swap_->RecorderFor(&cur));
|
cur.SetSwapRecorder(Swap()->RecorderFor(&cur));
|
||||||
swap_->NotifyFilenameChanged(cur);
|
Swap()->NotifyFilenameChanged(cur);
|
||||||
}
|
|
||||||
// Setup highlighting using registry (extension + shebang)
|
|
||||||
cur.EnsureHighlighter();
|
|
||||||
std::string first = "";
|
|
||||||
const auto &cur_rows = cur.Rows();
|
|
||||||
if (!cur_rows.empty())
|
|
||||||
first = static_cast<std::string>(cur_rows[0]);
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
|
||||||
if (!ft.empty()) {
|
|
||||||
cur.SetFiletype(ft);
|
|
||||||
cur.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = cur.Highlighter()) {
|
|
||||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cur.SetFiletype("");
|
|
||||||
cur.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = cur.Highlighter()) {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Highlighting by extension or shebang
|
||||||
|
cur.ApplyDetectedFiletype();
|
||||||
// Defensive: ensure any active prompt is closed after a successful open
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
CancelPrompt();
|
CancelPrompt();
|
||||||
return true;
|
return true;
|
||||||
@@ -245,34 +297,12 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
}
|
}
|
||||||
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
||||||
// final location (vector) because swap manager keys off Buffer*.
|
// final location (vector) because swap manager keys off Buffer*.
|
||||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
// Highlighting by extension or shebang
|
||||||
b.EnsureHighlighter();
|
b.ApplyDetectedFiletype();
|
||||||
std::string first = "";
|
|
||||||
{
|
|
||||||
const auto &rows = b.Rows();
|
|
||||||
if (!rows.empty())
|
|
||||||
first = static_cast<std::string>(rows[0]);
|
|
||||||
}
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
|
||||||
if (!ft.empty()) {
|
|
||||||
b.SetFiletype(ft);
|
|
||||||
b.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = b.Highlighter()) {
|
|
||||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.SetFiletype("");
|
|
||||||
b.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = b.Highlighter()) {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add as a new buffer and switch to it
|
// Add as a new buffer and switch to it
|
||||||
std::size_t idx = AddBuffer(std::move(b));
|
std::size_t idx = AddBuffer(std::move(b));
|
||||||
if (swap_) {
|
if (Swap()) {
|
||||||
swap_->NotifyFilenameChanged(buffers_[idx]);
|
Swap()->NotifyFilenameChanged(Buffers()[idx]);
|
||||||
}
|
}
|
||||||
SwitchTo(idx);
|
SwitchTo(idx);
|
||||||
// Defensive: ensure any active prompt is closed after a successful open
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
@@ -312,6 +342,10 @@ Editor::CancelRecoveryPrompt()
|
|||||||
pending_recovery_open_ = PendingOpen{};
|
pending_recovery_open_ = PendingOpen{};
|
||||||
pending_recovery_swap_path_.clear();
|
pending_recovery_swap_path_.clear();
|
||||||
pending_recovery_replay_err_.clear();
|
pending_recovery_replay_err_.clear();
|
||||||
|
std::string().swap(pending_recovery_content_);
|
||||||
|
pending_recovery_valid_bytes_ = 0;
|
||||||
|
pending_recovery_swap_size_ = 0;
|
||||||
|
pending_recovery_have_content_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -324,6 +358,10 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
const PendingOpen req = pending_recovery_open_;
|
const PendingOpen req = pending_recovery_open_;
|
||||||
const std::string swp = pending_recovery_swap_path_;
|
const std::string swp = pending_recovery_swap_path_;
|
||||||
const std::string rerr_s = pending_recovery_replay_err_;
|
const std::string rerr_s = pending_recovery_replay_err_;
|
||||||
|
std::string content = std::move(pending_recovery_content_);
|
||||||
|
const bool have_content = pending_recovery_have_content_;
|
||||||
|
std::uint64_t valid_bytes = pending_recovery_valid_bytes_;
|
||||||
|
const std::uint64_t swap_size_then = pending_recovery_swap_size_;
|
||||||
CancelRecoveryPrompt();
|
CancelRecoveryPrompt();
|
||||||
|
|
||||||
std::string err;
|
std::string err;
|
||||||
@@ -338,11 +376,35 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
SetStatus("Recovery failed: no buffer");
|
SetStatus("Recovery failed: no buffer");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
std::uint64_t swap_size_now = 0;
|
||||||
|
try {
|
||||||
|
swap_size_now = std::filesystem::file_size(swp);
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
if (have_content && swap_size_now == swap_size_then) {
|
||||||
|
// The prompt already replayed the journal (unchanged since); install that.
|
||||||
|
b->replace_all_bytes(content);
|
||||||
|
std::string().swap(content);
|
||||||
|
} else {
|
||||||
std::string rerr;
|
std::string rerr;
|
||||||
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
|
if (!kte::SwapManager::ReplayFile(*b, swp, rerr, &valid_bytes)) {
|
||||||
SetStatus("Swap recovery failed: " + rerr);
|
SetStatus("Swap recovery failed: " + rerr);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// This session keeps appending to the same journal. Drop a torn
|
||||||
|
// final record first, or new records would land behind it and the
|
||||||
|
// journal could not be replayed after another crash.
|
||||||
|
try {
|
||||||
|
if (std::filesystem::file_size(swp) > valid_bytes)
|
||||||
|
std::filesystem::resize_file(swp, valid_bytes);
|
||||||
|
} catch (...) {
|
||||||
|
// Best effort; the replay itself succeeded.
|
||||||
|
}
|
||||||
|
// Nothing in the undo history matches the file on disk now: keep
|
||||||
|
// the buffer dirty even after undoing back to the loaded state.
|
||||||
|
if (auto *u = b->Undo())
|
||||||
|
u->mark_base_unsaved();
|
||||||
b->SetDirty(true);
|
b->SetDirty(true);
|
||||||
apply_pending_line(*this, req.line1);
|
apply_pending_line(*this, req.line1);
|
||||||
SetStatus("Recovered " + req.path);
|
SetStatus("Recovered " + req.path);
|
||||||
@@ -359,8 +421,16 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
||||||
|
std::string kept_as;
|
||||||
if (yes) {
|
if (yes) {
|
||||||
(void) std::remove(swp.c_str());
|
(void) std::remove(swp.c_str());
|
||||||
|
} else {
|
||||||
|
// Keep the unreadable journal for inspection, but out of the way:
|
||||||
|
// left in place, the new session's records would be appended
|
||||||
|
// behind the corrupt data and could never be replayed.
|
||||||
|
kept_as = swp + ".corrupt";
|
||||||
|
if (std::rename(swp.c_str(), kept_as.c_str()) != 0)
|
||||||
|
kept_as.clear();
|
||||||
}
|
}
|
||||||
if (!OpenFile(req.path, err)) {
|
if (!OpenFile(req.path, err)) {
|
||||||
SetStatus(err);
|
SetStatus(err);
|
||||||
@@ -368,7 +438,9 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
}
|
}
|
||||||
apply_pending_line(*this, req.line1);
|
apply_pending_line(*this, req.line1);
|
||||||
// Include a short hint that the swap was corrupt.
|
// Include a short hint that the swap was corrupt.
|
||||||
if (!rerr_s.empty()) {
|
if (!kept_as.empty()) {
|
||||||
|
SetStatus("Opened " + req.path + " (unreadable swap kept as " + kept_as + ")");
|
||||||
|
} else if (!rerr_s.empty()) {
|
||||||
SetStatus("Opened " + req.path + " (swap unreadable)");
|
SetStatus("Opened " + req.path + " (swap unreadable)");
|
||||||
} else {
|
} else {
|
||||||
SetStatus("Opened " + req.path);
|
SetStatus("Opened " + req.path);
|
||||||
@@ -381,6 +453,39 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
|
|
||||||
bool
|
bool
|
||||||
Editor::ProcessPendingOpens()
|
Editor::ProcessPendingOpens()
|
||||||
|
{
|
||||||
|
bool opened = false;
|
||||||
|
// Opening can throw (bad_alloc on a huge file, filesystem errors), and so
|
||||||
|
// can the checkpoint snapshot below. This runs from every frontend's
|
||||||
|
// loop, outside command dispatch: report the failure instead of letting
|
||||||
|
// it end the editor.
|
||||||
|
try {
|
||||||
|
// Also the once-per-frame hook for the journal: retry the
|
||||||
|
// checkpoint of a journal that lost a record.
|
||||||
|
if (kte::SwapManager *sm = Swap())
|
||||||
|
sm->RetryGapCheckpoints();
|
||||||
|
opened = process_pending_opens_();
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
kte::ErrorHandler::Instance().Error("Editor", std::string("open failed: ") + e.what(), "");
|
||||||
|
SetStatus(std::string("Open failed: ") + e.what());
|
||||||
|
} catch (...) {
|
||||||
|
SetStatus("Open failed");
|
||||||
|
}
|
||||||
|
// Journal problems for the user, after the open's own status so it does
|
||||||
|
// not hide them; all pending ones at once (one per frame flashed by).
|
||||||
|
if (kte::SwapManager *sm = Swap()) {
|
||||||
|
std::string notices;
|
||||||
|
for (std::string n = sm->TakeUserNotice(); !n.empty(); n = sm->TakeUserNotice())
|
||||||
|
notices += (notices.empty() ? "" : "; ") + n;
|
||||||
|
if (!notices.empty())
|
||||||
|
SetStatus(notices);
|
||||||
|
}
|
||||||
|
return opened;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::process_pending_opens_()
|
||||||
{
|
{
|
||||||
if (PromptActive())
|
if (PromptActive())
|
||||||
return false;
|
return false;
|
||||||
@@ -393,6 +498,15 @@ Editor::ProcessPendingOpens()
|
|||||||
pending_open_.pop_front();
|
pending_open_.pop_front();
|
||||||
if (req.path.empty())
|
if (req.path.empty())
|
||||||
continue;
|
continue;
|
||||||
|
// Already open: switch to it. Its swap file is this session's live
|
||||||
|
// journal, not something to recover.
|
||||||
|
if (const std::size_t open_idx = FindOpenBuffer(req.path); open_idx != static_cast<std::size_t>(-1)) {
|
||||||
|
SwitchTo(open_idx);
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Switched to " + Buffers()[open_idx].Filename());
|
||||||
|
opened_any = true;
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
|
|
||||||
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
|
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
|
||||||
bool swp_exists = false;
|
bool swp_exists = false;
|
||||||
@@ -401,20 +515,59 @@ Editor::ProcessPendingOpens()
|
|||||||
} catch (...) {
|
} catch (...) {
|
||||||
swp_exists = false;
|
swp_exists = false;
|
||||||
}
|
}
|
||||||
|
if (swp_exists && kte::SwapManager::JournalInUse(swp)) {
|
||||||
|
// Another running kte is editing this file: its journal is live.
|
||||||
|
// Open without recovery and leave the journal alone; this
|
||||||
|
// session will not journal the file.
|
||||||
|
std::string err;
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus(req.path + " is open in another kte; this copy has no crash recovery");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (swp_exists && !kte::SwapManager::JournalMatchesFile(swp, req.path)) {
|
||||||
|
// The file changed (or was removed) after the journal started, so
|
||||||
|
// its position-based edits no longer apply.
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
|
||||||
|
pending_recovery_open_ = req;
|
||||||
|
pending_recovery_swap_path_ = swp;
|
||||||
|
pending_recovery_replay_err_ = "stale";
|
||||||
|
StartPrompt(PromptKind::Confirm, "Swap", "");
|
||||||
|
SetStatus("Swap file for " + req.path +
|
||||||
|
" predates changes to the file on disk and cannot be applied. Delete it? (y/N, C-g cancel)");
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
if (swp_exists) {
|
if (swp_exists) {
|
||||||
Buffer tmp;
|
Buffer tmp;
|
||||||
std::string oerr;
|
std::string oerr;
|
||||||
if (tmp.OpenFromFile(req.path, oerr)) {
|
if (tmp.OpenFromFile(req.path, oerr)) {
|
||||||
const std::string orig = buffer_bytes_via_views(tmp);
|
const std::string orig = tmp.Bytes();
|
||||||
std::string rerr;
|
std::string rerr;
|
||||||
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
|
std::uint64_t valid = 0;
|
||||||
const std::string rec = buffer_bytes_via_views(tmp);
|
if (kte::SwapManager::ReplayFile(tmp, swp, rerr, &valid)) {
|
||||||
if (rec != orig) {
|
std::string rec = tmp.Bytes();
|
||||||
|
if (rec == orig) {
|
||||||
|
// Nothing to recover. Remove the journal rather than
|
||||||
|
// appending this session's records to it (it may end in a
|
||||||
|
// torn record, which would make them unreplayable).
|
||||||
|
(void) std::remove(swp.c_str());
|
||||||
|
} else {
|
||||||
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
|
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
|
||||||
pending_recovery_open_ = req;
|
pending_recovery_open_ = req;
|
||||||
pending_recovery_swap_path_ = swp;
|
pending_recovery_swap_path_ = swp;
|
||||||
|
pending_recovery_content_ = std::move(rec);
|
||||||
|
pending_recovery_valid_bytes_ = valid;
|
||||||
|
pending_recovery_have_content_ = true;
|
||||||
|
try {
|
||||||
|
pending_recovery_swap_size_ = std::filesystem::file_size(swp);
|
||||||
|
} catch (...) {
|
||||||
|
pending_recovery_have_content_ = false; // replay on "y"
|
||||||
|
}
|
||||||
StartPrompt(PromptKind::Confirm, "Recover", "");
|
StartPrompt(PromptKind::Confirm, "Recover", "");
|
||||||
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)");
|
SetStatus("Recover swap edits for " + req.path + "? (y/n, C-g cancel)");
|
||||||
return opened_any;
|
return opened_any;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -450,31 +603,15 @@ Editor::ProcessPendingOpens()
|
|||||||
bool
|
bool
|
||||||
Editor::SwitchTo(std::size_t index)
|
Editor::SwitchTo(std::size_t index)
|
||||||
{
|
{
|
||||||
if (index >= buffers_.size()) {
|
auto &bufs = Buffers();
|
||||||
|
if (index >= bufs.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
curbuf_ = index;
|
curbuf_ = index;
|
||||||
// Robustness: ensure a valid highlighter is installed when switching buffers
|
// Robustness: ensure a valid highlighter is installed when switching buffers
|
||||||
Buffer &b = buffers_[curbuf_];
|
Buffer &b = bufs[curbuf_];
|
||||||
if (b.SyntaxEnabled()) {
|
if (b.SyntaxEnabled() && !(b.Highlighter() && b.Highlighter()->HasHighlighter()))
|
||||||
b.EnsureHighlighter();
|
b.InstallFiletypeHighlighter();
|
||||||
if (auto *eng = b.Highlighter()) {
|
|
||||||
if (!eng->HasHighlighter()) {
|
|
||||||
// Try to set based on existing filetype; fall back to NullHighlighter
|
|
||||||
if (!b.Filetype().empty()) {
|
|
||||||
auto hl = kte::HighlighterRegistry::CreateFor(b.Filetype());
|
|
||||||
if (hl) {
|
|
||||||
eng->SetHighlighter(std::move(hl));
|
|
||||||
} else {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,20 +619,36 @@ Editor::SwitchTo(std::size_t index)
|
|||||||
bool
|
bool
|
||||||
Editor::CloseBuffer(std::size_t index)
|
Editor::CloseBuffer(std::size_t index)
|
||||||
{
|
{
|
||||||
if (index >= buffers_.size()) {
|
auto &bufs = Buffers();
|
||||||
|
if (index >= bufs.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (swap_) {
|
if (Swap()) {
|
||||||
// If the buffer is clean, remove its swap file when closing.
|
// Always remove swap file when closing a buffer on normal exit.
|
||||||
// (Crash recovery is unaffected: on crash, close paths are not executed.)
|
// Swap files are for crash recovery; on clean close, we don't need them.
|
||||||
swap_->Detach(&buffers_[index], !buffers_[index].Dirty());
|
// This prevents stale swap files from accumulating (e.g., when used as git editor).
|
||||||
buffers_[index].SetSwapRecorder(nullptr);
|
Swap()->Detach(&bufs[index], true);
|
||||||
|
bufs[index].SetSwapRecorder(nullptr);
|
||||||
|
// Drain in-flight records before the erase-shift below moves other
|
||||||
|
// buffers to new addresses (vector::erase never reallocates, but it does
|
||||||
|
// move-assign each trailing buffer into the previous slot).
|
||||||
|
Swap()->Flush();
|
||||||
}
|
}
|
||||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
|
||||||
if (buffers_.empty()) {
|
if (Swap()) {
|
||||||
|
// erase() shifts every buffer after `index` down by one slot in-place
|
||||||
|
// (no reallocation), so the buffer now at slot i used to live at slot
|
||||||
|
// i+1 (same underlying storage, since data() doesn't move on erase).
|
||||||
|
for (std::size_t i = index; i < bufs.size(); ++i) {
|
||||||
|
Buffer *new_addr = &bufs[i];
|
||||||
|
Buffer *old_addr = new_addr + 1;
|
||||||
|
bufs[i].SetSwapRecorder(Swap()->Rehome(old_addr, new_addr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bufs.empty()) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
} else if (curbuf_ >= buffers_.size()) {
|
} else if (curbuf_ >= bufs.size()) {
|
||||||
curbuf_ = buffers_.size() - 1;
|
curbuf_ = bufs.size() - 1;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -505,21 +658,22 @@ void
|
|||||||
Editor::Reset()
|
Editor::Reset()
|
||||||
{
|
{
|
||||||
rows_ = cols_ = 0;
|
rows_ = cols_ = 0;
|
||||||
mode_ = 0;
|
|
||||||
kill_ = 0;
|
kill_ = 0;
|
||||||
no_kill_ = 0;
|
|
||||||
dirtyex_ = 0;
|
|
||||||
msg_.clear();
|
msg_.clear();
|
||||||
msgtm_ = 0;
|
msgtm_ = 0;
|
||||||
uarg_ = 0;
|
uarg_ = 0;
|
||||||
ucount_ = 0;
|
ucount_ = 0;
|
||||||
repeatable_ = false;
|
|
||||||
quit_requested_ = false;
|
quit_requested_ = false;
|
||||||
quit_confirm_pending_ = false;
|
quit_confirm_pending_ = false;
|
||||||
// Reset close-confirm/save state
|
// Reset close-confirm/save state
|
||||||
close_confirm_pending_ = false;
|
close_confirm_pending_ = false;
|
||||||
close_after_save_ = false;
|
close_after_save_ = false;
|
||||||
buffers_.clear();
|
auto &bufs = Buffers();
|
||||||
|
if (Swap()) {
|
||||||
|
for (auto &buf : bufs)
|
||||||
|
Swap()->Detach(&buf, true);
|
||||||
|
}
|
||||||
|
bufs.clear();
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +689,7 @@ Editor::UArgStart()
|
|||||||
if (ucount_ == 0) {
|
if (ucount_ == 0) {
|
||||||
ucount_ = 1;
|
ucount_ = 1;
|
||||||
}
|
}
|
||||||
ucount_ *= 4;
|
ucount_ = std::min(ucount_ * 4, kMaxUArgCount); // no int overflow
|
||||||
}
|
}
|
||||||
uarg_ = 1;
|
uarg_ = 1;
|
||||||
char buf[64];
|
char buf[64];
|
||||||
@@ -555,7 +709,7 @@ Editor::UArgDigit(int d)
|
|||||||
uarg_ = 1;
|
uarg_ = 1;
|
||||||
ucount_ = 0;
|
ucount_ = 0;
|
||||||
}
|
}
|
||||||
ucount_ = ucount_ * 10 + d;
|
ucount_ = (ucount_ > (kMaxUArgCount - d) / 10) ? kMaxUArgCount : ucount_ * 10 + d;
|
||||||
char buf[64];
|
char buf[64];
|
||||||
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
||||||
SetStatus(buf);
|
SetStatus(buf);
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
/*
|
/*
|
||||||
* Editor.h - top-level editor state and buffer management
|
* Editor.h - top-level editor state and buffer management
|
||||||
|
*
|
||||||
|
* Editor is the top-level coordinator in kte. It manages:
|
||||||
|
*
|
||||||
|
* - Buffer collection: Multiple open documents (buffers_), current buffer selection
|
||||||
|
* - UI state: Dimensions, status messages, prompts, search state
|
||||||
|
* - Kill ring: Shared clipboard for cut/copy/paste operations across buffers
|
||||||
|
* - Universal argument: Repeat count mechanism (C-u)
|
||||||
|
* - Mode flags: Editor modes (normal, k-command, search, prompt, etc.)
|
||||||
|
* - Swap/crash recovery: SwapManager integration for journaling
|
||||||
|
* - File operations: Opening files, managing pending opens, recovery prompts
|
||||||
|
*
|
||||||
|
* Key responsibilities:
|
||||||
|
*
|
||||||
|
* 1. Buffer lifecycle:
|
||||||
|
* - AddBuffer(): Add new buffers to the collection
|
||||||
|
* - OpenFile(): Load files into buffers
|
||||||
|
* - SwitchTo(): Change active buffer
|
||||||
|
* - CloseBuffer(): Remove buffers with dirty checks
|
||||||
|
*
|
||||||
|
* 2. UI coordination:
|
||||||
|
* - SetDimensions(): Terminal/window size for viewport calculations
|
||||||
|
* - SetStatus(): Status line messages with timestamps
|
||||||
|
* - Prompt system: Multi-step prompts for file open, buffer switch, etc.
|
||||||
|
* - Search state: Active search, query, match position, origin tracking
|
||||||
|
*
|
||||||
|
* 3. Shared editor state:
|
||||||
|
* - Kill ring: Circular buffer of killed text (max 60 entries)
|
||||||
|
* - Universal argument: C-u digit collection for command repetition
|
||||||
|
* - Mode tracking: Current input mode (normal, k-command, ESC, prompt)
|
||||||
|
*
|
||||||
|
* 4. Integration points:
|
||||||
|
* - Commands operate on Editor and current Buffer
|
||||||
|
* - Frontend (Terminal/GUI) queries Editor for rendering
|
||||||
|
* - SwapManager journals all buffer modifications
|
||||||
|
*
|
||||||
|
* Design note: Editor owns the buffer collection but doesn't directly edit content.
|
||||||
|
* Commands modify buffers through Buffer's API, and Editor coordinates the UI state.
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@@ -42,19 +79,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Mode and flags (mirroring legacy fields)
|
|
||||||
void SetMode(int m)
|
|
||||||
{
|
|
||||||
mode_ = m;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int Mode() const
|
|
||||||
{
|
|
||||||
return mode_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetKillChain(bool on)
|
void SetKillChain(bool on)
|
||||||
{
|
{
|
||||||
kill_ = on ? 1 : 0;
|
kill_ = on ? 1 : 0;
|
||||||
@@ -67,18 +91,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetNoKill(bool on)
|
|
||||||
{
|
|
||||||
no_kill_ = on ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool NoKill() const
|
|
||||||
{
|
|
||||||
return no_kill_ != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Kill ring API
|
// Kill ring API
|
||||||
void KillRingClear()
|
void KillRingClear()
|
||||||
{
|
{
|
||||||
@@ -127,18 +139,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetDirtyEx(int d)
|
|
||||||
{
|
|
||||||
dirtyex_ = d;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int DirtyEx() const
|
|
||||||
{
|
|
||||||
return dirtyex_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetUniversalArg(int uarg, int ucount)
|
void SetUniversalArg(int uarg, int ucount)
|
||||||
{
|
{
|
||||||
uarg_ = uarg;
|
uarg_ = uarg;
|
||||||
@@ -152,12 +152,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int UCount() const
|
|
||||||
{
|
|
||||||
return ucount_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Universal argument control (C-u) ---
|
// --- Universal argument control (C-u) ---
|
||||||
// Begin or extend a universal argument (like ke's uarg_start)
|
// Begin or extend a universal argument (like ke's uarg_start)
|
||||||
void UArgStart();
|
void UArgStart();
|
||||||
@@ -172,19 +166,6 @@ public:
|
|||||||
// If no universal argument active, returns 1.
|
// If no universal argument active, returns 1.
|
||||||
int UArgGet();
|
int UArgGet();
|
||||||
|
|
||||||
// Repeatable command flag: input layer can mark the next command as repeatable
|
|
||||||
void SetRepeatable(bool on)
|
|
||||||
{
|
|
||||||
repeatable_ = on;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool Repeatable() const
|
|
||||||
{
|
|
||||||
return repeatable_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Status message storage. Rendering is renderer-dependent; the editor
|
// Status message storage. Rendering is renderer-dependent; the editor
|
||||||
// merely stores the current message and its timestamp.
|
// merely stores the current message and its timestamp.
|
||||||
void SetStatus(const std::string &message);
|
void SetStatus(const std::string &message);
|
||||||
@@ -209,6 +190,18 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void SetNewWindowRequested(bool on)
|
||||||
|
{
|
||||||
|
new_window_requested_ = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool NewWindowRequested() const
|
||||||
|
{
|
||||||
|
return new_window_requested_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetQuitConfirmPending(bool on)
|
void SetQuitConfirmPending(bool on)
|
||||||
{
|
{
|
||||||
quit_confirm_pending_ = on;
|
quit_confirm_pending_ = on;
|
||||||
@@ -472,7 +465,7 @@ public:
|
|||||||
// Buffers
|
// Buffers
|
||||||
[[nodiscard]] std::size_t BufferCount() const
|
[[nodiscard]] std::size_t BufferCount() const
|
||||||
{
|
{
|
||||||
return buffers_.size();
|
return Buffers().size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -482,6 +475,19 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Clamp curbuf_ to valid range. Call when the shared buffer list may
|
||||||
|
// have been modified by another editor (e.g., buffer closed in another window).
|
||||||
|
void ValidateBufferIndex()
|
||||||
|
{
|
||||||
|
const auto &bufs = Buffers();
|
||||||
|
if (bufs.empty()) {
|
||||||
|
curbuf_ = 0;
|
||||||
|
} else if (curbuf_ >= bufs.size()) {
|
||||||
|
curbuf_ = bufs.size() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Buffer *CurrentBuffer();
|
Buffer *CurrentBuffer();
|
||||||
|
|
||||||
const Buffer *CurrentBuffer() const;
|
const Buffer *CurrentBuffer() const;
|
||||||
@@ -506,6 +512,11 @@ public:
|
|||||||
// Returns true if a file was opened during this call.
|
// Returns true if a file was opened during this call.
|
||||||
bool ProcessPendingOpens();
|
bool ProcessPendingOpens();
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool process_pending_opens_();
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
[[nodiscard]] bool HasPendingOpens() const;
|
[[nodiscard]] bool HasPendingOpens() const;
|
||||||
|
|
||||||
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
||||||
@@ -533,20 +544,38 @@ public:
|
|||||||
// Direct access when needed (try to prefer methods above)
|
// Direct access when needed (try to prefer methods above)
|
||||||
[[nodiscard]] const std::vector<Buffer> &Buffers() const
|
[[nodiscard]] const std::vector<Buffer> &Buffers() const
|
||||||
{
|
{
|
||||||
return buffers_;
|
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
std::vector<Buffer> &Buffers()
|
std::vector<Buffer> &Buffers()
|
||||||
{
|
{
|
||||||
return buffers_;
|
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Share another editor's buffer list. When set, this editor operates on
|
||||||
|
// the provided vector instead of its own. Pass nullptr to detach.
|
||||||
|
// Buffers hold recorders owned by the SwapManager that attached them, so
|
||||||
|
// editors sharing buffers must also share that manager (shared_swap);
|
||||||
|
// otherwise closing one window destroys recorders the other still uses.
|
||||||
|
// Index of the open buffer whose file is `path` (after the same
|
||||||
|
// normalization OpenFromFile applies), or size_t(-1) if none.
|
||||||
|
[[nodiscard]] std::size_t FindOpenBuffer(const std::string &path) const;
|
||||||
|
|
||||||
|
|
||||||
|
void SetSharedBuffers(std::vector<Buffer> *shared, kte::SwapManager *shared_swap = nullptr)
|
||||||
|
{
|
||||||
|
shared_buffers_ = shared;
|
||||||
|
shared_swap_ = shared ? shared_swap : nullptr;
|
||||||
|
curbuf_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Swap manager access (for advanced integrations/tests)
|
// Swap manager access (for advanced integrations/tests)
|
||||||
[[nodiscard]] kte::SwapManager *Swap()
|
[[nodiscard]] kte::SwapManager *Swap()
|
||||||
{
|
{
|
||||||
return swap_.get();
|
return shared_swap_ ? shared_swap_ : swap_.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -581,20 +610,18 @@ private:
|
|||||||
};
|
};
|
||||||
|
|
||||||
std::size_t rows_ = 0, cols_ = 0;
|
std::size_t rows_ = 0, cols_ = 0;
|
||||||
int mode_ = 0;
|
|
||||||
int kill_ = 0; // KILL CHAIN
|
int kill_ = 0; // KILL CHAIN
|
||||||
int no_kill_ = 0; // don't kill in delete_row
|
|
||||||
int dirtyex_ = 0;
|
|
||||||
std::string msg_;
|
std::string msg_;
|
||||||
std::time_t msgtm_ = 0;
|
std::time_t msgtm_ = 0;
|
||||||
int uarg_ = 0, ucount_ = 0; // C-u support
|
int uarg_ = 0, ucount_ = 0; // C-u support
|
||||||
bool repeatable_ = false; // whether the next command is repeatable
|
|
||||||
|
|
||||||
std::vector<Buffer> buffers_;
|
std::vector<Buffer> buffers_;
|
||||||
|
std::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of buffers_
|
||||||
std::size_t curbuf_ = 0; // index into buffers_
|
std::size_t curbuf_ = 0; // index into buffers_
|
||||||
|
|
||||||
// Swap journaling manager (lifetime = editor)
|
// Swap journaling manager (lifetime = editor)
|
||||||
std::unique_ptr<kte::SwapManager> swap_;
|
std::unique_ptr<kte::SwapManager> swap_;
|
||||||
|
kte::SwapManager *shared_swap_ = nullptr; // set with shared_buffers_; not owned
|
||||||
|
|
||||||
// Kill ring (Emacs-like)
|
// Kill ring (Emacs-like)
|
||||||
std::vector<std::string> kill_ring_;
|
std::vector<std::string> kill_ring_;
|
||||||
@@ -602,6 +629,7 @@ private:
|
|||||||
|
|
||||||
// Quit state
|
// Quit state
|
||||||
bool quit_requested_ = false;
|
bool quit_requested_ = false;
|
||||||
|
bool new_window_requested_ = false;
|
||||||
bool quit_confirm_pending_ = false;
|
bool quit_confirm_pending_ = false;
|
||||||
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close
|
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close
|
||||||
bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
|
bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
|
||||||
@@ -629,6 +657,12 @@ private:
|
|||||||
PendingOpen pending_recovery_open_{};
|
PendingOpen pending_recovery_open_{};
|
||||||
std::string pending_recovery_swap_path_;
|
std::string pending_recovery_swap_path_;
|
||||||
std::string pending_recovery_replay_err_;
|
std::string pending_recovery_replay_err_;
|
||||||
|
// The recovered content the prompt offers, and how much of the journal
|
||||||
|
// produced it: "y" installs it instead of replaying the journal again.
|
||||||
|
std::string pending_recovery_content_;
|
||||||
|
std::uint64_t pending_recovery_valid_bytes_ = 0;
|
||||||
|
std::uint64_t pending_recovery_swap_size_ = 0; // the journal's size then
|
||||||
|
bool pending_recovery_have_content_ = false;
|
||||||
|
|
||||||
// GUI-only state (safe no-op in terminal builds)
|
// GUI-only state (safe no-op in terminal builds)
|
||||||
bool file_picker_visible_ = false;
|
bool file_picker_visible_ = false;
|
||||||
|
|||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
#include "ErrorHandler.h"
|
||||||
|
#include <chrono>
|
||||||
|
#include <ctime>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <sstream>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
ErrorHandler::ErrorHandler()
|
||||||
|
{
|
||||||
|
// Determine log file path: ~/.local/state/kte/error.log
|
||||||
|
const char *home = std::getenv("HOME");
|
||||||
|
if (home) {
|
||||||
|
fs::path log_dir = fs::path(home) / ".local" / "state" / "kte";
|
||||||
|
try {
|
||||||
|
if (!fs::exists(log_dir)) {
|
||||||
|
fs::create_directories(log_dir);
|
||||||
|
}
|
||||||
|
log_file_path_ = (log_dir / "error.log").string();
|
||||||
|
// Create the log file immediately so it exists in the state directory
|
||||||
|
ensure_log_file();
|
||||||
|
} catch (...) {
|
||||||
|
// If we can't create the directory, disable file logging
|
||||||
|
file_logging_enabled_ = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No HOME, disable file logging
|
||||||
|
file_logging_enabled_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ErrorHandler::~ErrorHandler()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
if (log_file_ && log_file_->is_open()) {
|
||||||
|
log_file_->flush();
|
||||||
|
log_file_->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ErrorHandler &
|
||||||
|
ErrorHandler::Instance()
|
||||||
|
{
|
||||||
|
static ErrorHandler instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Report(ErrorSeverity severity, const std::string &component,
|
||||||
|
const std::string &message, const std::string &context)
|
||||||
|
{
|
||||||
|
ErrorRecord record;
|
||||||
|
record.timestamp_ns = now_ns();
|
||||||
|
record.severity = severity;
|
||||||
|
record.component = component;
|
||||||
|
record.message = message;
|
||||||
|
record.context = context;
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
|
// Add to in-memory queue
|
||||||
|
errors_.push_back(record);
|
||||||
|
while (errors_.size() > 100) {
|
||||||
|
errors_.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
++total_error_count_;
|
||||||
|
if (severity == ErrorSeverity::Critical) {
|
||||||
|
++critical_error_count_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to log file if enabled
|
||||||
|
if (file_logging_enabled_) {
|
||||||
|
write_to_log(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Info(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context)
|
||||||
|
{
|
||||||
|
Report(ErrorSeverity::Info, component, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Warning(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context)
|
||||||
|
{
|
||||||
|
Report(ErrorSeverity::Warning, component, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Error(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context)
|
||||||
|
{
|
||||||
|
Report(ErrorSeverity::Error, component, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::Critical(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context)
|
||||||
|
{
|
||||||
|
Report(ErrorSeverity::Critical, component, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
ErrorHandler::HasErrors() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
return !errors_.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
ErrorHandler::HasCriticalErrors() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
return critical_error_count_ > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
ErrorHandler::GetLastError() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
if (errors_.empty())
|
||||||
|
return "";
|
||||||
|
|
||||||
|
const ErrorRecord &e = errors_.back();
|
||||||
|
std::string result = "[" + severity_to_string(e.severity) + "] ";
|
||||||
|
result += e.component;
|
||||||
|
if (!e.context.empty()) {
|
||||||
|
result += " (" + e.context + ")";
|
||||||
|
}
|
||||||
|
result += ": " + e.message;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
ErrorHandler::GetErrorCount() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
return total_error_count_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
ErrorHandler::GetErrorCount(ErrorSeverity severity) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
std::size_t count = 0;
|
||||||
|
for (const auto &e: errors_) {
|
||||||
|
if (e.severity == severity) {
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<ErrorHandler::ErrorRecord>
|
||||||
|
ErrorHandler::GetRecentErrors(std::size_t max_count) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
std::vector<ErrorRecord> result;
|
||||||
|
result.reserve(std::min(max_count, errors_.size()));
|
||||||
|
|
||||||
|
// Return most recent first
|
||||||
|
auto it = errors_.rbegin();
|
||||||
|
for (std::size_t i = 0; i < max_count && it != errors_.rend(); ++i, ++it) {
|
||||||
|
result.push_back(*it);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::ClearErrors()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
errors_.clear();
|
||||||
|
total_error_count_ = 0;
|
||||||
|
critical_error_count_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::SetFileLoggingEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
file_logging_enabled_ = enabled;
|
||||||
|
if (!enabled && log_file_ && log_file_->is_open()) {
|
||||||
|
log_file_->flush();
|
||||||
|
log_file_->close();
|
||||||
|
log_file_.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
ErrorHandler::GetLogFilePath() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
return log_file_path_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::write_to_log(const ErrorRecord &record)
|
||||||
|
{
|
||||||
|
// Must be called with mtx_ held
|
||||||
|
if (log_file_path_.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
ensure_log_file();
|
||||||
|
if (!log_file_ || !log_file_->is_open())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Format: [timestamp] [SEVERITY] component (context): message
|
||||||
|
std::string timestamp = format_timestamp(record.timestamp_ns);
|
||||||
|
std::string severity = severity_to_string(record.severity);
|
||||||
|
|
||||||
|
*log_file_ << "[" << timestamp << "] [" << severity << "] " << record.component;
|
||||||
|
if (!record.context.empty()) {
|
||||||
|
*log_file_ << " (" << record.context << ")";
|
||||||
|
}
|
||||||
|
*log_file_ << ": " << record.message << "\n";
|
||||||
|
log_file_->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ErrorHandler::ensure_log_file()
|
||||||
|
{
|
||||||
|
// Must be called with mtx_ held
|
||||||
|
if (log_file_ && log_file_->is_open())
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (log_file_path_.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
log_file_ = std::make_unique<std::ofstream>(log_file_path_,
|
||||||
|
std::ios::app | std::ios::out);
|
||||||
|
if (!log_file_->is_open()) {
|
||||||
|
log_file_.reset();
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
log_file_.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
ErrorHandler::format_timestamp(std::uint64_t timestamp_ns) const
|
||||||
|
{
|
||||||
|
// Convert nanoseconds to time_t (seconds)
|
||||||
|
std::time_t seconds = static_cast<std::time_t>(timestamp_ns / 1000000000ULL);
|
||||||
|
std::uint64_t nanos = timestamp_ns % 1000000000ULL;
|
||||||
|
|
||||||
|
std::tm tm_buf{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
localtime_s(&tm_buf, &seconds);
|
||||||
|
#else
|
||||||
|
localtime_r(&seconds, &tm_buf);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::put_time(&tm_buf, "%Y-%m-%d %H:%M:%S");
|
||||||
|
oss << "." << std::setfill('0') << std::setw(3) << (nanos / 1000000ULL);
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
ErrorHandler::severity_to_string(ErrorSeverity severity) const
|
||||||
|
{
|
||||||
|
switch (severity) {
|
||||||
|
case ErrorSeverity::Info:
|
||||||
|
return "INFO";
|
||||||
|
case ErrorSeverity::Warning:
|
||||||
|
return "WARNING";
|
||||||
|
case ErrorSeverity::Error:
|
||||||
|
return "ERROR";
|
||||||
|
case ErrorSeverity::Critical:
|
||||||
|
return "CRITICAL";
|
||||||
|
default:
|
||||||
|
return "UNKNOWN";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::uint64_t
|
||||||
|
ErrorHandler::now_ns()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
return duration_cast<nanoseconds>(system_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
} // namespace kte
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
// ErrorHandler.h - Centralized error handling and logging for kte
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <deque>
|
||||||
|
#include <mutex>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <fstream>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
enum class ErrorSeverity {
|
||||||
|
Info, // Informational messages
|
||||||
|
Warning, // Non-critical issues
|
||||||
|
Error, // Errors that affect functionality but allow continuation
|
||||||
|
Critical // Critical errors that may cause data loss or crashes
|
||||||
|
};
|
||||||
|
|
||||||
|
// Centralized error handler with logging and in-memory error tracking
|
||||||
|
class ErrorHandler {
|
||||||
|
public:
|
||||||
|
struct ErrorRecord {
|
||||||
|
std::uint64_t timestamp_ns{0};
|
||||||
|
ErrorSeverity severity{ErrorSeverity::Error};
|
||||||
|
std::string component; // e.g., "SwapManager", "Buffer", "main"
|
||||||
|
std::string message;
|
||||||
|
std::string context; // e.g., filename, buffer name, operation
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the global ErrorHandler instance
|
||||||
|
static ErrorHandler &Instance();
|
||||||
|
|
||||||
|
// Report an error with severity, component, message, and optional context
|
||||||
|
void Report(ErrorSeverity severity, const std::string &component,
|
||||||
|
const std::string &message, const std::string &context = "");
|
||||||
|
|
||||||
|
// Convenience methods for common severity levels
|
||||||
|
void Info(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context = "");
|
||||||
|
|
||||||
|
void Warning(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context = "");
|
||||||
|
|
||||||
|
void Error(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context = "");
|
||||||
|
|
||||||
|
void Critical(const std::string &component, const std::string &message,
|
||||||
|
const std::string &context = "");
|
||||||
|
|
||||||
|
// Query error state (thread-safe)
|
||||||
|
bool HasErrors() const;
|
||||||
|
|
||||||
|
bool HasCriticalErrors() const;
|
||||||
|
|
||||||
|
std::string GetLastError() const;
|
||||||
|
|
||||||
|
std::size_t GetErrorCount() const;
|
||||||
|
|
||||||
|
std::size_t GetErrorCount(ErrorSeverity severity) const;
|
||||||
|
|
||||||
|
// Get recent errors (up to max_count, most recent first)
|
||||||
|
std::vector<ErrorRecord> GetRecentErrors(std::size_t max_count = 10) const;
|
||||||
|
|
||||||
|
// Clear in-memory error history (does not affect log file)
|
||||||
|
void ClearErrors();
|
||||||
|
|
||||||
|
// Enable/disable file logging (enabled by default)
|
||||||
|
void SetFileLoggingEnabled(bool enabled);
|
||||||
|
|
||||||
|
// Get the path to the error log file
|
||||||
|
std::string GetLogFilePath() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
ErrorHandler();
|
||||||
|
|
||||||
|
~ErrorHandler();
|
||||||
|
|
||||||
|
// Non-copyable, non-movable
|
||||||
|
ErrorHandler(const ErrorHandler &) = delete;
|
||||||
|
|
||||||
|
ErrorHandler &operator=(const ErrorHandler &) = delete;
|
||||||
|
|
||||||
|
ErrorHandler(ErrorHandler &&) = delete;
|
||||||
|
|
||||||
|
ErrorHandler &operator=(ErrorHandler &&) = delete;
|
||||||
|
|
||||||
|
void write_to_log(const ErrorRecord &record);
|
||||||
|
|
||||||
|
void ensure_log_file();
|
||||||
|
|
||||||
|
std::string format_timestamp(std::uint64_t timestamp_ns) const;
|
||||||
|
|
||||||
|
std::string severity_to_string(ErrorSeverity severity) const;
|
||||||
|
|
||||||
|
static std::uint64_t now_ns();
|
||||||
|
|
||||||
|
mutable std::mutex mtx_;
|
||||||
|
std::deque<ErrorRecord> errors_; // bounded to max 100 entries
|
||||||
|
std::size_t total_error_count_{0};
|
||||||
|
std::size_t critical_error_count_{0};
|
||||||
|
bool file_logging_enabled_{true};
|
||||||
|
std::string log_file_path_;
|
||||||
|
std::unique_ptr<std::ofstream> log_file_;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// ErrorRecovery.cc - Error recovery mechanisms implementation
|
||||||
|
#include "ErrorRecovery.h"
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
CircuitBreaker::CircuitBreaker(const Config &cfg)
|
||||||
|
: config_(cfg), state_(State::Closed), failure_count_(0), success_count_(0),
|
||||||
|
last_failure_time_(std::chrono::steady_clock::time_point::min()),
|
||||||
|
state_change_time_(std::chrono::steady_clock::now()) {}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
CircuitBreaker::AllowRequest()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
switch (state_) {
|
||||||
|
case State::Closed:
|
||||||
|
// Normal operation, allow all requests
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case State::Open: {
|
||||||
|
// Check if timeout has elapsed to transition to HalfOpen
|
||||||
|
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
now - state_change_time_
|
||||||
|
);
|
||||||
|
if (elapsed >= config_.open_timeout) {
|
||||||
|
TransitionTo(State::HalfOpen);
|
||||||
|
return true; // Allow one request to test recovery
|
||||||
|
}
|
||||||
|
return false; // Circuit is open, reject request
|
||||||
|
}
|
||||||
|
|
||||||
|
case State::HalfOpen:
|
||||||
|
// Allow limited requests to test recovery
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
CircuitBreaker::RecordSuccess()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
|
switch (state_) {
|
||||||
|
case State::Closed:
|
||||||
|
// Reset failure count on success in normal operation
|
||||||
|
failure_count_ = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::HalfOpen:
|
||||||
|
++success_count_;
|
||||||
|
if (success_count_ >= config_.success_threshold) {
|
||||||
|
// Enough successes, close the circuit
|
||||||
|
TransitionTo(State::Closed);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::Open:
|
||||||
|
// Shouldn't happen (requests rejected), but handle gracefully
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
CircuitBreaker::RecordFailure()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
|
// The window is measured from the previous failure, so check it before
|
||||||
|
// recording this one.
|
||||||
|
const bool window_expired = IsWindowExpired();
|
||||||
|
last_failure_time_ = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
switch (state_) {
|
||||||
|
case State::Closed:
|
||||||
|
// Check if we need to reset the failure count (window expired)
|
||||||
|
if (window_expired) {
|
||||||
|
failure_count_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
++failure_count_;
|
||||||
|
if (failure_count_ >= config_.failure_threshold) {
|
||||||
|
// Too many failures, open the circuit
|
||||||
|
TransitionTo(State::Open);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::HalfOpen:
|
||||||
|
// Failure during recovery test, reopen the circuit
|
||||||
|
TransitionTo(State::Open);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::Open:
|
||||||
|
// Already open, just track the failure
|
||||||
|
++failure_count_;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
CircuitBreaker::Reset()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
TransitionTo(State::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
CircuitBreaker::TransitionTo(State new_state)
|
||||||
|
{
|
||||||
|
if (state_ == new_state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state_ = new_state;
|
||||||
|
state_change_time_ = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
switch (new_state) {
|
||||||
|
case State::Closed:
|
||||||
|
failure_count_ = 0;
|
||||||
|
success_count_ = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::Open:
|
||||||
|
success_count_ = 0;
|
||||||
|
// Keep failure_count_ for diagnostics
|
||||||
|
break;
|
||||||
|
|
||||||
|
case State::HalfOpen:
|
||||||
|
success_count_ = 0;
|
||||||
|
// Keep failure_count_ for diagnostics
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
CircuitBreaker::IsWindowExpired() const
|
||||||
|
{
|
||||||
|
if (failure_count_ == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
now - last_failure_time_
|
||||||
|
);
|
||||||
|
|
||||||
|
return elapsed >= config_.window;
|
||||||
|
}
|
||||||
|
} // namespace kte
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
// ErrorRecovery.h - Error recovery mechanisms for kte
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <mutex>
|
||||||
|
#include <cerrno>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
// Classify errno values as transient (retryable) or permanent
|
||||||
|
inline bool
|
||||||
|
IsTransientError(int err)
|
||||||
|
{
|
||||||
|
switch (err) {
|
||||||
|
case EAGAIN:
|
||||||
|
#if EAGAIN != EWOULDBLOCK
|
||||||
|
case EWOULDBLOCK:
|
||||||
|
#endif
|
||||||
|
case EBUSY:
|
||||||
|
case EIO: // I/O error (may be transient on network filesystems)
|
||||||
|
case ETIMEDOUT:
|
||||||
|
case ENOSPC: // Disk full (may become available)
|
||||||
|
case EDQUOT: // Quota exceeded (may become available)
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// RetryPolicy defines retry behavior for transient failures
|
||||||
|
struct RetryPolicy {
|
||||||
|
std::size_t max_attempts{3}; // Maximum retry attempts
|
||||||
|
std::chrono::milliseconds initial_delay{100}; // Initial delay before first retry
|
||||||
|
double backoff_multiplier{2.0}; // Exponential backoff multiplier
|
||||||
|
std::chrono::milliseconds max_delay{5000}; // Maximum delay between retries
|
||||||
|
|
||||||
|
// Default policy: 3 attempts, 100ms initial, 2x backoff, 5s max
|
||||||
|
static RetryPolicy Default()
|
||||||
|
{
|
||||||
|
return RetryPolicy{};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Aggressive policy for critical operations: more attempts, faster retries
|
||||||
|
static RetryPolicy Aggressive()
|
||||||
|
{
|
||||||
|
return RetryPolicy{5, std::chrono::milliseconds(50), 1.5, std::chrono::milliseconds(2000)};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retry a function with exponential backoff for transient errors
|
||||||
|
// Returns true on success, false on permanent failure or exhausted retries
|
||||||
|
// The function `fn` should return true on success, false on failure, and set errno on failure
|
||||||
|
template<typename Func>
|
||||||
|
bool
|
||||||
|
RetryOnTransientError(Func fn, const RetryPolicy &policy, std::string &err)
|
||||||
|
{
|
||||||
|
std::size_t attempt = 0;
|
||||||
|
std::chrono::milliseconds delay = policy.initial_delay;
|
||||||
|
|
||||||
|
while (attempt < policy.max_attempts) {
|
||||||
|
++attempt;
|
||||||
|
errno = 0;
|
||||||
|
if (fn()) {
|
||||||
|
return true; // Success
|
||||||
|
}
|
||||||
|
|
||||||
|
int saved_errno = errno;
|
||||||
|
if (!IsTransientError(saved_errno)) {
|
||||||
|
// Permanent error, don't retry
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt >= policy.max_attempts) {
|
||||||
|
// Exhausted retries
|
||||||
|
err += " (exhausted " + std::to_string(policy.max_attempts) + " retry attempts)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep before retry
|
||||||
|
std::this_thread::sleep_for(delay);
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
delay = std::chrono::milliseconds(
|
||||||
|
static_cast<long long>(delay.count() * policy.backoff_multiplier)
|
||||||
|
);
|
||||||
|
if (delay > policy.max_delay) {
|
||||||
|
delay = policy.max_delay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// CircuitBreaker prevents repeated attempts to failing operations
|
||||||
|
// States: Closed (normal), Open (failing, reject immediately), HalfOpen (testing recovery)
|
||||||
|
class CircuitBreaker {
|
||||||
|
public:
|
||||||
|
enum class State {
|
||||||
|
Closed, // Normal operation, allow all requests
|
||||||
|
Open, // Failing, reject requests immediately
|
||||||
|
HalfOpen // Testing recovery, allow limited requests
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
std::size_t failure_threshold; // Failures before opening circuit
|
||||||
|
std::chrono::seconds open_timeout; // Time before attempting recovery (Open → HalfOpen)
|
||||||
|
std::size_t success_threshold; // Successes in HalfOpen before closing
|
||||||
|
std::chrono::seconds window; // Time window for counting failures
|
||||||
|
|
||||||
|
Config()
|
||||||
|
: failure_threshold(5), open_timeout(30), success_threshold(2), window(60) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
explicit CircuitBreaker(const Config &cfg = Config());
|
||||||
|
|
||||||
|
|
||||||
|
// Check if operation is allowed (returns false if circuit is Open)
|
||||||
|
bool AllowRequest();
|
||||||
|
|
||||||
|
// Record successful operation
|
||||||
|
void RecordSuccess();
|
||||||
|
|
||||||
|
// Record failed operation
|
||||||
|
void RecordFailure();
|
||||||
|
|
||||||
|
// Get current state
|
||||||
|
State GetState() const
|
||||||
|
{
|
||||||
|
return state_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Get failure count in current window
|
||||||
|
std::size_t GetFailureCount() const
|
||||||
|
{
|
||||||
|
return failure_count_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Reset circuit to Closed state (for testing or manual intervention)
|
||||||
|
void Reset();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void TransitionTo(State new_state);
|
||||||
|
|
||||||
|
bool IsWindowExpired() const;
|
||||||
|
|
||||||
|
Config config_;
|
||||||
|
State state_;
|
||||||
|
std::size_t failure_count_;
|
||||||
|
std::size_t success_count_;
|
||||||
|
std::chrono::steady_clock::time_point last_failure_time_;
|
||||||
|
std::chrono::steady_clock::time_point state_change_time_;
|
||||||
|
mutable std::mutex mtx_;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
+151
-12
@@ -1,11 +1,49 @@
|
|||||||
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
#include "GUIConfig.h"
|
#include "GUIConfig.h"
|
||||||
|
|
||||||
|
// toml++ for TOML config parsing
|
||||||
|
#if defined(__clang__)
|
||||||
|
# pragma clang diagnostic push
|
||||||
|
# pragma clang diagnostic ignored "-Weverything"
|
||||||
|
#elif defined(__GNUC__)
|
||||||
|
# pragma GCC diagnostic push
|
||||||
|
# pragma GCC diagnostic ignored "-Wall"
|
||||||
|
# pragma GCC diagnostic ignored "-Wextra"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "ext/tomlplusplus/toml.hpp"
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(__clang__)
|
||||||
|
# pragma clang diagnostic pop
|
||||||
|
#elif defined(__GNUC__)
|
||||||
|
# pragma GCC diagnostic pop
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
// Reject values that would overflow later arithmetic (columns * font size)
|
||||||
|
// or that are not usable: "size = inf" is valid TOML.
|
||||||
|
static bool
|
||||||
|
valid_dimension(long long v)
|
||||||
|
{
|
||||||
|
return v > 0 && v <= 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
valid_font_size(double v)
|
||||||
|
{
|
||||||
|
return std::isfinite(v) && v >= 4.0 && v <= 200.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
static void
|
static void
|
||||||
trim(std::string &s)
|
trim(std::string &s)
|
||||||
@@ -19,37 +57,124 @@ trim(std::string &s)
|
|||||||
|
|
||||||
|
|
||||||
static std::string
|
static std::string
|
||||||
default_config_path()
|
config_dir()
|
||||||
{
|
{
|
||||||
const char *home = std::getenv("HOME");
|
const char *home = std::getenv("HOME");
|
||||||
if (!home || !*home)
|
if (!home || !*home)
|
||||||
return {};
|
return {};
|
||||||
std::string path(home);
|
return std::string(home) + "/.config/kte";
|
||||||
path += "/.config/kte/kge.ini";
|
|
||||||
return path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
GUIConfig
|
GUIConfig
|
||||||
GUIConfig::Load()
|
GUIConfig::Load()
|
||||||
{
|
{
|
||||||
GUIConfig cfg; // defaults already set
|
GUIConfig cfg;
|
||||||
const std::string path = default_config_path();
|
std::string dir = config_dir();
|
||||||
|
if (dir.empty())
|
||||||
|
return cfg;
|
||||||
|
|
||||||
if (!path.empty()) {
|
// Try TOML first
|
||||||
cfg.LoadFromFile(path);
|
std::string toml_path = dir + "/kge.toml";
|
||||||
|
if (cfg.LoadFromTOML(toml_path))
|
||||||
|
return cfg;
|
||||||
|
|
||||||
|
// Fall back to legacy INI
|
||||||
|
std::string ini_path = dir + "/kge.ini";
|
||||||
|
if (cfg.LoadFromINI(ini_path)) {
|
||||||
|
std::cerr << "kge: loaded legacy kge.ini; consider migrating to kge.toml\n";
|
||||||
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
GUIConfig::LoadFromFile(const std::string &path)
|
GUIConfig::LoadFromTOML(const std::string &path)
|
||||||
|
{
|
||||||
|
if (!std::filesystem::exists(path))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
toml::table tbl;
|
||||||
|
try {
|
||||||
|
tbl = toml::parse_file(path);
|
||||||
|
} catch (const toml::parse_error &err) {
|
||||||
|
std::cerr << "kge: TOML parse error in " << path << ": " << err.what() << "\n";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [window]
|
||||||
|
if (auto win = tbl["window"].as_table()) {
|
||||||
|
if (auto v = (*win)["fullscreen"].value<bool>())
|
||||||
|
fullscreen = *v;
|
||||||
|
if (auto v = (*win)["columns"].value<int64_t>()) {
|
||||||
|
if (valid_dimension(*v)) columns = static_cast<int>(*v);
|
||||||
|
}
|
||||||
|
if (auto v = (*win)["rows"].value<int64_t>()) {
|
||||||
|
if (valid_dimension(*v)) rows = static_cast<int>(*v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [font]
|
||||||
|
bool explicit_code_font = false;
|
||||||
|
bool explicit_writing_font = false;
|
||||||
|
if (auto sec = tbl["font"].as_table()) {
|
||||||
|
if (auto v = (*sec)["name"].value<std::string>())
|
||||||
|
font = *v;
|
||||||
|
if (auto v = (*sec)["size"].value<double>()) {
|
||||||
|
if (valid_font_size(*v)) font_size = static_cast<float>(*v);
|
||||||
|
}
|
||||||
|
if (auto v = (*sec)["code"].value<std::string>()) {
|
||||||
|
code_font = *v;
|
||||||
|
explicit_code_font = true;
|
||||||
|
}
|
||||||
|
if (auto v = (*sec)["writing"].value<std::string>()) {
|
||||||
|
writing_font = *v;
|
||||||
|
explicit_writing_font = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [appearance]
|
||||||
|
if (auto sec = tbl["appearance"].as_table()) {
|
||||||
|
if (auto v = (*sec)["theme"].value<std::string>())
|
||||||
|
theme = *v;
|
||||||
|
if (auto v = (*sec)["background"].value<std::string>()) {
|
||||||
|
std::string bg = *v;
|
||||||
|
std::transform(bg.begin(), bg.end(), bg.begin(), [](unsigned char c) {
|
||||||
|
return (char) std::tolower(c);
|
||||||
|
});
|
||||||
|
if (bg == "light" || bg == "dark")
|
||||||
|
background = bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [editor]
|
||||||
|
if (auto sec = tbl["editor"].as_table()) {
|
||||||
|
if (auto v = (*sec)["syntax"].value<bool>())
|
||||||
|
syntax = *v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default code_font to the main font if not explicitly set
|
||||||
|
if (!explicit_code_font)
|
||||||
|
code_font = font;
|
||||||
|
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
|
||||||
|
writing_font = font;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
GUIConfig::LoadFromINI(const std::string &path)
|
||||||
{
|
{
|
||||||
std::ifstream in(path);
|
std::ifstream in(path);
|
||||||
if (!in.good())
|
if (!in.good())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
bool explicit_code_font = false;
|
||||||
|
bool explicit_writing_font = false;
|
||||||
|
|
||||||
std::string line;
|
std::string line;
|
||||||
while (std::getline(in, line)) {
|
while (std::getline(in, line)) {
|
||||||
// Remove comments starting with '#' or ';'
|
// Remove comments starting with '#' or ';'
|
||||||
@@ -85,25 +210,31 @@ GUIConfig::LoadFromFile(const std::string &path)
|
|||||||
try {
|
try {
|
||||||
v = std::stoi(val);
|
v = std::stoi(val);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
if (v > 0)
|
if (valid_dimension(v))
|
||||||
columns = v;
|
columns = v;
|
||||||
} else if (key == "rows") {
|
} else if (key == "rows") {
|
||||||
int v = rows;
|
int v = rows;
|
||||||
try {
|
try {
|
||||||
v = std::stoi(val);
|
v = std::stoi(val);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
if (v > 0)
|
if (valid_dimension(v))
|
||||||
rows = v;
|
rows = v;
|
||||||
} else if (key == "font_size" || key == "fontsize") {
|
} else if (key == "font_size" || key == "fontsize") {
|
||||||
float v = font_size;
|
float v = font_size;
|
||||||
try {
|
try {
|
||||||
v = std::stof(val);
|
v = std::stof(val);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
if (v > 0.0f) {
|
if (valid_font_size(v)) {
|
||||||
font_size = v;
|
font_size = v;
|
||||||
}
|
}
|
||||||
} else if (key == "font") {
|
} else if (key == "font") {
|
||||||
font = val;
|
font = val;
|
||||||
|
} else if (key == "code_font") {
|
||||||
|
code_font = val;
|
||||||
|
explicit_code_font = true;
|
||||||
|
} else if (key == "writing_font") {
|
||||||
|
writing_font = val;
|
||||||
|
explicit_writing_font = true;
|
||||||
} else if (key == "theme") {
|
} else if (key == "theme") {
|
||||||
theme = val;
|
theme = val;
|
||||||
} else if (key == "background" || key == "bg") {
|
} else if (key == "background" || key == "bg") {
|
||||||
@@ -126,5 +257,13 @@ GUIConfig::LoadFromFile(const std::string &path)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If code_font was not explicitly set, default it to the main font
|
||||||
|
// so that the edit-mode font switcher doesn't immediately switch away
|
||||||
|
// from the font loaded during Init.
|
||||||
|
if (!explicit_code_font)
|
||||||
|
code_font = font;
|
||||||
|
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
|
||||||
|
writing_font = font;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
+14
-6
@@ -1,5 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* GUIConfig - loads simple GUI configuration from $HOME/.config/kte/kge.ini
|
* GUIConfig - loads GUI configuration from $HOME/.config/kte/kge.toml
|
||||||
|
*
|
||||||
|
* Falls back to legacy kge.ini if no TOML config is found.
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -22,12 +24,18 @@ public:
|
|||||||
std::string background = "dark";
|
std::string background = "dark";
|
||||||
|
|
||||||
// Default syntax highlighting state for GUI (kge): on/off
|
// Default syntax highlighting state for GUI (kge): on/off
|
||||||
// Accepts: on/off/true/false/yes/no/1/0 in the ini file.
|
bool syntax = true;
|
||||||
bool syntax = true; // default: enabled
|
|
||||||
|
|
||||||
// Load from default path: $HOME/.config/kte/kge.ini
|
// Per-mode font defaults
|
||||||
|
std::string code_font = "default";
|
||||||
|
std::string writing_font = "crimsonpro";
|
||||||
|
|
||||||
|
// Load from default paths: try kge.toml first, fall back to kge.ini
|
||||||
static GUIConfig Load();
|
static GUIConfig Load();
|
||||||
|
|
||||||
// Load from explicit path. Returns true if file existed and was parsed.
|
// Load from explicit TOML path. Returns true if file existed and was parsed.
|
||||||
bool LoadFromFile(const std::string &path);
|
bool LoadFromTOML(const std::string &path);
|
||||||
|
|
||||||
|
// Load from explicit INI path (legacy). Returns true if file existed and was parsed.
|
||||||
|
bool LoadFromINI(const std::string &path);
|
||||||
};
|
};
|
||||||
+187
-311
@@ -8,296 +8,6 @@
|
|||||||
|
|
||||||
#include "Highlight.h"
|
#include "Highlight.h"
|
||||||
|
|
||||||
// Cross-frontend theme change request hook: declared here, defined in Command.cc
|
|
||||||
namespace kte {
|
|
||||||
extern bool gThemeChangePending;
|
|
||||||
extern std::string gThemeChangeRequest; // raw user-provided name
|
|
||||||
// Qt GUI: cross-frontend font change hooks and current font state
|
|
||||||
extern bool gFontChangePending;
|
|
||||||
extern std::string gFontFamilyRequest; // requested family (case-insensitive)
|
|
||||||
extern float gFontSizeRequest; // <= 0 means keep size
|
|
||||||
extern std::string gCurrentFontFamily; // last applied family (Qt)
|
|
||||||
extern float gCurrentFontSize; // last applied size (Qt)
|
|
||||||
// Qt GUI: request to show a visual font dialog (set by command handler)
|
|
||||||
extern bool gFontDialogRequested;
|
|
||||||
}
|
|
||||||
|
|
||||||
#if defined(KTE_USE_QT)
|
|
||||||
// Qt build: avoid hard dependency on ImGui headers/types.
|
|
||||||
// Provide a lightweight color vector matching ImVec4 fields used by renderers.
|
|
||||||
struct KteColor {
|
|
||||||
float x{0}, y{0}, z{0}, w{1};
|
|
||||||
};
|
|
||||||
|
|
||||||
static inline KteColor
|
|
||||||
RGBA(unsigned int rgb, float a = 1.0f)
|
|
||||||
{
|
|
||||||
const float r = static_cast<float>((rgb >> 16) & 0xFF) / 255.0f;
|
|
||||||
const float g = static_cast<float>((rgb >> 8) & 0xFF) / 255.0f;
|
|
||||||
const float b = static_cast<float>(rgb & 0xFF) / 255.0f;
|
|
||||||
return {r, g, b, a};
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
// Background mode selection for light/dark palettes
|
|
||||||
enum class BackgroundMode { Light, Dark };
|
|
||||||
|
|
||||||
// Global background mode; default to Dark to match prior defaults
|
|
||||||
static inline auto gBackgroundMode = BackgroundMode::Dark;
|
|
||||||
|
|
||||||
|
|
||||||
static inline void
|
|
||||||
SetBackgroundMode(const BackgroundMode m)
|
|
||||||
{
|
|
||||||
gBackgroundMode = m;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline BackgroundMode
|
|
||||||
GetBackgroundMode()
|
|
||||||
{
|
|
||||||
return gBackgroundMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Minimal GUI palette for Qt builds. This mirrors the defaults used in the ImGui
|
|
||||||
// frontend (Nord-ish) and switches for light/dark background mode.
|
|
||||||
struct Palette {
|
|
||||||
KteColor bg; // editor background
|
|
||||||
KteColor fg; // default foreground text
|
|
||||||
KteColor sel_bg; // selection background
|
|
||||||
KteColor cur_bg; // cursor cell background
|
|
||||||
KteColor status_bg; // status bar background
|
|
||||||
KteColor status_fg; // status bar foreground
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional theme override (Qt): when set, GetPalette() will return this instead
|
|
||||||
// of the generic light/dark defaults. This allows honoring theme names in kge.ini.
|
|
||||||
static inline bool gPaletteOverride = false;
|
|
||||||
static inline Palette gOverridePalette{};
|
|
||||||
static inline std::string gOverrideThemeName = ""; // lowercased name
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
GetPalette()
|
|
||||||
{
|
|
||||||
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
|
|
||||||
if (gPaletteOverride) {
|
|
||||||
return gOverridePalette;
|
|
||||||
}
|
|
||||||
if (dark) {
|
|
||||||
return Palette{
|
|
||||||
/*bg*/ RGBA(0x1C1C1E),
|
|
||||||
/*fg*/ RGBA(0xDCDCDC),
|
|
||||||
/*sel_bg*/ RGBA(0xC8C800, 0.35f),
|
|
||||||
/*cur_bg*/ RGBA(0xC8C8FF, 0.50f),
|
|
||||||
/*status_bg*/ RGBA(0x28282C),
|
|
||||||
/*status_fg*/ RGBA(0xB4B48C)
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Light palette tuned for readability
|
|
||||||
return Palette{
|
|
||||||
/*bg*/ RGBA(0xFBFBFC),
|
|
||||||
/*fg*/ RGBA(0x30343A),
|
|
||||||
/*sel_bg*/ RGBA(0x268BD2, 0.22f),
|
|
||||||
/*cur_bg*/ RGBA(0x000000, 0.15f),
|
|
||||||
/*status_bg*/ RGBA(0xE6E8EA),
|
|
||||||
/*status_fg*/ RGBA(0x50555A)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// A few named palettes to provide visible differences between themes in Qt.
|
|
||||||
// These are approximate and palette-based (no widget style changes like ImGuiStyle).
|
|
||||||
static inline Palette
|
|
||||||
NordDark()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0x2E3440), /*fg*/RGBA(0xD8DEE9), /*sel_bg*/RGBA(0x88C0D0, 0.25f),
|
|
||||||
/*cur_bg*/RGBA(0x81A1C1, 0.35f), /*status_bg*/RGBA(0x3B4252), /*status_fg*/RGBA(0xE5E9F0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
NordLight()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0xECEFF4), /*fg*/RGBA(0x2E3440), /*sel_bg*/RGBA(0x5E81AC, 0.22f),
|
|
||||||
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0xE5E9F0), /*status_fg*/RGBA(0x4C566A)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
SolarizedDark()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0x002b36), /*fg*/RGBA(0x93a1a1), /*sel_bg*/RGBA(0x586e75, 0.40f),
|
|
||||||
/*cur_bg*/RGBA(0x657b83, 0.35f), /*status_bg*/RGBA(0x073642), /*status_fg*/RGBA(0xeee8d5)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
SolarizedLight()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0xfdf6e3), /*fg*/RGBA(0x586e75), /*sel_bg*/RGBA(0x268bd2, 0.25f),
|
|
||||||
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xeee8d5), /*status_fg*/RGBA(0x657b83)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
GruvboxDark()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0x282828), /*fg*/RGBA(0xebdbb2), /*sel_bg*/RGBA(0xd79921, 0.35f),
|
|
||||||
/*cur_bg*/RGBA(0x458588, 0.40f), /*status_bg*/RGBA(0x3c3836), /*status_fg*/RGBA(0xd5c4a1)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
GruvboxLight()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0xfbf1c7), /*fg*/RGBA(0x3c3836), /*sel_bg*/RGBA(0x076678, 0.22f),
|
|
||||||
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xebdbb2), /*status_fg*/RGBA(0x504945)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static inline Palette
|
|
||||||
EInk()
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
/*bg*/RGBA(0xffffff), /*fg*/RGBA(0x000000), /*sel_bg*/RGBA(0x000000, 0.10f),
|
|
||||||
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0x000000), /*status_fg*/RGBA(0xffffff)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Apply a Qt theme by name. Returns true on success. Name matching is case-insensitive and
|
|
||||||
// supports common aliases (e.g., "solarized-light" or "solarized light"). If the name conveys
|
|
||||||
// a background (light/dark), BackgroundMode is updated to keep SyntaxInk consistent.
|
|
||||||
static inline bool
|
|
||||||
ApplyQtThemeByName(std::string name)
|
|
||||||
{
|
|
||||||
// normalize
|
|
||||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) {
|
|
||||||
return (char) std::tolower(c);
|
|
||||||
});
|
|
||||||
auto has = [&](const std::string &s) {
|
|
||||||
return name.find(s) != std::string::npos;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (name.empty() || name == "default" || name == "nord") {
|
|
||||||
// Choose variant by current background mode
|
|
||||||
if (GetBackgroundMode() == BackgroundMode::Dark) {
|
|
||||||
gOverridePalette = NordDark();
|
|
||||||
} else {
|
|
||||||
gOverridePalette = NordLight();
|
|
||||||
}
|
|
||||||
gPaletteOverride = true;
|
|
||||||
gOverrideThemeName = "nord";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (has("solarized")) {
|
|
||||||
if (has("light")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Light);
|
|
||||||
gOverridePalette = SolarizedLight();
|
|
||||||
} else if (has("dark")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Dark);
|
|
||||||
gOverridePalette = SolarizedDark();
|
|
||||||
} else {
|
|
||||||
// pick from current background
|
|
||||||
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
|
|
||||||
? SolarizedDark()
|
|
||||||
: SolarizedLight();
|
|
||||||
}
|
|
||||||
gPaletteOverride = true;
|
|
||||||
gOverrideThemeName = "solarized";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (has("gruvbox")) {
|
|
||||||
if (has("light")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Light);
|
|
||||||
gOverridePalette = GruvboxLight();
|
|
||||||
} else if (has("dark")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Dark);
|
|
||||||
gOverridePalette = GruvboxDark();
|
|
||||||
} else {
|
|
||||||
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
|
|
||||||
? GruvboxDark()
|
|
||||||
: GruvboxLight();
|
|
||||||
}
|
|
||||||
gPaletteOverride = true;
|
|
||||||
gOverrideThemeName = "gruvbox";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (has("eink") || has("e-ink") || has("paper")) {
|
|
||||||
SetBackgroundMode(BackgroundMode::Light);
|
|
||||||
gOverridePalette = EInk();
|
|
||||||
gPaletteOverride = true;
|
|
||||||
gOverrideThemeName = "eink";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown -> clear override so default light/dark applies; return false.
|
|
||||||
gPaletteOverride = false;
|
|
||||||
gOverrideThemeName.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Minimal SyntaxInk mapping for Qt builds, returning KteColor
|
|
||||||
[[maybe_unused]] static KteColor
|
|
||||||
SyntaxInk(const TokenKind k)
|
|
||||||
{
|
|
||||||
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
|
|
||||||
const KteColor def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
|
|
||||||
switch (k) {
|
|
||||||
case TokenKind::Keyword:
|
|
||||||
return dark ? RGBA(0x81A1C1) : RGBA(0x5E81AC);
|
|
||||||
case TokenKind::Type:
|
|
||||||
return dark ? RGBA(0x8FBCBB) : RGBA(0x4C566A);
|
|
||||||
case TokenKind::String:
|
|
||||||
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
|
|
||||||
case TokenKind::Char:
|
|
||||||
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
|
|
||||||
case TokenKind::Comment:
|
|
||||||
return dark ? RGBA(0x616E88) : RGBA(0x7A869A);
|
|
||||||
case TokenKind::Number:
|
|
||||||
return dark ? RGBA(0xEBCB8B) : RGBA(0xB58900);
|
|
||||||
case TokenKind::Preproc:
|
|
||||||
return dark ? RGBA(0xD08770) : RGBA(0xAF3A03);
|
|
||||||
case TokenKind::Constant:
|
|
||||||
return dark ? RGBA(0xB48EAD) : RGBA(0x7B4B7F);
|
|
||||||
case TokenKind::Function:
|
|
||||||
return dark ? RGBA(0x88C0D0) : RGBA(0x3465A4);
|
|
||||||
case TokenKind::Operator:
|
|
||||||
return dark ? RGBA(0x2E3440) : RGBA(0x2E3440);
|
|
||||||
case TokenKind::Punctuation:
|
|
||||||
return dark ? RGBA(0xECEFF4) : RGBA(0x2E3440);
|
|
||||||
case TokenKind::Identifier:
|
|
||||||
return def;
|
|
||||||
case TokenKind::Whitespace:
|
|
||||||
return def;
|
|
||||||
case TokenKind::Error:
|
|
||||||
return dark ? RGBA(0xBF616A) : RGBA(0xCC0000);
|
|
||||||
case TokenKind::Default: default:
|
|
||||||
return def;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // namespace kte
|
|
||||||
|
|
||||||
#else
|
|
||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -312,7 +22,7 @@ namespace kte {
|
|||||||
enum class BackgroundMode { Light, Dark };
|
enum class BackgroundMode { Light, Dark };
|
||||||
|
|
||||||
// Global background mode; default to Dark to match prior defaults
|
// Global background mode; default to Dark to match prior defaults
|
||||||
static inline auto gBackgroundMode = BackgroundMode::Dark;
|
inline auto gBackgroundMode = BackgroundMode::Dark;
|
||||||
|
|
||||||
// Basic theme identifier (kept minimal; some ids are aliases)
|
// Basic theme identifier (kept minimal; some ids are aliases)
|
||||||
enum class ThemeId {
|
enum class ThemeId {
|
||||||
@@ -330,11 +40,13 @@ enum class ThemeId {
|
|||||||
Amber = 10,
|
Amber = 10,
|
||||||
WeylandYutani = 11,
|
WeylandYutani = 11,
|
||||||
Orbital = 12,
|
Orbital = 12,
|
||||||
|
Tufte = 13,
|
||||||
|
Leuchtturm = 14,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Current theme tracking
|
// Current theme tracking
|
||||||
static inline auto gCurrentTheme = ThemeId::Nord;
|
inline auto gCurrentTheme = ThemeId::Nord;
|
||||||
static inline std::size_t gCurrentThemeIndex = 6; // Nord index
|
inline std::size_t gCurrentThemeIndex = 7; // Nord index
|
||||||
|
|
||||||
// Forward declarations for helpers used below
|
// Forward declarations for helpers used below
|
||||||
static size_t ThemeIndexFromId(ThemeId id);
|
static size_t ThemeIndexFromId(ThemeId id);
|
||||||
@@ -372,11 +84,13 @@ BackgroundModeName()
|
|||||||
#include "themes/Everforest.h"
|
#include "themes/Everforest.h"
|
||||||
#include "themes/KanagawaPaper.h"
|
#include "themes/KanagawaPaper.h"
|
||||||
#include "themes/LCARS.h"
|
#include "themes/LCARS.h"
|
||||||
|
#include "themes/Leuchtturm.h"
|
||||||
#include "themes/OldBook.h"
|
#include "themes/OldBook.h"
|
||||||
#include "themes/Amber.h"
|
#include "themes/Amber.h"
|
||||||
#include "themes/WeylandYutani.h"
|
#include "themes/WeylandYutani.h"
|
||||||
#include "themes/Zenburn.h"
|
#include "themes/Zenburn.h"
|
||||||
#include "themes/Orbital.h"
|
#include "themes/Orbital.h"
|
||||||
|
#include "themes/Tufte.h"
|
||||||
|
|
||||||
|
|
||||||
// Theme abstraction and registry (generalized theme system)
|
// Theme abstraction and registry (generalized theme system)
|
||||||
@@ -409,6 +123,28 @@ struct LCARSTheme final : Theme {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct LeuchtturmTheme final : Theme {
|
||||||
|
[[nodiscard]] const char *Name() const override
|
||||||
|
{
|
||||||
|
return "leuchtturm";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void Apply() const override
|
||||||
|
{
|
||||||
|
if (gBackgroundMode == BackgroundMode::Dark)
|
||||||
|
ApplyLeuchtturmDarkTheme();
|
||||||
|
else
|
||||||
|
ApplyLeuchtturmLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ThemeId Id() override
|
||||||
|
{
|
||||||
|
return ThemeId::Leuchtturm;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
struct EverforestTheme final : Theme {
|
struct EverforestTheme final : Theme {
|
||||||
[[nodiscard]] const char *Name() const override
|
[[nodiscard]] const char *Name() const override
|
||||||
{
|
{
|
||||||
@@ -488,6 +224,28 @@ struct OrbitalTheme final : Theme {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct TufteTheme final : Theme {
|
||||||
|
[[nodiscard]] const char *Name() const override
|
||||||
|
{
|
||||||
|
return "tufte";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void Apply() const override
|
||||||
|
{
|
||||||
|
if (gBackgroundMode == BackgroundMode::Dark)
|
||||||
|
ApplyTufteDarkTheme();
|
||||||
|
else
|
||||||
|
ApplyTufteLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ThemeId Id() override
|
||||||
|
{
|
||||||
|
return ThemeId::Tufte;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
struct ZenburnTheme final : Theme {
|
struct ZenburnTheme final : Theme {
|
||||||
[[nodiscard]] const char *Name() const override
|
[[nodiscard]] const char *Name() const override
|
||||||
{
|
{
|
||||||
@@ -657,18 +415,20 @@ ThemeRegistry()
|
|||||||
static std::vector<std::unique_ptr<Theme> > reg;
|
static std::vector<std::unique_ptr<Theme> > reg;
|
||||||
if (reg.empty()) {
|
if (reg.empty()) {
|
||||||
// Alphabetical by canonical name:
|
// Alphabetical by canonical name:
|
||||||
// amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, orbital, plan9, solarized, weyland-yutani, zenburn
|
// amber, eink, everforest, gruvbox, kanagawa-paper, lcars, leuchtturm, nord, old-book, orbital, plan9, solarized, tufte, weyland-yutani, zenburn
|
||||||
reg.emplace_back(std::make_unique<detail::AmberTheme>());
|
reg.emplace_back(std::make_unique<detail::AmberTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::EInkTheme>());
|
reg.emplace_back(std::make_unique<detail::EInkTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::EverforestTheme>());
|
reg.emplace_back(std::make_unique<detail::EverforestTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::GruvboxTheme>());
|
reg.emplace_back(std::make_unique<detail::GruvboxTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::KanagawaPaperTheme>());
|
reg.emplace_back(std::make_unique<detail::KanagawaPaperTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::LCARSTheme>());
|
reg.emplace_back(std::make_unique<detail::LCARSTheme>());
|
||||||
|
reg.emplace_back(std::make_unique<detail::LeuchtturmTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::NordTheme>());
|
reg.emplace_back(std::make_unique<detail::NordTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::OldBookTheme>());
|
reg.emplace_back(std::make_unique<detail::OldBookTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::OrbitalTheme>());
|
reg.emplace_back(std::make_unique<detail::OrbitalTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::Plan9Theme>());
|
reg.emplace_back(std::make_unique<detail::Plan9Theme>());
|
||||||
reg.emplace_back(std::make_unique<detail::SolarizedTheme>());
|
reg.emplace_back(std::make_unique<detail::SolarizedTheme>());
|
||||||
|
reg.emplace_back(std::make_unique<detail::TufteTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::WeylandYutaniTheme>());
|
reg.emplace_back(std::make_unique<detail::WeylandYutaniTheme>());
|
||||||
reg.emplace_back(std::make_unique<detail::ZenburnTheme>());
|
reg.emplace_back(std::make_unique<detail::ZenburnTheme>());
|
||||||
}
|
}
|
||||||
@@ -845,20 +605,24 @@ ThemeIndexFromId(const ThemeId id)
|
|||||||
return 4;
|
return 4;
|
||||||
case ThemeId::LCARS:
|
case ThemeId::LCARS:
|
||||||
return 5;
|
return 5;
|
||||||
case ThemeId::Nord:
|
case ThemeId::Leuchtturm:
|
||||||
return 6;
|
return 6;
|
||||||
case ThemeId::OldBook:
|
case ThemeId::Nord:
|
||||||
return 7;
|
return 7;
|
||||||
case ThemeId::Orbital:
|
case ThemeId::OldBook:
|
||||||
return 8;
|
return 8;
|
||||||
case ThemeId::Plan9:
|
case ThemeId::Orbital:
|
||||||
return 9;
|
return 9;
|
||||||
case ThemeId::Solarized:
|
case ThemeId::Plan9:
|
||||||
return 10;
|
return 10;
|
||||||
case ThemeId::WeylandYutani:
|
case ThemeId::Solarized:
|
||||||
return 11;
|
return 11;
|
||||||
case ThemeId::Zenburn:
|
case ThemeId::Tufte:
|
||||||
return 12;
|
return 12;
|
||||||
|
case ThemeId::WeylandYutani:
|
||||||
|
return 13;
|
||||||
|
case ThemeId::Zenburn:
|
||||||
|
return 14;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -882,30 +646,144 @@ ThemeIdFromIndex(const size_t idx)
|
|||||||
case 5:
|
case 5:
|
||||||
return ThemeId::LCARS;
|
return ThemeId::LCARS;
|
||||||
case 6:
|
case 6:
|
||||||
return ThemeId::Nord;
|
return ThemeId::Leuchtturm;
|
||||||
case 7:
|
case 7:
|
||||||
return ThemeId::OldBook;
|
return ThemeId::Nord;
|
||||||
case 8:
|
case 8:
|
||||||
return ThemeId::Orbital;
|
return ThemeId::OldBook;
|
||||||
case 9:
|
case 9:
|
||||||
return ThemeId::Plan9;
|
return ThemeId::Orbital;
|
||||||
case 10:
|
case 10:
|
||||||
return ThemeId::Solarized;
|
return ThemeId::Plan9;
|
||||||
case 11:
|
case 11:
|
||||||
return ThemeId::WeylandYutani;
|
return ThemeId::Solarized;
|
||||||
case 12:
|
case 12:
|
||||||
|
return ThemeId::Tufte;
|
||||||
|
case 13:
|
||||||
|
return ThemeId::WeylandYutani;
|
||||||
|
case 14:
|
||||||
return ThemeId::Zenburn;
|
return ThemeId::Zenburn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Syntax palette (v1): map TokenKind to ink color per current theme/background ---
|
// --- Syntax palette (v1): map TokenKind to ink color per current theme/background ---
|
||||||
|
|
||||||
|
// Tufte palette: high-contrast, restrained color. Body text is true black on
|
||||||
|
// cream; only keywords and links get subtle color to avoid a "christmas tree."
|
||||||
|
static ImVec4
|
||||||
|
SyntaxInkTufte(const TokenKind k, const bool dark)
|
||||||
|
{
|
||||||
|
const ImVec4 ink = dark ? RGBA(0xEAE6DE) : RGBA(0x111111); // body text
|
||||||
|
const ImVec4 dim = dark ? RGBA(0x8A8680) : RGBA(0x555555); // comments
|
||||||
|
const ImVec4 red = dark ? RGBA(0xD06060) : RGBA(0x8B0000); // keywords/preproc
|
||||||
|
const ImVec4 navy = dark ? RGBA(0x7098C0) : RGBA(0x1A3A5C); // functions/links
|
||||||
|
const ImVec4 grn = dark ? RGBA(0x8AAA6E) : RGBA(0x2E5E2E); // strings
|
||||||
|
switch (k) {
|
||||||
|
case TokenKind::Keyword:
|
||||||
|
case TokenKind::Preproc:
|
||||||
|
return red;
|
||||||
|
case TokenKind::String:
|
||||||
|
case TokenKind::Char:
|
||||||
|
return grn;
|
||||||
|
case TokenKind::Comment:
|
||||||
|
return dim;
|
||||||
|
case TokenKind::Function:
|
||||||
|
return navy;
|
||||||
|
case TokenKind::Number:
|
||||||
|
case TokenKind::Constant:
|
||||||
|
return dark ? RGBA(0xC8A85A) : RGBA(0x6B4C00);
|
||||||
|
case TokenKind::Type:
|
||||||
|
return dark ? RGBA(0xBBAA90) : RGBA(0x333333);
|
||||||
|
case TokenKind::Error:
|
||||||
|
return dark ? RGBA(0xD06060) : RGBA(0xCC0000);
|
||||||
|
default:
|
||||||
|
return ink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Leuchtturm palette: blue-black fountain pen ink with brass and bronze accents.
|
||||||
|
// Body text is ink-colored; accents drawn from the pen metals.
|
||||||
|
static ImVec4
|
||||||
|
SyntaxInkLeuchtturm(const TokenKind k, const bool dark)
|
||||||
|
{
|
||||||
|
const ImVec4 ink = dark ? RGBA(0xE5DDD0) : RGBA(0x040720); // fountain pen ink
|
||||||
|
const ImVec4 dim = dark ? RGBA(0x7A7060) : RGBA(0x6A6558); // comments
|
||||||
|
const ImVec4 brass = dark ? RGBA(0xB8A060) : RGBA(0x504518); // patinated brass
|
||||||
|
const ImVec4 bronze= dark ? RGBA(0xC08050) : RGBA(0x5C3010); // dark bronze
|
||||||
|
const ImVec4 navy = dark ? RGBA(0x8898B0) : RGBA(0x1C2E4A); // deep navy
|
||||||
|
switch (k) {
|
||||||
|
case TokenKind::Keyword:
|
||||||
|
case TokenKind::Preproc:
|
||||||
|
return brass;
|
||||||
|
case TokenKind::String:
|
||||||
|
case TokenKind::Char:
|
||||||
|
return bronze;
|
||||||
|
case TokenKind::Comment:
|
||||||
|
return dim;
|
||||||
|
case TokenKind::Function:
|
||||||
|
return navy;
|
||||||
|
case TokenKind::Number:
|
||||||
|
case TokenKind::Constant:
|
||||||
|
return dark ? RGBA(0xA89060) : RGBA(0x483C10);
|
||||||
|
case TokenKind::Type:
|
||||||
|
return dark ? RGBA(0xC0B898) : RGBA(0x222238);
|
||||||
|
case TokenKind::Error:
|
||||||
|
return dark ? RGBA(0xD06060) : RGBA(0xA02020);
|
||||||
|
default:
|
||||||
|
return ink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Everforest: warm forest palette on dark green-gray (bg 0x2B3339).
|
||||||
|
// Default comment color (0x616E88) is too dim; boost it and tune others.
|
||||||
|
static ImVec4
|
||||||
|
SyntaxInkEverforest(const TokenKind k)
|
||||||
|
{
|
||||||
|
switch (k) {
|
||||||
|
case TokenKind::Keyword:
|
||||||
|
return RGBA(0xE67E80); // everforest red
|
||||||
|
case TokenKind::Type:
|
||||||
|
return RGBA(0xD699B6); // everforest purple
|
||||||
|
case TokenKind::String:
|
||||||
|
case TokenKind::Char:
|
||||||
|
return RGBA(0xA7C080); // everforest green
|
||||||
|
case TokenKind::Comment:
|
||||||
|
return RGBA(0x859289); // boosted from 0x616E88 for contrast
|
||||||
|
case TokenKind::Number:
|
||||||
|
case TokenKind::Constant:
|
||||||
|
return RGBA(0xD8A657); // everforest yellow/orange
|
||||||
|
case TokenKind::Preproc:
|
||||||
|
return RGBA(0xE69875); // everforest orange
|
||||||
|
case TokenKind::Function:
|
||||||
|
return RGBA(0x83C092); // everforest aqua
|
||||||
|
case TokenKind::Operator:
|
||||||
|
case TokenKind::Punctuation:
|
||||||
|
return RGBA(0xD3C6AA); // everforest fg
|
||||||
|
case TokenKind::Error:
|
||||||
|
return RGBA(0xE67E80);
|
||||||
|
default:
|
||||||
|
return RGBA(0xD3C6AA); // everforest fg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[[maybe_unused]] static ImVec4
|
[[maybe_unused]] static ImVec4
|
||||||
SyntaxInk(const TokenKind k)
|
SyntaxInk(const TokenKind k)
|
||||||
{
|
{
|
||||||
// Basic palettes for dark/light backgrounds; tuned for Nord-ish defaults
|
|
||||||
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
|
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
|
||||||
// Base text
|
|
||||||
|
// Per-theme syntax palettes
|
||||||
|
if (gCurrentTheme == ThemeId::Tufte)
|
||||||
|
return SyntaxInkTufte(k, dark);
|
||||||
|
if (gCurrentTheme == ThemeId::Leuchtturm)
|
||||||
|
return SyntaxInkLeuchtturm(k, dark);
|
||||||
|
if (gCurrentTheme == ThemeId::Everforest)
|
||||||
|
return SyntaxInkEverforest(k);
|
||||||
|
|
||||||
|
// Default palettes tuned for Nord-ish themes
|
||||||
const ImVec4 def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
|
const ImVec4 def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
|
||||||
switch (k) {
|
switch (k) {
|
||||||
case TokenKind::Keyword:
|
case TokenKind::Keyword:
|
||||||
@@ -941,5 +819,3 @@ SyntaxInk(const TokenKind k)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|
||||||
#endif // KTE_USE_QT
|
|
||||||
+34
-5
@@ -22,21 +22,26 @@ HelpText::Text()
|
|||||||
" C-k ' Toggle read-only\n"
|
" C-k ' Toggle read-only\n"
|
||||||
" C-k - Unindent region (mark required)\n"
|
" C-k - Unindent region (mark required)\n"
|
||||||
" C-k = Indent region (mark required)\n"
|
" C-k = Indent region (mark required)\n"
|
||||||
|
" C-k / Toggle visual line mode\n"
|
||||||
" C-k ; Command prompt (:\\ )\n"
|
" C-k ; Command prompt (:\\ )\n"
|
||||||
|
" C-k SPACE Toggle mark\n"
|
||||||
" C-k C-d Kill entire line\n"
|
" C-k C-d Kill entire line\n"
|
||||||
" C-k C-q Quit now (no confirm)\n"
|
" C-k C-q Quit now (no confirm)\n"
|
||||||
|
" C-k C-s Save\n"
|
||||||
" C-k C-x Save and quit\n"
|
" C-k C-x Save and quit\n"
|
||||||
" C-k a Mark start of file, jump to end\n"
|
" C-k a Mark start of file, jump to end\n"
|
||||||
" C-k b Switch buffer\n"
|
" C-k b Switch buffer\n"
|
||||||
" C-k c Close current buffer\n"
|
" C-k c Close current buffer\n"
|
||||||
" C-k d Kill to end of line\n"
|
" C-k d Kill to end of line\n"
|
||||||
" C-k e Open file (prompt)\n"
|
" C-k e Open file (prompt)\n"
|
||||||
" C-k i New empty buffer\n"
|
|
||||||
" C-k f Flush kill ring\n"
|
" C-k f Flush kill ring\n"
|
||||||
" C-k g Jump to line\n"
|
" C-k g Jump to line\n"
|
||||||
" C-k h Show this help\n"
|
" C-k h Show this help\n"
|
||||||
|
" C-k i New empty buffer\n"
|
||||||
" C-k j Jump to mark\n"
|
" C-k j Jump to mark\n"
|
||||||
|
" C-k k Center viewport on cursor\n"
|
||||||
" C-k l Reload buffer from disk\n"
|
" C-k l Reload buffer from disk\n"
|
||||||
|
" C-k m Toggle edit mode (code/writing)\n"
|
||||||
" C-k n Previous buffer\n"
|
" C-k n Previous buffer\n"
|
||||||
" C-k o Change working directory (prompt)\n"
|
" C-k o Change working directory (prompt)\n"
|
||||||
" C-k p Next buffer\n"
|
" C-k p Next buffer\n"
|
||||||
@@ -60,6 +65,10 @@ HelpText::Text()
|
|||||||
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
|
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
|
||||||
" ESC q Reflow paragraph\n"
|
" ESC q Reflow paragraph\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
"Universal argument:\n"
|
||||||
|
" C-u Begin repeat count (then type digits); C-u alone multiplies by 4\n"
|
||||||
|
" C-u N <cmd> Repeat <cmd> N times (e.g., C-u 8 C-f moves right 8 chars)\n"
|
||||||
|
"\n"
|
||||||
"Control keys:\n"
|
"Control keys:\n"
|
||||||
" C-a C-e Line start / end\n"
|
" C-a C-e Line start / end\n"
|
||||||
" C-b C-f Move left / right\n"
|
" C-b C-f Move left / right\n"
|
||||||
@@ -71,12 +80,32 @@ HelpText::Text()
|
|||||||
" C-t Regex search & replace\n"
|
" C-t Regex search & replace\n"
|
||||||
" C-h Search & replace\n"
|
" C-h Search & replace\n"
|
||||||
" C-l / C-g Refresh / Cancel\n"
|
" C-l / C-g Refresh / Cancel\n"
|
||||||
" C-u [digits] Universal argument (repeat count)\n"
|
|
||||||
"\n"
|
"\n"
|
||||||
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
|
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"GUI appearance (command prompt):\n"
|
"Edit modes:\n"
|
||||||
" : theme NAME Set GUI theme (amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, plan9, solarized, weyland-yutani, zenburn)\n"
|
" code Monospace font (default for source files)\n"
|
||||||
" : background MODE Set background: light | dark (affects eink, gruvbox, old-book, solarized)\n"
|
" writing Proportional font (auto for .txt, .md, .rst, .org, .tex)\n"
|
||||||
|
" C-k m or : mode [code|writing] to toggle\n"
|
||||||
|
"\n"
|
||||||
|
"GUI commands (command prompt):\n"
|
||||||
|
" : theme NAME Set theme (amber, eink, everforest, gruvbox,\n"
|
||||||
|
" kanagawa-paper, lcars, leuchtturm, nord, old-book,\n"
|
||||||
|
" orbital, plan9, solarized, tufte, weyland-yutani,\n"
|
||||||
|
" zenburn)\n"
|
||||||
|
" : background MODE Background: light | dark\n"
|
||||||
|
" : font NAME Set font (tab completes)\n"
|
||||||
|
" : font-size NUM Set font size in pixels\n"
|
||||||
|
" : mode [code|writing] Toggle or set edit mode\n"
|
||||||
|
"\n"
|
||||||
|
"Configuration:\n"
|
||||||
|
" Config file: ~/.config/kte/kge.toml (see CONFIG.md)\n"
|
||||||
|
" Legacy kge.ini is also supported.\n"
|
||||||
|
"\n"
|
||||||
|
"GUI window management:\n"
|
||||||
|
" Cmd+N (macOS) Open a new editor window sharing the same buffers\n"
|
||||||
|
" Ctrl+Shift+N (Linux) Open a new editor window sharing the same buffers\n"
|
||||||
|
" Close window Secondary windows close independently; closing the\n"
|
||||||
|
" primary window quits the editor\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+42
@@ -1,7 +1,10 @@
|
|||||||
// Highlight.h - core syntax highlighting types for kte
|
// Highlight.h - core syntax highlighting types for kte
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
@@ -34,4 +37,43 @@ struct LineHighlight {
|
|||||||
std::vector<HighlightSpan> spans;
|
std::vector<HighlightSpan> spans;
|
||||||
std::uint64_t version{0}; // buffer version used for this line
|
std::uint64_t version{0}; // buffer version used for this line
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// A line's spans ready to draw: clamped to the line, ordered by start, not
|
||||||
|
// overlapping (the earlier span wins), and widened to whole UTF-8
|
||||||
|
// characters. Highlighters may emit one span per byte of a non-ASCII
|
||||||
|
// character; drawn separately, each byte showed as a replacement glyph.
|
||||||
|
inline void
|
||||||
|
SanitizeSpans(std::string_view line, const std::vector<HighlightSpan> &in, std::vector<HighlightSpan> &out)
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
const long len = static_cast<long>(line.size());
|
||||||
|
for (const HighlightSpan &sp: in) {
|
||||||
|
long s = std::clamp<long>(std::min(sp.col_start, sp.col_end), 0, len);
|
||||||
|
long e = std::clamp<long>(std::max(sp.col_start, sp.col_end), 0, len);
|
||||||
|
if (e > s)
|
||||||
|
out.push_back(HighlightSpan{static_cast<int>(s), static_cast<int>(e), sp.kind});
|
||||||
|
}
|
||||||
|
std::stable_sort(out.begin(), out.end(), [](const HighlightSpan &a, const HighlightSpan &b) {
|
||||||
|
return a.col_start < b.col_start;
|
||||||
|
});
|
||||||
|
auto continuation = [&](long i) {
|
||||||
|
return i < len && (static_cast<unsigned char>(line[static_cast<std::size_t>(i)]) & 0xC0) == 0x80;
|
||||||
|
};
|
||||||
|
std::size_t w = 0;
|
||||||
|
long prev_end = 0;
|
||||||
|
for (const HighlightSpan &sp: out) {
|
||||||
|
long s = sp.col_start, e = sp.col_end;
|
||||||
|
while (s > 0 && continuation(s))
|
||||||
|
--s;
|
||||||
|
while (e < len && continuation(e))
|
||||||
|
++e;
|
||||||
|
s = std::max(s, prev_end);
|
||||||
|
if (e <= s)
|
||||||
|
continue;
|
||||||
|
out[w++] = HighlightSpan{static_cast<int>(s), static_cast<int>(e), sp.kind};
|
||||||
|
prev_end = e;
|
||||||
|
}
|
||||||
|
out.resize(w);
|
||||||
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
+445
-166
@@ -19,8 +19,6 @@
|
|||||||
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
||||||
#include "fonts/FontRegistry.h"
|
#include "fonts/FontRegistry.h"
|
||||||
#include "fonts/IosevkaExtended.h"
|
#include "fonts/IosevkaExtended.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
|
||||||
#include "syntax/NullHighlighter.h"
|
|
||||||
|
|
||||||
|
|
||||||
#ifndef KTE_FONT_SIZE
|
#ifndef KTE_FONT_SIZE
|
||||||
@@ -29,21 +27,141 @@
|
|||||||
|
|
||||||
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
|
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void
|
||||||
|
apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
|
||||||
|
{
|
||||||
|
if (!b)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Auto-detect edit mode from file extension once per buffer so that
|
||||||
|
// manual toggles (C-k m / : mode) are not overridden every frame.
|
||||||
|
if (!b->EditModeDetected() && !b->Filename().empty())
|
||||||
|
b->SetEditMode(DetectEditMode(b->Filename()));
|
||||||
|
|
||||||
|
// If the user explicitly set syntax state via a command (:syntax on/off,
|
||||||
|
// :set filetype=...), leave it alone - otherwise this runs every frame
|
||||||
|
// and silently undoes a manual ":syntax off" on the very next frame.
|
||||||
|
if (b->SyntaxUserOverride())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Writing mode disables syntax; otherwise follow the global config.
|
||||||
|
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
|
||||||
|
b->SetSyntaxEnabled(true);
|
||||||
|
b->EnsureHighlighter();
|
||||||
|
if (!b->Highlighter()->HasHighlighter())
|
||||||
|
b->ApplyDetectedFiletype();
|
||||||
|
} else {
|
||||||
|
b->SetSyntaxEnabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Update editor logical rows/cols from current ImGui metrics for a given display size.
|
||||||
|
static void
|
||||||
|
update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
|
||||||
|
{
|
||||||
|
float row_h = ImGui::GetTextLineHeightWithSpacing();
|
||||||
|
// Use average character width rather than "M" (the widest character)
|
||||||
|
// so that column count is reasonable for proportional fonts too.
|
||||||
|
float ch_w = ImGui::CalcTextSize("abcdefghijklmnopqrstuvwxyz").x / 26.0f;
|
||||||
|
if (row_h <= 0.0f)
|
||||||
|
row_h = 16.0f;
|
||||||
|
if (ch_w <= 0.0f)
|
||||||
|
ch_w = 8.0f;
|
||||||
|
|
||||||
|
const float pad_x = 6.0f;
|
||||||
|
const float pad_y = 6.0f;
|
||||||
|
|
||||||
|
float wanted_bar_h = ImGui::GetFrameHeight();
|
||||||
|
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
|
||||||
|
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
|
||||||
|
|
||||||
|
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
|
||||||
|
std::size_t rows = content_rows + 1;
|
||||||
|
|
||||||
|
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
|
||||||
|
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
||||||
|
|
||||||
|
if (rows != ed.Rows() || cols != ed.Cols()) {
|
||||||
|
ed.SetDimensions(rows, cols);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SetupImGuiStyle_ — apply theme, fonts, and flags to the current ImGui context
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
void
|
||||||
|
GUIFrontend::SetupImGuiStyle_()
|
||||||
|
{
|
||||||
|
ImGuiIO &io = ImGui::GetIO();
|
||||||
|
|
||||||
|
// Disable imgui.ini for secondary windows (primary sets its own path in Init)
|
||||||
|
io.IniFilename = nullptr;
|
||||||
|
|
||||||
|
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||||
|
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||||
|
ImGui::StyleColorsDark();
|
||||||
|
|
||||||
|
if (config_.background == "light")
|
||||||
|
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
||||||
|
else
|
||||||
|
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
||||||
|
kte::ApplyThemeByName(config_.theme);
|
||||||
|
|
||||||
|
// Load fonts into this context's font atlas.
|
||||||
|
// Font registry is global and already populated by Init; just load into this atlas.
|
||||||
|
if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
|
||||||
|
LoadGuiFont_(nullptr, (float) config_.font_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Destroy a single window's ImGui context + SDL/GL resources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
void
|
||||||
|
GUIFrontend::DestroyWindowResources_(WindowState &ws)
|
||||||
|
{
|
||||||
|
if (ws.imgui_ctx) {
|
||||||
|
// Must activate this window's GL context before shutting down the
|
||||||
|
// OpenGL3 backend, otherwise it deletes another context's resources.
|
||||||
|
if (ws.window && ws.gl_ctx)
|
||||||
|
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
|
||||||
|
ImGui::SetCurrentContext(ws.imgui_ctx);
|
||||||
|
ImGui_ImplOpenGL3_Shutdown();
|
||||||
|
ImGui_ImplSDL2_Shutdown();
|
||||||
|
ImGui::DestroyContext(ws.imgui_ctx);
|
||||||
|
ws.imgui_ctx = nullptr;
|
||||||
|
}
|
||||||
|
if (ws.gl_ctx) {
|
||||||
|
SDL_GL_DeleteContext(ws.gl_ctx);
|
||||||
|
ws.gl_ctx = nullptr;
|
||||||
|
}
|
||||||
|
if (ws.window) {
|
||||||
|
SDL_DestroyWindow(ws.window);
|
||||||
|
ws.window = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||||
{
|
{
|
||||||
(void) argc;
|
(void) argc;
|
||||||
(void) argv;
|
(void) argv;
|
||||||
// Attach editor to input handler for editor-owned features (e.g., universal argument)
|
|
||||||
input_.Attach(&ed);
|
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
|
||||||
// editor dimensions will be initialized during the first Step() frame
|
config_ = GUIConfig::Load();
|
||||||
|
|
||||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
|
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
|
|
||||||
GUIConfig cfg = GUIConfig::Load();
|
|
||||||
|
|
||||||
// GL attributes for core profile
|
// GL attributes for core profile
|
||||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
|
||||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
|
||||||
@@ -56,159 +174,114 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
|||||||
// Compute desired window size from config
|
// Compute desired window size from config
|
||||||
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
||||||
|
|
||||||
if (cfg.fullscreen) {
|
int init_w = 1280, init_h = 800;
|
||||||
// "Fullscreen": fill the usable bounds of the primary display.
|
if (config_.fullscreen) {
|
||||||
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
|
|
||||||
SDL_Rect usable{};
|
SDL_Rect usable{};
|
||||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||||
width_ = usable.w;
|
init_w = usable.w;
|
||||||
height_ = usable.h;
|
init_h = usable.h;
|
||||||
}
|
}
|
||||||
#if !defined(__APPLE__)
|
#if !defined(__APPLE__)
|
||||||
// Non-macOS: desktop fullscreen uses the current display resolution.
|
|
||||||
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
|
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
// Windowed: width = columns * font_size, height = (rows * 2) * font_size
|
int w = config_.columns * static_cast<int>(config_.font_size);
|
||||||
int w = cfg.columns * static_cast<int>(cfg.font_size);
|
int h = config_.rows * static_cast<int>(config_.font_size * 1.2);
|
||||||
int h = cfg.rows * static_cast<int>(cfg.font_size * 1.2);
|
|
||||||
|
|
||||||
// As a safety, clamp to display usable bounds if retrievable
|
|
||||||
SDL_Rect usable{};
|
SDL_Rect usable{};
|
||||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||||
w = std::min(w, usable.w);
|
w = std::min(w, usable.w);
|
||||||
h = std::min(h, usable.h);
|
h = std::min(h, usable.h);
|
||||||
}
|
}
|
||||||
width_ = std::max(320, w);
|
init_w = std::max(320, w);
|
||||||
height_ = std::max(200, h);
|
init_h = std::max(200, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||||
window_ = SDL_CreateWindow(
|
SDL_Window *win = SDL_CreateWindow(
|
||||||
"kge - kyle's graphical editor " KTE_VERSION_STR,
|
"kge - kyle's graphical editor " KTE_VERSION_STR,
|
||||||
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||||
width_, height_,
|
init_w, init_h,
|
||||||
win_flags);
|
win_flags);
|
||||||
if (!window_) {
|
if (!win) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
SDL_EnableScreenSaver();
|
SDL_EnableScreenSaver();
|
||||||
|
|
||||||
#if defined(__APPLE__)
|
#if defined(__APPLE__)
|
||||||
// macOS: when "fullscreen" is requested, position the window at the
|
if (config_.fullscreen) {
|
||||||
// top-left of the usable display area to mimic fullscreen while keeping
|
|
||||||
// the system menu bar visible.
|
|
||||||
if (cfg.fullscreen) {
|
|
||||||
SDL_Rect usable{};
|
SDL_Rect usable{};
|
||||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||||
SDL_SetWindowPosition(window_, usable.x, usable.y);
|
SDL_SetWindowPosition(win, usable.x, usable.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
gl_ctx_ = SDL_GL_CreateContext(window_);
|
SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
|
||||||
if (!gl_ctx_)
|
if (!gl_ctx) {
|
||||||
|
SDL_DestroyWindow(win);
|
||||||
return false;
|
return false;
|
||||||
SDL_GL_MakeCurrent(window_, gl_ctx_);
|
}
|
||||||
|
SDL_GL_MakeCurrent(win, gl_ctx);
|
||||||
SDL_GL_SetSwapInterval(1); // vsync
|
SDL_GL_SetSwapInterval(1); // vsync
|
||||||
|
|
||||||
|
// Create primary ImGui context
|
||||||
IMGUI_CHECKVERSION();
|
IMGUI_CHECKVERSION();
|
||||||
ImGui::CreateContext();
|
ImGuiContext *imgui_ctx = ImGui::CreateContext();
|
||||||
ImGuiIO &io = ImGui::GetIO();
|
ImGuiIO &io = ImGui::GetIO();
|
||||||
|
|
||||||
// Set custom ini filename path to ~/.config/kte/imgui.ini
|
// Set custom ini filename path to ~/.config/kte/imgui.ini
|
||||||
if (const char *home = std::getenv("HOME")) {
|
if (const char *home = std::getenv("HOME")) {
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
fs::path config_dir = fs::path(home) / ".config" / "kte";
|
fs::path config_dir = fs::path(home) / ".config" / "kte";
|
||||||
|
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
if (!fs::exists(config_dir)) {
|
if (!fs::exists(config_dir)) {
|
||||||
fs::create_directories(config_dir, ec);
|
fs::create_directories(config_dir, ec);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs::exists(config_dir)) {
|
if (fs::exists(config_dir)) {
|
||||||
static std::string ini_path = (config_dir / "imgui.ini").string();
|
static std::string ini_path = (config_dir / "imgui.ini").string();
|
||||||
io.IniFilename = ini_path.c_str();
|
io.IniFilename = ini_path.c_str();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
|
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
|
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||||
ImGui::StyleColorsDark();
|
ImGui::StyleColorsDark();
|
||||||
|
|
||||||
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands.
|
if (config_.background == "light")
|
||||||
if (cfg.background == "light")
|
|
||||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
||||||
else
|
else
|
||||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
||||||
kte::ApplyThemeByName(cfg.theme);
|
kte::ApplyThemeByName(config_.theme);
|
||||||
|
|
||||||
// Apply default syntax highlighting preference from GUI config to the current buffer
|
apply_syntax_to_buffer(ed.CurrentBuffer(), config_);
|
||||||
if (Buffer *b = ed.CurrentBuffer()) {
|
|
||||||
if (cfg.syntax) {
|
|
||||||
b->SetSyntaxEnabled(true);
|
|
||||||
// Ensure a highlighter is available if possible
|
|
||||||
b->EnsureHighlighter();
|
|
||||||
if (auto *eng = b->Highlighter()) {
|
|
||||||
if (!eng->HasHighlighter()) {
|
|
||||||
// Try detect from filename and first line; fall back to cpp or existing filetype
|
|
||||||
std::string first_line;
|
|
||||||
const auto &rows = b->Rows();
|
|
||||||
if (!rows.empty())
|
|
||||||
first_line = static_cast<std::string>(rows[0]);
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(
|
|
||||||
b->Filename(), first_line);
|
|
||||||
if (!ft.empty()) {
|
|
||||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
|
||||||
b->SetFiletype(ft);
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
} else {
|
|
||||||
// Unknown/unsupported -> install a null highlighter to keep syntax enabled
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
b->SetFiletype("");
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b->SetSyntaxEnabled(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_))
|
if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx))
|
||||||
return false;
|
return false;
|
||||||
if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
|
if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Cache initial window size; logical rows/cols will be computed in Step() once a valid ImGui frame exists
|
// Cache initial window size
|
||||||
int w, h;
|
int w, h;
|
||||||
SDL_GetWindowSize(window_, &w, &h);
|
SDL_GetWindowSize(win, &w, &h);
|
||||||
width_ = w;
|
init_w = w;
|
||||||
height_ = h;
|
init_h = h;
|
||||||
|
|
||||||
#if defined(__APPLE__)
|
#if defined(__APPLE__)
|
||||||
// Workaround: On macOS Retina when starting maximized, we sometimes get a
|
|
||||||
// subtle input vs draw alignment mismatch until the first manual resize.
|
|
||||||
// Nudge the window size by 1px and back to trigger a proper internal
|
|
||||||
// recomputation, without visible impact.
|
|
||||||
if (w > 1 && h > 1) {
|
if (w > 1 && h > 1) {
|
||||||
SDL_SetWindowSize(window_, w - 1, h - 1);
|
SDL_SetWindowSize(win, w - 1, h - 1);
|
||||||
SDL_SetWindowSize(window_, w, h);
|
SDL_SetWindowSize(win, w, h);
|
||||||
// Update cached size in case backend reports immediately
|
SDL_GetWindowSize(win, &w, &h);
|
||||||
SDL_GetWindowSize(window_, &w, &h);
|
init_w = w;
|
||||||
width_ = w;
|
init_h = h;
|
||||||
height_ = h;
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Install embedded fonts into registry and load configured font
|
// Install embedded fonts
|
||||||
kte::Fonts::InstallDefaultFonts();
|
kte::Fonts::InstallDefaultFonts();
|
||||||
// Initialize font atlas using configured font name and size; fallback to embedded default helper
|
if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
|
||||||
if (!kte::Fonts::FontRegistry::Instance().LoadFont(cfg.font, (float) cfg.font_size)) {
|
LoadGuiFont_(nullptr, (float) config_.font_size);
|
||||||
LoadGuiFont_(nullptr, (float) cfg.font_size);
|
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) config_.font_size);
|
||||||
// Record defaults in registry so subsequent size changes have a base
|
|
||||||
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) cfg.font_size);
|
|
||||||
std::string n;
|
std::string n;
|
||||||
float s = 0.0f;
|
float s = 0.0f;
|
||||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) {
|
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) {
|
||||||
@@ -216,6 +289,90 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build primary WindowState
|
||||||
|
auto ws = std::make_unique<WindowState>();
|
||||||
|
ws->window = win;
|
||||||
|
ws->gl_ctx = gl_ctx;
|
||||||
|
ws->imgui_ctx = imgui_ctx;
|
||||||
|
ws->width = init_w;
|
||||||
|
ws->height = init_h;
|
||||||
|
// The primary window's editor IS the editor passed in from main; we don't
|
||||||
|
// use ws->editor for the primary — instead we keep a pointer to &ed.
|
||||||
|
// We store a sentinel: window index 0 uses the external editor reference.
|
||||||
|
// To keep things simple, attach input to the passed-in editor.
|
||||||
|
ws->input.Attach(&ed);
|
||||||
|
windows_.push_back(std::move(ws));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
GUIFrontend::OpenNewWindow_(Editor &primary)
|
||||||
|
{
|
||||||
|
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
||||||
|
int w = windows_[0]->width;
|
||||||
|
int h = windows_[0]->height;
|
||||||
|
|
||||||
|
SDL_Window *win = SDL_CreateWindow(
|
||||||
|
"kge - kyle's graphical editor " KTE_VERSION_STR,
|
||||||
|
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||||
|
w, h,
|
||||||
|
win_flags);
|
||||||
|
if (!win)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
|
||||||
|
if (!gl_ctx) {
|
||||||
|
SDL_DestroyWindow(win);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SDL_GL_MakeCurrent(win, gl_ctx);
|
||||||
|
SDL_GL_SetSwapInterval(1);
|
||||||
|
|
||||||
|
// Each window gets its own ImGui context — ImGui requires exactly one
|
||||||
|
// NewFrame/Render cycle per context per frame.
|
||||||
|
ImGuiContext *imgui_ctx = ImGui::CreateContext();
|
||||||
|
ImGui::SetCurrentContext(imgui_ctx);
|
||||||
|
|
||||||
|
SetupImGuiStyle_();
|
||||||
|
|
||||||
|
if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx)) {
|
||||||
|
ImGui::DestroyContext(imgui_ctx);
|
||||||
|
SDL_GL_DeleteContext(gl_ctx);
|
||||||
|
SDL_DestroyWindow(win);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!ImGui_ImplOpenGL3_Init(kGlslVersion)) {
|
||||||
|
ImGui_ImplSDL2_Shutdown();
|
||||||
|
ImGui::DestroyContext(imgui_ctx);
|
||||||
|
SDL_GL_DeleteContext(gl_ctx);
|
||||||
|
SDL_DestroyWindow(win);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ws = std::make_unique<WindowState>();
|
||||||
|
ws->window = win;
|
||||||
|
ws->gl_ctx = gl_ctx;
|
||||||
|
ws->imgui_ctx = imgui_ctx;
|
||||||
|
ws->width = w;
|
||||||
|
ws->height = h;
|
||||||
|
|
||||||
|
// Secondary editor shares the primary's buffer list
|
||||||
|
ws->editor.SetSharedBuffers(&primary.Buffers(), primary.Swap());
|
||||||
|
ws->editor.SetDimensions(primary.Rows(), primary.Cols());
|
||||||
|
|
||||||
|
// Open a new untitled buffer and switch to it in the new window.
|
||||||
|
ws->editor.AddBuffer(Buffer());
|
||||||
|
ws->editor.SwitchTo(ws->editor.BufferCount() - 1);
|
||||||
|
|
||||||
|
ws->input.Attach(&ws->editor);
|
||||||
|
|
||||||
|
windows_.push_back(std::move(ws));
|
||||||
|
|
||||||
|
// Restore primary context
|
||||||
|
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
|
||||||
|
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,137 +380,261 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
|||||||
void
|
void
|
||||||
GUIFrontend::Step(Editor &ed, bool &running)
|
GUIFrontend::Step(Editor &ed, bool &running)
|
||||||
{
|
{
|
||||||
|
// --- Event processing ---
|
||||||
|
// SDL events carry a window ID. Route each event to the correct window's
|
||||||
|
// ImGui context (for ImGui_ImplSDL2_ProcessEvent) and input handler.
|
||||||
SDL_Event e;
|
SDL_Event e;
|
||||||
while (SDL_PollEvent(&e)) {
|
while (SDL_PollEvent(&e)) {
|
||||||
ImGui_ImplSDL2_ProcessEvent(&e);
|
// Determine which window this event belongs to
|
||||||
|
Uint32 event_win_id = 0;
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case SDL_QUIT:
|
|
||||||
running = false;
|
|
||||||
break;
|
|
||||||
case SDL_WINDOWEVENT:
|
case SDL_WINDOWEVENT:
|
||||||
if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
|
event_win_id = e.window.windowID;
|
||||||
width_ = e.window.data1;
|
break;
|
||||||
height_ = e.window.data2;
|
case SDL_KEYDOWN:
|
||||||
}
|
case SDL_KEYUP:
|
||||||
|
event_win_id = e.key.windowID;
|
||||||
|
break;
|
||||||
|
case SDL_TEXTINPUT:
|
||||||
|
event_win_id = e.text.windowID;
|
||||||
|
break;
|
||||||
|
case SDL_MOUSEBUTTONDOWN:
|
||||||
|
case SDL_MOUSEBUTTONUP:
|
||||||
|
event_win_id = e.button.windowID;
|
||||||
|
break;
|
||||||
|
case SDL_MOUSEWHEEL:
|
||||||
|
event_win_id = e.wheel.windowID;
|
||||||
|
break;
|
||||||
|
case SDL_MOUSEMOTION:
|
||||||
|
event_win_id = e.motion.windowID;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Map input to commands
|
|
||||||
input_.ProcessSDLEvent(e);
|
if (e.type == SDL_QUIT) {
|
||||||
|
running = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply pending font change before starting a new frame
|
// Find the target window and route the event to its ImGui context
|
||||||
|
WindowState *target = nullptr;
|
||||||
|
std::size_t target_idx = 0;
|
||||||
|
if (event_win_id != 0) {
|
||||||
|
for (std::size_t i = 0; i < windows_.size(); ++i) {
|
||||||
|
if (SDL_GetWindowID(windows_[i]->window) == event_win_id) {
|
||||||
|
target = windows_[i].get();
|
||||||
|
target_idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target && target->imgui_ctx) {
|
||||||
|
// Set this window's ImGui context so ImGui_ImplSDL2_ProcessEvent
|
||||||
|
// updates the correct IO state.
|
||||||
|
ImGui::SetCurrentContext(target->imgui_ctx);
|
||||||
|
ImGui_ImplSDL2_ProcessEvent(&e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.type == SDL_WINDOWEVENT) {
|
||||||
|
if (e.window.event == SDL_WINDOWEVENT_CLOSE) {
|
||||||
|
if (target) {
|
||||||
|
if (target_idx == 0) {
|
||||||
|
running = false;
|
||||||
|
} else {
|
||||||
|
target->alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
|
||||||
|
if (target) {
|
||||||
|
target->width = e.window.data1;
|
||||||
|
target->height = e.window.data2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route input events to the correct window's input handler
|
||||||
|
if (target) {
|
||||||
|
Editor &tgt_ed = (target_idx == 0) ? ed : target->editor;
|
||||||
|
if (tgt_ed.FilePickerVisible()) {
|
||||||
|
// Modal: don't let keystrokes fall through as edit commands
|
||||||
|
// to the buffer underneath. Escape closes the picker;
|
||||||
|
// everything else (navigation, filtering, double-click) is
|
||||||
|
// handled by the picker's own ImGui widgets via
|
||||||
|
// ImGui_ImplSDL2_ProcessEvent above.
|
||||||
|
if (e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE) {
|
||||||
|
tgt_ed.SetFilePickerVisible(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
target->input.ProcessSDLEvent(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// --- Apply pending font change (to all contexts) ---
|
||||||
{
|
{
|
||||||
std::string fname;
|
std::string fname;
|
||||||
float fsize = 0.0f;
|
float fsize = 0.0f;
|
||||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
|
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
|
||||||
if (!fname.empty() && fsize > 0.0f) {
|
if (!fname.empty() && fsize > 0.0f) {
|
||||||
|
for (auto &ws : windows_) {
|
||||||
|
if (!ws->alive || !ws->imgui_ctx)
|
||||||
|
continue;
|
||||||
|
ImGui::SetCurrentContext(ws->imgui_ctx);
|
||||||
|
SDL_GL_MakeCurrent(ws->window, ws->gl_ctx);
|
||||||
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
|
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
|
||||||
// Recreate backend font texture
|
|
||||||
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
||||||
ImGui_ImplOpenGL3_CreateFontsTexture();
|
ImGui_ImplOpenGL3_CreateFontsTexture();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start a new ImGui frame BEFORE processing commands so dimensions are correct
|
// --- Step each window ---
|
||||||
|
// We iterate by index because OpenNewWindow_ may append to windows_.
|
||||||
|
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
|
||||||
|
WindowState &ws = *windows_[wi];
|
||||||
|
if (!ws.alive)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Editor &wed = (wi == 0) ? ed : ws.editor;
|
||||||
|
|
||||||
|
// Shared buffer list may have been modified by another window.
|
||||||
|
wed.ValidateBufferIndex();
|
||||||
|
|
||||||
|
// Activate this window's GL and ImGui contexts
|
||||||
|
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
|
||||||
|
ImGui::SetCurrentContext(ws.imgui_ctx);
|
||||||
|
|
||||||
|
// Start a new ImGui frame
|
||||||
ImGui_ImplOpenGL3_NewFrame();
|
ImGui_ImplOpenGL3_NewFrame();
|
||||||
ImGui_ImplSDL2_NewFrame(window_);
|
ImGui_ImplSDL2_NewFrame(ws.window);
|
||||||
ImGui::NewFrame();
|
ImGui::NewFrame();
|
||||||
|
|
||||||
// Update editor logical rows/cols using current ImGui metrics and display size
|
// Update editor dimensions
|
||||||
{
|
{
|
||||||
ImGuiIO &io = ImGui::GetIO();
|
ImGuiIO &io = ImGui::GetIO();
|
||||||
float row_h = ImGui::GetTextLineHeightWithSpacing();
|
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(ws.width);
|
||||||
float ch_w = ImGui::CalcTextSize("M").x;
|
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(ws.height);
|
||||||
if (row_h <= 0.0f)
|
update_editor_dimensions(wed, disp_w, disp_h);
|
||||||
row_h = 16.0f;
|
|
||||||
if (ch_w <= 0.0f)
|
|
||||||
ch_w = 8.0f;
|
|
||||||
// Prefer ImGui IO display size; fall back to cached SDL window size
|
|
||||||
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(width_);
|
|
||||||
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_);
|
|
||||||
|
|
||||||
// Account for the GUI window padding and the status bar height used in ImGuiRenderer.
|
|
||||||
const float pad_x = 6.0f;
|
|
||||||
const float pad_y = 6.0f;
|
|
||||||
|
|
||||||
// Use the same logic as ImGuiRenderer for available height and status bar reservation.
|
|
||||||
float wanted_bar_h = ImGui::GetFrameHeight();
|
|
||||||
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
|
|
||||||
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
|
|
||||||
|
|
||||||
// Visible content rows inside the scroll child
|
|
||||||
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
|
|
||||||
// Editor::Rows includes the status line; add 1 back for it.
|
|
||||||
std::size_t rows = content_rows + 1;
|
|
||||||
|
|
||||||
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
|
|
||||||
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
|
||||||
|
|
||||||
// Only update if changed to avoid churn
|
|
||||||
if (rows != ed.Rows() || cols != ed.Cols()) {
|
|
||||||
ed.SetDimensions(rows, cols);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow deferred opens (including swap recovery prompts) to run.
|
// Allow deferred opens
|
||||||
ed.ProcessPendingOpens();
|
wed.ProcessPendingOpens();
|
||||||
|
|
||||||
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
// Ensure newly opened buffers get syntax + edit mode detection
|
||||||
|
apply_syntax_to_buffer(wed.CurrentBuffer(), config_);
|
||||||
|
|
||||||
|
// Drain input queue
|
||||||
for (;;) {
|
for (;;) {
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (!input_.Poll(mi))
|
if (!ws.input.Poll(mi))
|
||||||
break;
|
break;
|
||||||
if (mi.hasCommand) {
|
if (mi.hasCommand) {
|
||||||
// Track kill ring before and after to sync GUI clipboard when it changes
|
if (mi.id == CommandId::NewWindow) {
|
||||||
const std::string before = ed.KillRingHead();
|
// Open a new window; handled after this loop
|
||||||
Execute(ed, mi.id, mi.arg, mi.count);
|
wed.SetNewWindowRequested(true);
|
||||||
const std::string after = ed.KillRingHead();
|
} else if (mi.id == CommandId::FontZoomIn ||
|
||||||
|
mi.id == CommandId::FontZoomOut ||
|
||||||
|
mi.id == CommandId::FontZoomReset) {
|
||||||
|
auto &fr = kte::Fonts::FontRegistry::Instance();
|
||||||
|
float cur = fr.CurrentFontSize();
|
||||||
|
if (cur <= 0.0f) cur = config_.font_size;
|
||||||
|
float next = cur;
|
||||||
|
if (mi.id == CommandId::FontZoomIn)
|
||||||
|
next = std::min(cur + 2.0f, 72.0f);
|
||||||
|
else if (mi.id == CommandId::FontZoomOut)
|
||||||
|
next = std::max(cur - 2.0f, 8.0f);
|
||||||
|
else
|
||||||
|
next = config_.font_size; // reset to config default
|
||||||
|
if (next != cur)
|
||||||
|
fr.RequestLoadFont(fr.CurrentFontName(), next);
|
||||||
|
} else {
|
||||||
|
const std::string before = wed.KillRingHead();
|
||||||
|
Execute(wed, mi.id, mi.arg, mi.count);
|
||||||
|
const std::string after = wed.KillRingHead();
|
||||||
if (after != before && !after.empty()) {
|
if (after != before && !after.empty()) {
|
||||||
// Update the system clipboard to mirror the kill ring head in GUI
|
|
||||||
SDL_SetClipboardText(after.c_str());
|
SDL_SetClipboardText(after.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (ed.QuitRequested()) {
|
if (wi == 0 && wed.QuitRequested()) {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No runtime font UI; always use embedded font.
|
// Switch font based on current buffer's edit mode (deferred to next frame)
|
||||||
|
{
|
||||||
|
Buffer *cur = wed.CurrentBuffer();
|
||||||
|
if (cur) {
|
||||||
|
auto &fr = kte::Fonts::FontRegistry::Instance();
|
||||||
|
const std::string &expected =
|
||||||
|
(cur->GetEditMode() == EditMode::Writing)
|
||||||
|
? config_.writing_font
|
||||||
|
: config_.code_font;
|
||||||
|
if (fr.CurrentFontName() != expected && fr.HasFont(expected)) {
|
||||||
|
float sz = fr.CurrentFontSize();
|
||||||
|
if (sz <= 0.0f) sz = config_.font_size;
|
||||||
|
fr.RequestLoadFont(expected, sz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Draw editor UI
|
// Draw
|
||||||
renderer_.Draw(ed);
|
ws.renderer.Draw(wed);
|
||||||
|
|
||||||
// Render
|
// Render
|
||||||
ImGui::Render();
|
ImGui::Render();
|
||||||
int display_w, display_h;
|
int display_w, display_h;
|
||||||
SDL_GL_GetDrawableSize(window_, &display_w, &display_h);
|
SDL_GL_GetDrawableSize(ws.window, &display_w, &display_h);
|
||||||
glViewport(0, 0, display_w, display_h);
|
glViewport(0, 0, display_w, display_h);
|
||||||
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
|
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
|
||||||
glClear(GL_COLOR_BUFFER_BIT);
|
glClear(GL_COLOR_BUFFER_BIT);
|
||||||
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
||||||
SDL_GL_SwapWindow(window_);
|
SDL_GL_SwapWindow(ws.window);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deferred new-window requests (must happen outside the render loop
|
||||||
|
// to avoid corrupting an in-progress ImGui frame).
|
||||||
|
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
|
||||||
|
Editor &wed = (wi == 0) ? ed : windows_[wi]->editor;
|
||||||
|
if (wed.NewWindowRequested()) {
|
||||||
|
wed.SetNewWindowRequested(false);
|
||||||
|
OpenNewWindow_(ed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove dead secondary windows
|
||||||
|
for (auto it = windows_.begin() + 1; it != windows_.end();) {
|
||||||
|
if (!(*it)->alive) {
|
||||||
|
DestroyWindowResources_(**it);
|
||||||
|
it = windows_.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore primary context
|
||||||
|
if (!windows_.empty()) {
|
||||||
|
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
|
||||||
|
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
GUIFrontend::Shutdown()
|
GUIFrontend::Shutdown()
|
||||||
{
|
{
|
||||||
ImGui_ImplOpenGL3_Shutdown();
|
// Destroy all windows (secondary first, then primary)
|
||||||
ImGui_ImplSDL2_Shutdown();
|
for (auto it = windows_.rbegin(); it != windows_.rend(); ++it) {
|
||||||
ImGui::DestroyContext();
|
DestroyWindowResources_(**it);
|
||||||
|
|
||||||
if (gl_ctx_) {
|
|
||||||
SDL_GL_DeleteContext(gl_ctx_);
|
|
||||||
gl_ctx_ = nullptr;
|
|
||||||
}
|
|
||||||
if (window_) {
|
|
||||||
SDL_DestroyWindow(window_);
|
|
||||||
window_ = nullptr;
|
|
||||||
}
|
}
|
||||||
|
windows_.clear();
|
||||||
SDL_Quit();
|
SDL_Quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +648,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
|||||||
ImFontConfig config;
|
ImFontConfig config;
|
||||||
config.MergeMode = false;
|
config.MergeMode = false;
|
||||||
|
|
||||||
// Load Basic Latin + Latin Supplement
|
|
||||||
io.Fonts->AddFontFromMemoryCompressedTTF(
|
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||||
kte::Fonts::DefaultFontData,
|
kte::Fonts::DefaultFontData,
|
||||||
kte::Fonts::DefaultFontSize,
|
kte::Fonts::DefaultFontSize,
|
||||||
@@ -375,7 +655,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
|||||||
&config,
|
&config,
|
||||||
io.Fonts->GetGlyphRangesDefault());
|
io.Fonts->GetGlyphRangesDefault());
|
||||||
|
|
||||||
// Merge Greek and Mathematical symbols from IosevkaExtended
|
|
||||||
config.MergeMode = true;
|
config.MergeMode = true;
|
||||||
static const ImWchar extended_ranges[] = {
|
static const ImWchar extended_ranges[] = {
|
||||||
0x0370, 0x03FF, // Greek and Coptic
|
0x0370, 0x03FF, // Greek and Coptic
|
||||||
|
|||||||
+29
-6
@@ -2,13 +2,18 @@
|
|||||||
* GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
|
* GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "Frontend.h"
|
#include "Frontend.h"
|
||||||
#include "GUIConfig.h"
|
#include "GUIConfig.h"
|
||||||
#include "ImGuiInputHandler.h"
|
#include "ImGuiInputHandler.h"
|
||||||
#include "ImGuiRenderer.h"
|
#include "ImGuiRenderer.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
|
||||||
struct SDL_Window;
|
struct SDL_Window;
|
||||||
|
struct ImGuiContext;
|
||||||
typedef void *SDL_GLContext;
|
typedef void *SDL_GLContext;
|
||||||
|
|
||||||
class GUIFrontend final : public Frontend {
|
class GUIFrontend final : public Frontend {
|
||||||
@@ -24,13 +29,31 @@ public:
|
|||||||
void Shutdown() override;
|
void Shutdown() override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Per-window state — each window owns its own ImGui context so that
|
||||||
|
// NewFrame/Render cycles are fully independent (ImGui requires exactly
|
||||||
|
// one NewFrame per Render per context).
|
||||||
|
struct WindowState {
|
||||||
|
SDL_Window *window = nullptr;
|
||||||
|
SDL_GLContext gl_ctx = nullptr;
|
||||||
|
ImGuiContext *imgui_ctx = nullptr;
|
||||||
|
ImGuiInputHandler input{};
|
||||||
|
ImGuiRenderer renderer{};
|
||||||
|
Editor editor{};
|
||||||
|
int width = 1280;
|
||||||
|
int height = 800;
|
||||||
|
bool alive = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open a new secondary window sharing the primary editor's buffer list.
|
||||||
|
// Returns false if window creation fails.
|
||||||
|
bool OpenNewWindow_(Editor &primary);
|
||||||
|
|
||||||
|
// Initialize fonts and theme for a given ImGui context (must be current).
|
||||||
|
void SetupImGuiStyle_();
|
||||||
|
static void DestroyWindowResources_(WindowState &ws);
|
||||||
static bool LoadGuiFont_(const char *path, float size_px);
|
static bool LoadGuiFont_(const char *path, float size_px);
|
||||||
|
|
||||||
GUIConfig config_{};
|
GUIConfig config_{};
|
||||||
ImGuiInputHandler input_{};
|
// Primary window (index 0 in windows_); created during Init.
|
||||||
ImGuiRenderer renderer_{};
|
std::vector<std::unique_ptr<WindowState> > windows_;
|
||||||
SDL_Window *window_ = nullptr;
|
|
||||||
SDL_GLContext gl_ctx_ = nullptr;
|
|
||||||
int width_ = 1280;
|
|
||||||
int height_ = 800;
|
|
||||||
};
|
};
|
||||||
+89
-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
|
||||||
@@ -125,7 +139,11 @@ map_key(const SDL_Keycode key,
|
|||||||
case SDLK_KP_ENTER:
|
case SDLK_KP_ENTER:
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
k_ctrl_pending = false;
|
k_ctrl_pending = false;
|
||||||
|
if (mod & KMOD_SHIFT) {
|
||||||
|
out = {true, CommandId::SmartNewline, "", 0};
|
||||||
|
} else {
|
||||||
out = {true, CommandId::Newline, "", 0};
|
out = {true, CommandId::Newline, "", 0};
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
case SDLK_ESCAPE:
|
case SDLK_ESCAPE:
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
@@ -139,6 +157,24 @@ map_key(const SDL_Keycode key,
|
|||||||
|
|
||||||
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
|
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
|
||||||
if (k_prefix) {
|
if (k_prefix) {
|
||||||
|
// A modifier pressed on its own (Shift for C-k C or C-k ^, Ctrl
|
||||||
|
// pressed again for C-k C-d) is not the suffix: keep waiting.
|
||||||
|
switch (key) {
|
||||||
|
case SDLK_LSHIFT:
|
||||||
|
case SDLK_RSHIFT:
|
||||||
|
case SDLK_LCTRL:
|
||||||
|
case SDLK_RCTRL:
|
||||||
|
case SDLK_LALT:
|
||||||
|
case SDLK_RALT:
|
||||||
|
case SDLK_LGUI:
|
||||||
|
case SDLK_RGUI:
|
||||||
|
case SDLK_CAPSLOCK:
|
||||||
|
case SDLK_MODE:
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
esc_meta = false;
|
esc_meta = false;
|
||||||
// Normalize to ASCII; preserve case for letters using Shift
|
// Normalize to ASCII; preserve case for letters using Shift
|
||||||
int ascii_key = 0;
|
int ascii_key = 0;
|
||||||
@@ -178,18 +214,19 @@ map_key(const SDL_Keycode key,
|
|||||||
k_ctrl_pending = false;
|
k_ctrl_pending = false;
|
||||||
CommandId id;
|
CommandId id;
|
||||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||||
|
#if IMGUI_IH_DEBUG
|
||||||
// Diagnostics for u/U
|
// Diagnostics for u/U
|
||||||
if (lower == 'u') {
|
if (lower == 'u') {
|
||||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||||
? static_cast<char>(ascii_key)
|
? static_cast<char>(ascii_key)
|
||||||
: '?';
|
: '?';
|
||||||
std::fprintf(stderr,
|
IH_LOGF(
|
||||||
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
||||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||||
mapped ? static_cast<int>(id) : -1);
|
mapped ? static_cast<int>(id) : -1);
|
||||||
std::fflush(stderr);
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
out = {true, id, "", 0};
|
out = {true, id, "", 0};
|
||||||
if (ed)
|
if (ed)
|
||||||
@@ -327,12 +364,49 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
case SDL_KEYDOWN: {
|
case SDL_KEYDOWN: {
|
||||||
|
// A suppression request covers only the text event of the key that
|
||||||
|
// made it, which arrives before the next key press. Chords such as
|
||||||
|
// Ctrl+V produce no text event at all, and a leftover request ate
|
||||||
|
// the next character typed (B8).
|
||||||
|
suppress_text_input_once_ = false;
|
||||||
// Remember state before mapping; used for TEXTINPUT suppression heuristics
|
// Remember state before mapping; used for TEXTINPUT suppression heuristics
|
||||||
const bool was_k_prefix = k_prefix_;
|
const bool was_k_prefix = k_prefix_;
|
||||||
const bool was_esc_meta = esc_meta_;
|
const bool was_esc_meta = esc_meta_;
|
||||||
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
|
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
|
||||||
const SDL_Keycode key = e.key.keysym.sym;
|
const SDL_Keycode key = e.key.keysym.sym;
|
||||||
|
|
||||||
|
// New window: Cmd+N (macOS) or Ctrl+Shift+N (Linux/Windows)
|
||||||
|
{
|
||||||
|
const bool gui_n = (mods & KMOD_GUI) && !(mods & KMOD_CTRL) && (key == SDLK_n);
|
||||||
|
const bool ctrl_sn = (mods & KMOD_CTRL) && (mods & KMOD_SHIFT) && (key == SDLK_n);
|
||||||
|
if (gui_n || ctrl_sn) {
|
||||||
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
|
q_.push(MappedInput{true, CommandId::NewWindow, std::string(), 0});
|
||||||
|
suppress_text_input_once_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font zoom: Cmd+=/Cmd+-/Cmd+0 (macOS) or Ctrl+=/Ctrl+-/Ctrl+0
|
||||||
|
if ((mods & (KMOD_CTRL | KMOD_GUI)) && !(mods & KMOD_SHIFT)) {
|
||||||
|
bool is_zoom = true;
|
||||||
|
CommandId zoom_cmd = CommandId::FontZoomIn;
|
||||||
|
if (key == SDLK_EQUALS || key == SDLK_PLUS)
|
||||||
|
zoom_cmd = CommandId::FontZoomIn;
|
||||||
|
else if (key == SDLK_MINUS)
|
||||||
|
zoom_cmd = CommandId::FontZoomOut;
|
||||||
|
else if (key == SDLK_0)
|
||||||
|
zoom_cmd = CommandId::FontZoomReset;
|
||||||
|
else
|
||||||
|
is_zoom = false;
|
||||||
|
if (is_zoom) {
|
||||||
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
|
q_.push(MappedInput{true, zoom_cmd, std::string(), 0});
|
||||||
|
suppress_text_input_once_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS)
|
// Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS)
|
||||||
// Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode.
|
// Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode.
|
||||||
if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) {
|
if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) {
|
||||||
@@ -340,32 +414,15 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
if (clip) {
|
if (clip) {
|
||||||
std::string text(clip);
|
std::string text(clip);
|
||||||
SDL_free(clip);
|
SDL_free(clip);
|
||||||
// Split on '\n' and enqueue as InsertText/Newline commands
|
// Turn line breaks (\n, \r\n, or bare \r) into Newline
|
||||||
|
// commands and the rest into InsertText; InsertText itself
|
||||||
|
// rejects any embedded '\r'/'\n'.
|
||||||
|
// Into a prompt, line breaks must not become Newline: that
|
||||||
|
// would accept the prompt and send the rest to the buffer.
|
||||||
|
const bool to_prompt = ed_ && ed_->PromptActive();
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
std::size_t start = 0;
|
for (const auto &cmd: to_prompt ? SplitPasteForPrompt(text) : SplitPasteIntoCommands(text))
|
||||||
while (start <= text.size()) {
|
q_.push(cmd);
|
||||||
std::size_t pos = text.find('\n', start);
|
|
||||||
std::string_view segment;
|
|
||||||
bool has_nl = (pos != std::string::npos);
|
|
||||||
if (has_nl) {
|
|
||||||
segment = std::string_view(text).substr(start, pos - start);
|
|
||||||
} else {
|
|
||||||
segment = std::string_view(text).substr(start);
|
|
||||||
}
|
|
||||||
if (!segment.empty()) {
|
|
||||||
MappedInput ins{
|
|
||||||
true, CommandId::InsertText, std::string(segment), 0
|
|
||||||
};
|
|
||||||
q_.push(ins);
|
|
||||||
}
|
|
||||||
if (has_nl) {
|
|
||||||
MappedInput nl{true, CommandId::Newline, std::string(), 0};
|
|
||||||
q_.push(nl);
|
|
||||||
start = pos + 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Suppress the corresponding TEXTINPUT that may follow
|
// Suppress the corresponding TEXTINPUT that may follow
|
||||||
suppress_text_input_once_ = true;
|
suppress_text_input_once_ = true;
|
||||||
return true; // consumed
|
return true; // consumed
|
||||||
@@ -488,15 +545,17 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
bool pass_ctrl = k_ctrl_pending_;
|
bool pass_ctrl = k_ctrl_pending_;
|
||||||
k_ctrl_pending_ = false;
|
k_ctrl_pending_ = false;
|
||||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||||
|
#if IMGUI_IH_DEBUG
|
||||||
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
||||||
|
{
|
||||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||||
? static_cast<char>(ascii_key)
|
? static_cast<char>(ascii_key)
|
||||||
: '?';
|
: '?';
|
||||||
std::fprintf(stderr,
|
IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||||
"[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
|
||||||
ascii_key, disp, mapped ? 1 : 0,
|
ascii_key, disp, mapped ? 1 : 0,
|
||||||
mapped ? static_cast<int>(id) : -1);
|
mapped ? static_cast<int>(id) : -1);
|
||||||
std::fflush(stderr);
|
}
|
||||||
|
#endif
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
mi = {true, id, "", 0};
|
mi = {true, id, "", 0};
|
||||||
if (ed_)
|
if (ed_)
|
||||||
|
|||||||
+254
-228
@@ -7,9 +7,10 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
#include <regex>
|
|
||||||
|
|
||||||
#include "ImGuiRenderer.h"
|
#include "ImGuiRenderer.h"
|
||||||
|
#include "SearchHighlight.h"
|
||||||
|
#include "TermWidth.h"
|
||||||
#include "Highlight.h"
|
#include "Highlight.h"
|
||||||
#include "GUITheme.h"
|
#include "GUITheme.h"
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
@@ -66,7 +67,9 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
if (!buf) {
|
if (!buf) {
|
||||||
ImGui::TextUnformatted("[no buffer]");
|
ImGui::TextUnformatted("[no buffer]");
|
||||||
} else {
|
} else {
|
||||||
const auto &lines = buf->Rows();
|
// Per-row access only: Rows() rebuilds a string for every line of the
|
||||||
|
// file after each edit, which made every frame O(file size).
|
||||||
|
const std::size_t nlines = buf->Nrows();
|
||||||
std::size_t cy = buf->Cury();
|
std::size_t cy = buf->Cury();
|
||||||
std::size_t cx = buf->Curx();
|
std::size_t cx = buf->Curx();
|
||||||
const float line_h = ImGui::GetTextLineHeight();
|
const float line_h = ImGui::GetTextLineHeight();
|
||||||
@@ -76,23 +79,18 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
// Two-way sync between Buffer::Rowoffs and ImGui scroll position:
|
// Two-way sync between Buffer::Rowoffs and ImGui scroll position:
|
||||||
// - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it.
|
// - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it.
|
||||||
// - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view.
|
// - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view.
|
||||||
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
|
|
||||||
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
|
|
||||||
|
|
||||||
const long buf_rowoffs = static_cast<long>(buf->Rowoffs());
|
const long buf_rowoffs = static_cast<long>(buf->Rowoffs());
|
||||||
const long buf_coloffs = static_cast<long>(buf->Coloffs());
|
const long buf_coloffs = static_cast<long>(buf->Coloffs());
|
||||||
|
|
||||||
// Detect programmatic change (e.g., page_down command changed rowoffs)
|
// Detect programmatic change (e.g., page_down command changed rowoffs)
|
||||||
// Use SetNextWindowScroll BEFORE BeginChild to set initial scroll position
|
// Use SetNextWindowScroll BEFORE BeginChild to set initial scroll position
|
||||||
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
|
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
|
||||||
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
||||||
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
|
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
|
||||||
}
|
}
|
||||||
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) {
|
// Horizontal scroll is handled purely in pixel space (see
|
||||||
float target_x = static_cast<float>(buf_coloffs) * space_w;
|
// cursor-visibility block after the line loop) so we don't
|
||||||
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
// convert the character-based coloffs to an ImGui scroll here.
|
||||||
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reserve space for status bar at bottom.
|
// Reserve space for status bar at bottom.
|
||||||
// We calculate a height that is an exact multiple of the line height
|
// We calculate a height that is an exact multiple of the line height
|
||||||
@@ -111,44 +109,81 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImVec2 child_window_pos = ImGui::GetWindowPos();
|
ImVec2 child_window_pos = ImGui::GetWindowPos();
|
||||||
float scroll_y = ImGui::GetScrollY();
|
float scroll_y = ImGui::GetScrollY();
|
||||||
float scroll_x = ImGui::GetScrollX();
|
float scroll_x = ImGui::GetScrollX();
|
||||||
std::size_t rowoffs = 0; // we render from the first line; scrolling is handled by ImGui
|
|
||||||
|
|
||||||
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
|
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
|
||||||
bool forced_scroll = false;
|
bool forced_scroll = false;
|
||||||
{
|
{
|
||||||
static float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels
|
|
||||||
static float prev_scroll_x = -1.0f; // previous frame's ImGui scroll X in pixels
|
|
||||||
|
|
||||||
const long scroll_top = static_cast<long>(scroll_y / row_h);
|
const long scroll_top = static_cast<long>(scroll_y / row_h);
|
||||||
const long scroll_left = static_cast<long>(scroll_x / space_w);
|
|
||||||
|
|
||||||
// Check if rowoffs was programmatically changed this frame
|
// Check if rowoffs was programmatically changed this frame
|
||||||
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
|
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
|
||||||
forced_scroll = true;
|
forced_scroll = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user scrolled (not programmatic), update buffer offsets accordingly
|
// If user scrolled vertically (not programmatic), update buffer row offset
|
||||||
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y && !forced_scroll) {
|
if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
|
||||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||||
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
|
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
|
||||||
mbuf->Coloffs());
|
mbuf->Coloffs());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (prev_scroll_x >= 0.0f && scroll_x != prev_scroll_x && !forced_scroll) {
|
// Horizontal scroll is pixel-based and managed by the cursor
|
||||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
// visibility block below; we don't sync it back to coloffs.
|
||||||
mbuf->SetOffsets(mbuf->Rowoffs(),
|
|
||||||
static_cast<std::size_t>(std::max(0L, scroll_left)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update trackers for next frame
|
// Update trackers for next frame
|
||||||
prev_scroll_y = scroll_y;
|
prev_scroll_y_ = scroll_y;
|
||||||
prev_scroll_x = scroll_x;
|
prev_scroll_x_ = scroll_x;
|
||||||
|
}
|
||||||
|
prev_buf_rowoffs_ = buf_rowoffs;
|
||||||
|
prev_buf_coloffs_ = buf_coloffs;
|
||||||
|
|
||||||
|
// Max-line-width cache: reset when the buffer or font changes. We only
|
||||||
|
// measure visible lines per frame and maintain a running max, so the
|
||||||
|
// scrollbar may be slightly conservative on first view of a file until
|
||||||
|
// the user scrolls, but idle CPU drops dramatically on large files.
|
||||||
|
// Edits bump the buffer version but we intentionally do NOT reset the
|
||||||
|
// max on version changes — keeping a conservative (possibly too-wide)
|
||||||
|
// scrollbar is preferable to per-keystroke jitter.
|
||||||
|
{
|
||||||
|
ImFont *cur_font = ImGui::GetFont();
|
||||||
|
float cur_fsize = ImGui::GetFontSize();
|
||||||
|
if (buf != max_width_buf_
|
||||||
|
|| cur_font != max_width_font_
|
||||||
|
|| cur_fsize != max_width_font_size_) {
|
||||||
|
max_width_buf_ = buf;
|
||||||
|
max_width_font_ = cur_font;
|
||||||
|
max_width_font_size_ = cur_fsize;
|
||||||
|
max_width_px_ = 0.0f;
|
||||||
|
}
|
||||||
|
max_width_version_ = buf->Version();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search matches: the pattern is compiled once per frame (compiling it
|
||||||
|
// per line per frame was a large source of idle CPU on macOS).
|
||||||
|
const kte::SearchHighlight search_hl(ed);
|
||||||
|
const bool search_mode = search_hl.Active();
|
||||||
|
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||||
|
|
||||||
|
// Compute the visible row range and skip rendering work for off-screen
|
||||||
|
// lines. ImGui clips drawing, but string allocation, tab expansion,
|
||||||
|
// syntax-highlight lookups, and width measurement are not free.
|
||||||
|
const std::size_t total_rows = nlines;
|
||||||
|
std::size_t first_vis = 0;
|
||||||
|
std::size_t last_vis = total_rows; // exclusive
|
||||||
|
if (row_h > 0.0f && total_rows > 0) {
|
||||||
|
const long margin = 4; // render a few extra rows above/below for smoother scrolling
|
||||||
|
long fv = static_cast<long>(std::floor(scroll_y / row_h)) - margin;
|
||||||
|
long lv = static_cast<long>(
|
||||||
|
std::ceil((scroll_y + child_h_plan) / row_h)) + margin;
|
||||||
|
if (fv < 0) fv = 0;
|
||||||
|
if (lv < 0) lv = 0;
|
||||||
|
if (static_cast<std::size_t>(fv) > total_rows)
|
||||||
|
fv = static_cast<long>(total_rows);
|
||||||
|
if (static_cast<std::size_t>(lv) > total_rows)
|
||||||
|
lv = static_cast<long>(total_rows);
|
||||||
|
first_vis = static_cast<std::size_t>(fv);
|
||||||
|
last_vis = static_cast<std::size_t>(lv);
|
||||||
}
|
}
|
||||||
prev_buf_rowoffs = buf_rowoffs;
|
|
||||||
prev_buf_coloffs = buf_coloffs;
|
|
||||||
// Cache current horizontal offset in rendered columns for click handling
|
|
||||||
const std::size_t coloffs_now = buf->Coloffs();
|
|
||||||
|
|
||||||
// Mark selection state (mark -> cursor), in source coordinates
|
// Mark selection state (mark -> cursor), in source coordinates
|
||||||
bool sel_active = false;
|
bool sel_active = false;
|
||||||
@@ -169,7 +204,7 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
||||||
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
||||||
|
|
||||||
static bool mouse_selecting = false;
|
// (mouse_selecting__ is a member variable)
|
||||||
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
|
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
|
||||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||||
// Convert mouse pos to buffer row
|
// Convert mouse pos to buffer row
|
||||||
@@ -178,108 +213,155 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
if (by_l < 0)
|
if (by_l < 0)
|
||||||
by_l = 0;
|
by_l = 0;
|
||||||
std::size_t by = static_cast<std::size_t>(by_l);
|
std::size_t by = static_cast<std::size_t>(by_l);
|
||||||
if (by >= lines.size())
|
if (by >= nlines)
|
||||||
by = lines.empty() ? 0 : (lines.size() - 1);
|
by = (nlines == 0) ? 0 : (nlines - 1);
|
||||||
|
|
||||||
// Convert mouse pos to rendered x
|
if (nlines == 0)
|
||||||
|
return {0, 0};
|
||||||
|
|
||||||
|
// Expand tabs for the clicked line
|
||||||
|
std::string line_clicked = buf->GetLineString(by);
|
||||||
|
const std::size_t tabw = kte::kTabWidth;
|
||||||
|
std::string click_expanded;
|
||||||
|
click_expanded.reserve(line_clicked.size() + 16);
|
||||||
|
std::size_t click_rx = 0;
|
||||||
|
// Map: source column -> expanded column
|
||||||
|
std::vector<std::size_t> src_to_exp;
|
||||||
|
src_to_exp.reserve(line_clicked.size() + 1);
|
||||||
|
for (std::size_t ci = 0; ci < line_clicked.size(); ++ci) {
|
||||||
|
src_to_exp.push_back(click_rx);
|
||||||
|
if (line_clicked[ci] == '\t') {
|
||||||
|
std::size_t adv = (tabw - (click_rx % tabw));
|
||||||
|
click_expanded.append(adv, ' ');
|
||||||
|
click_rx += adv;
|
||||||
|
} else {
|
||||||
|
click_expanded.push_back(line_clicked[ci]);
|
||||||
|
click_rx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src_to_exp.push_back(click_rx); // past-end position
|
||||||
|
|
||||||
|
// Pixel x relative to the line start (accounting for scroll)
|
||||||
float visual_x = mp.x - child_window_pos.x;
|
float visual_x = mp.x - child_window_pos.x;
|
||||||
if (visual_x < 0.0f)
|
if (visual_x < 0.0f)
|
||||||
visual_x = 0.0f;
|
visual_x = 0.0f;
|
||||||
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
|
// Add scroll offset in pixels
|
||||||
|
visual_x += scroll_x;
|
||||||
|
|
||||||
// Convert rendered column to source column
|
// Find the source column whose expanded position is closest
|
||||||
if (lines.empty())
|
// to the click pixel, using actual text measurement.
|
||||||
return {0, 0};
|
|
||||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
|
||||||
const std::size_t tabw = 8;
|
|
||||||
std::size_t rx = 0;
|
|
||||||
std::size_t best_col = 0;
|
std::size_t best_col = 0;
|
||||||
float best_dist = std::numeric_limits<float>::infinity();
|
float best_dist = std::numeric_limits<float>::infinity();
|
||||||
float clicked_rx_f = static_cast<float>(clicked_rx);
|
for (std::size_t ci = 0; ci <= line_clicked.size(); ++ci) {
|
||||||
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
|
std::size_t exp_col = src_to_exp[ci];
|
||||||
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
|
float px = 0.0f;
|
||||||
|
if (exp_col > 0 && !click_expanded.empty()) {
|
||||||
|
std::size_t end = std::min(click_expanded.size(), exp_col);
|
||||||
|
px = ImGui::CalcTextSize(click_expanded.c_str(),
|
||||||
|
click_expanded.c_str() + end).x;
|
||||||
|
}
|
||||||
|
float dist = std::fabs(visual_x - px);
|
||||||
if (dist < best_dist) {
|
if (dist < best_dist) {
|
||||||
best_dist = dist;
|
best_dist = dist;
|
||||||
best_col = i;
|
best_col = ci;
|
||||||
}
|
|
||||||
if (i < line_clicked.size()) {
|
|
||||||
rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {by, best_col};
|
return {by, best_col};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mouse-driven selection: set mark on press, update cursor on drag
|
// Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag
|
||||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||||
mouse_selecting = true;
|
mouse_selecting_ = true;
|
||||||
auto [by, bx] = mouse_pos_to_buf();
|
auto [by, bx] = mouse_pos_to_buf();
|
||||||
char tmp[64];
|
char tmp[64];
|
||||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||||
|
|
||||||
|
// Only set mark on double click.
|
||||||
|
// Dragging will also set the mark if not already set (handled below).
|
||||||
|
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
|
||||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||||
mbuf->SetMark(bx, by);
|
mbuf->SetMark(bx, by);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
}
|
||||||
|
if (mouse_selecting_ && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||||
auto [by, bx] = mouse_pos_to_buf();
|
auto [by, bx] = mouse_pos_to_buf();
|
||||||
|
// If we are dragging (mouse moved while down), ensure mark is set to start selection
|
||||||
|
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
|
||||||
|
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||||
|
if (!mbuf->MarkSet()) {
|
||||||
|
// We'd need to convert click_pos to buf coords, but it's complex here.
|
||||||
|
// Setting it to where the cursor was *before* we started moving it
|
||||||
|
// in this frame is a good approximation, or just using current.
|
||||||
|
mbuf->SetMark(mbuf->Curx(), mbuf->Cury());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
char tmp[64];
|
char tmp[64];
|
||||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||||
}
|
}
|
||||||
if (mouse_selecting && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
||||||
mouse_selecting = false;
|
mouse_selecting_ = false;
|
||||||
}
|
}
|
||||||
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
// Advance the cursor Y so the first visible line draws at its correct
|
||||||
|
// scroll position. Skipped rows simply leave the layout cursor untouched.
|
||||||
|
if (first_vis > 0) {
|
||||||
|
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
|
||||||
|
static_cast<float>(first_vis) * row_h);
|
||||||
|
}
|
||||||
|
for (std::size_t i = first_vis; i < last_vis; ++i) {
|
||||||
// Capture the screen position before drawing the line
|
// Capture the screen position before drawing the line
|
||||||
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
||||||
std::string line = static_cast<std::string>(lines[i]);
|
std::string line = buf->GetLineString(i);
|
||||||
|
|
||||||
// Expand tabs to spaces with width=8 and apply horizontal scroll offset
|
// Expand tabs to spaces with width=8, recording each source
|
||||||
const std::size_t tabw = 8;
|
// byte's rendered column (rx_of[line.size()] is the line's width).
|
||||||
|
const std::size_t tabw = kte::kTabWidth;
|
||||||
std::string expanded;
|
std::string expanded;
|
||||||
expanded.reserve(line.size() + 16);
|
expanded.reserve(line.size() + 16);
|
||||||
std::size_t rx_abs_draw = 0; // rendered column for drawing
|
std::vector<std::size_t> rx_of(line.size() + 1);
|
||||||
// Compute search highlight ranges for this line in source indices
|
for (std::size_t src = 0; src < line.size(); ++src) {
|
||||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
rx_of[src] = expanded.size();
|
||||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
if (line[src] == '\t')
|
||||||
if (search_mode) {
|
expanded.append(tabw - (expanded.size() % tabw), ' ');
|
||||||
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
|
|
||||||
if (ed.PromptActive() && (
|
|
||||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
|
||||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
|
||||||
try {
|
|
||||||
std::regex rx(ed.SearchQuery());
|
|
||||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
|
|
||||||
it != std::sregex_iterator(); ++it) {
|
|
||||||
const auto &m = *it;
|
|
||||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
|
||||||
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
|
||||||
hl_src_ranges.emplace_back(sx, ex);
|
|
||||||
}
|
|
||||||
} catch (const std::regex_error &) {
|
|
||||||
// ignore invalid patterns here; status line already shows the error
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const std::string &q = ed.SearchQuery();
|
|
||||||
std::size_t pos = 0;
|
|
||||||
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
|
|
||||||
hl_src_ranges.emplace_back(pos, pos + q.size());
|
|
||||||
pos += q.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
|
|
||||||
std::size_t rx = 0;
|
|
||||||
std::size_t s = 0;
|
|
||||||
while (s < upto_src_exclusive && s < line.size()) {
|
|
||||||
if (line[s] == '\t')
|
|
||||||
rx += (tabw - (rx % tabw));
|
|
||||||
else
|
else
|
||||||
rx += 1;
|
expanded.push_back(line[src]);
|
||||||
++s;
|
|
||||||
}
|
}
|
||||||
return rx;
|
rx_of[line.size()] = expanded.size();
|
||||||
|
auto src_to_rx = [&](std::size_t src) -> std::size_t {
|
||||||
|
return rx_of[std::min(src, line.size())];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rendered column -> pixel offset from the start of the line.
|
||||||
|
// Measuring from column 0 for every span was quadratic in line
|
||||||
|
// length; measure on from the nearest earlier position already
|
||||||
|
// measured (only character boundaries are kept, so a multi-byte
|
||||||
|
// character is never measured in pieces).
|
||||||
|
std::vector<std::pair<std::size_t, float> > px_marks{{0, 0.0f}}; // unrounded
|
||||||
|
auto rx_to_px = [&](std::size_t rx_col) -> float {
|
||||||
|
const std::size_t end = std::min(expanded.size(), rx_col);
|
||||||
|
auto it = std::upper_bound(px_marks.begin(), px_marks.end(), end,
|
||||||
|
[](std::size_t v, const std::pair<std::size_t, float> &m) {
|
||||||
|
return v < m.first;
|
||||||
|
});
|
||||||
|
--it; // px_marks[0] is column 0
|
||||||
|
float px = it->second;
|
||||||
|
if (it->first != end) {
|
||||||
|
px += ImGui::GetFont()->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f,
|
||||||
|
expanded.c_str() + it->first,
|
||||||
|
expanded.c_str() + end).x;
|
||||||
|
if (end == expanded.size() || (static_cast<unsigned char>(expanded[end]) & 0xC0) != 0x80)
|
||||||
|
px_marks.insert(it + 1, {end, px});
|
||||||
|
}
|
||||||
|
// Rounded up as ImGui::CalcTextSize does (sums of rounded
|
||||||
|
// pieces would drift).
|
||||||
|
return end == 0 ? 0.0f : static_cast<float>(static_cast<int>(px + 0.99999f));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search highlight ranges for this line in source indices
|
||||||
|
search_hl.Ranges(line, hl_src_ranges);
|
||||||
// Draw background highlights (under text)
|
// Draw background highlights (under text)
|
||||||
if (search_mode && !hl_src_ranges.empty()) {
|
if (search_mode && !hl_src_ranges.empty()) {
|
||||||
// Current match emphasis
|
// Current match emphasis
|
||||||
@@ -290,13 +372,8 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
std::size_t sx = rg.first, ex = rg.second;
|
std::size_t sx = rg.first, ex = rg.second;
|
||||||
std::size_t rx_start = src_to_rx(sx);
|
std::size_t rx_start = src_to_rx(sx);
|
||||||
std::size_t rx_end = src_to_rx(ex);
|
std::size_t rx_end = src_to_rx(ex);
|
||||||
// Apply horizontal scroll offset
|
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y);
|
||||||
if (rx_end <= coloffs_now)
|
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||||
continue; // fully left of view
|
|
||||||
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
|
|
||||||
std::size_t vx1 = rx_end - coloffs_now;
|
|
||||||
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
|
|
||||||
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
|
||||||
line_pos.y + line_h);
|
line_pos.y + line_h);
|
||||||
// Choose color: current match stronger
|
// Choose color: current match stronger
|
||||||
bool is_current = has_current && sx == cur_x && ex == cur_end;
|
bool is_current = has_current && sx == cur_x && ex == cur_end;
|
||||||
@@ -333,20 +410,14 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
if (line_has) {
|
if (line_has) {
|
||||||
std::size_t rx_start = src_to_rx(sx);
|
std::size_t rx_start = src_to_rx(sx);
|
||||||
std::size_t rx_end = src_to_rx(ex);
|
std::size_t rx_end = src_to_rx(ex);
|
||||||
if (rx_end > coloffs_now) {
|
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
||||||
std::size_t vx0 = (rx_start > coloffs_now)
|
|
||||||
? (rx_start - coloffs_now)
|
|
||||||
: 0;
|
|
||||||
std::size_t vx1 = rx_end - coloffs_now;
|
|
||||||
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
|
|
||||||
line_pos.y);
|
line_pos.y);
|
||||||
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||||
line_pos.y + line_h);
|
line_pos.y + line_h);
|
||||||
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
||||||
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
|
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
|
||||||
const std::size_t spot_sx = std::min(buf->Curx(), line.size());
|
const std::size_t spot_sx = std::min(buf->Curx(), line.size());
|
||||||
@@ -358,87 +429,31 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
// EOL spot: draw a 1-cell highlight just past the last character.
|
// EOL spot: draw a 1-cell highlight just past the last character.
|
||||||
rx_end = rx_start + 1;
|
rx_end = rx_start + 1;
|
||||||
}
|
}
|
||||||
if (rx_end > coloffs_now) {
|
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
||||||
std::size_t vx0 = (rx_start > coloffs_now)
|
|
||||||
? (rx_start - coloffs_now)
|
|
||||||
: 0;
|
|
||||||
std::size_t vx1 = rx_end - coloffs_now;
|
|
||||||
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
|
|
||||||
line_pos.y);
|
line_pos.y);
|
||||||
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||||
line_pos.y + line_h);
|
line_pos.y + line_h);
|
||||||
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Emit entire line to an expanded buffer (tabs -> spaces)
|
|
||||||
for (std::size_t src = 0; src < line.size(); ++src) {
|
|
||||||
char c = line[src];
|
|
||||||
if (c == '\t') {
|
|
||||||
std::size_t adv = (tabw - (rx_abs_draw % tabw));
|
|
||||||
expanded.append(adv, ' ');
|
|
||||||
rx_abs_draw += adv;
|
|
||||||
} else {
|
|
||||||
expanded.push_back(c);
|
|
||||||
rx_abs_draw += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw syntax-colored runs (text above background highlights)
|
// Draw syntax-colored runs (text above background highlights)
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||||
kte::LineHighlight lh = buf->Highlighter()->GetLine(
|
const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
|
||||||
*buf, static_cast<int>(i), buf->Version());
|
*buf, static_cast<int>(i), buf->Version());
|
||||||
// Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties
|
std::vector<kte::HighlightSpan> spans; // clamped, ordered, whole characters
|
||||||
struct SSpan {
|
kte::SanitizeSpans(line, lh.spans, spans);
|
||||||
std::size_t s;
|
|
||||||
std::size_t e;
|
|
||||||
kte::TokenKind k;
|
|
||||||
};
|
|
||||||
std::vector<SSpan> spans;
|
|
||||||
spans.reserve(lh.spans.size());
|
|
||||||
const std::size_t line_len = line.size();
|
|
||||||
for (const auto &sp: lh.spans) {
|
|
||||||
int s_raw = sp.col_start;
|
|
||||||
int e_raw = sp.col_end;
|
|
||||||
if (e_raw < s_raw)
|
|
||||||
std::swap(e_raw, s_raw);
|
|
||||||
std::size_t s = static_cast<std::size_t>(std::max(
|
|
||||||
0, std::min(s_raw, static_cast<int>(line_len))));
|
|
||||||
std::size_t e = static_cast<std::size_t>(std::max(
|
|
||||||
static_cast<int>(s), std::min(e_raw, static_cast<int>(line_len))));
|
|
||||||
if (e <= s)
|
|
||||||
continue;
|
|
||||||
spans.push_back(SSpan{s, e, sp.kind});
|
|
||||||
}
|
|
||||||
std::sort(spans.begin(), spans.end(), [](const SSpan &a, const SSpan &b) {
|
|
||||||
return a.s < b.s;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to convert a src column to expanded rx position
|
|
||||||
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
|
|
||||||
std::size_t rx = 0;
|
|
||||||
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
|
|
||||||
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
|
|
||||||
}
|
|
||||||
return rx;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const auto &sp: spans) {
|
for (const auto &sp: spans) {
|
||||||
std::size_t rx_s = src_to_rx_full(sp.s);
|
std::size_t rx_s = src_to_rx(static_cast<std::size_t>(sp.col_start));
|
||||||
std::size_t rx_e = src_to_rx_full(sp.e);
|
std::size_t rx_e = src_to_rx(static_cast<std::size_t>(sp.col_end));
|
||||||
if (rx_e <= coloffs_now)
|
std::size_t draw_start = rx_s;
|
||||||
continue; // fully left of viewport
|
|
||||||
// Clamp to visible portion and expanded length
|
|
||||||
std::size_t draw_start = (rx_s > coloffs_now) ? rx_s : coloffs_now;
|
|
||||||
if (draw_start >= expanded.size())
|
if (draw_start >= expanded.size())
|
||||||
continue; // fully right of expanded text
|
continue;
|
||||||
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
|
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
|
||||||
if (draw_end <= draw_start)
|
if (draw_end <= draw_start)
|
||||||
continue;
|
continue;
|
||||||
// Screen position is relative to coloffs_now
|
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
|
||||||
std::size_t screen_x = draw_start - coloffs_now;
|
ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start),
|
||||||
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
|
|
||||||
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(screen_x) * space_w,
|
|
||||||
line_pos.y);
|
line_pos.y);
|
||||||
ImGui::GetWindowDrawList()->AddText(
|
ImGui::GetWindowDrawList()->AddText(
|
||||||
p, col, expanded.c_str() + draw_start, expanded.c_str() + draw_end);
|
p, col, expanded.c_str() + draw_start, expanded.c_str() + draw_end);
|
||||||
@@ -447,49 +462,49 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
// Use row_h (with spacing) to match click calculation and ensure consistent line positions.
|
// Use row_h (with spacing) to match click calculation and ensure consistent line positions.
|
||||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||||
} else {
|
} else {
|
||||||
// No syntax: draw as one run, accounting for horizontal scroll offset
|
// No syntax: draw the full line; ImGui scroll handles clipping.
|
||||||
if (coloffs_now < expanded.size()) {
|
if (!expanded.empty()) {
|
||||||
ImVec2 p = ImVec2(line_pos.x, line_pos.y);
|
ImVec2 p = ImVec2(line_pos.x, line_pos.y);
|
||||||
ImGui::GetWindowDrawList()->AddText(
|
ImGui::GetWindowDrawList()->AddText(
|
||||||
p, ImGui::GetColorU32(ImGuiCol_Text),
|
p, ImGui::GetColorU32(ImGuiCol_Text),
|
||||||
expanded.c_str() + coloffs_now);
|
expanded.c_str());
|
||||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
|
||||||
} else {
|
|
||||||
// Line is fully scrolled out of view horizontally
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
|
||||||
}
|
}
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw a visible cursor indicator on the current line
|
// Draw a visible cursor indicator on the current line
|
||||||
if (i == cy) {
|
if (i == cy) {
|
||||||
// Compute rendered X (rx) from source column with tab expansion
|
std::size_t rx_abs = src_to_rx(cx);
|
||||||
std::size_t rx_abs = 0;
|
float cursor_px = rx_to_px(rx_abs);
|
||||||
for (std::size_t k = 0; k < std::min(cx, line.size()); ++k) {
|
|
||||||
if (line[k] == '\t')
|
|
||||||
rx_abs += (tabw - (rx_abs % tabw));
|
|
||||||
else
|
|
||||||
rx_abs += 1;
|
|
||||||
}
|
|
||||||
// Convert to viewport x by subtracting horizontal col offset
|
|
||||||
std::size_t rx_viewport = (rx_abs > coloffs_now) ? (rx_abs - coloffs_now) : 0;
|
|
||||||
// For proportional fonts (Linux GUI), avoid accumulating drift by computing
|
|
||||||
// the exact pixel width of the expanded substring up to the cursor.
|
|
||||||
// expanded contains the line with tabs expanded to spaces and is what we draw.
|
|
||||||
float cursor_px = 0.0f;
|
|
||||||
if (rx_viewport > 0 && coloffs_now < expanded.size()) {
|
|
||||||
std::size_t start = coloffs_now;
|
|
||||||
std::size_t end = std::min(expanded.size(), start + rx_viewport);
|
|
||||||
// Measure substring width in pixels
|
|
||||||
ImVec2 sz = ImGui::CalcTextSize(expanded.c_str() + start,
|
|
||||||
expanded.c_str() + end);
|
|
||||||
cursor_px = sz.x;
|
|
||||||
}
|
|
||||||
ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y);
|
ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y);
|
||||||
ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h);
|
ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h);
|
||||||
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
|
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
|
||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track widest line for content width reporting. We only measure
|
||||||
|
// visible lines and fold the result into a monotonic max cached on
|
||||||
|
// the renderer (see max_width_* members). Off-screen lines are not
|
||||||
|
// measured per frame; if the user scrolls or edits, the cache is
|
||||||
|
// refreshed accordingly.
|
||||||
|
if (!expanded.empty()) {
|
||||||
|
const float line_w = rx_to_px(expanded.size());
|
||||||
|
if (line_w > max_width_px_)
|
||||||
|
max_width_px_ = line_w;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// After the visible-range loop, advance the layout cursor to the end of
|
||||||
|
// the (virtual) content so ImGui sees the correct total size. Vertical
|
||||||
|
// height comes from total_rows; horizontal width comes from the cached
|
||||||
|
// max line width. A Dummy at the final position records both.
|
||||||
|
if (total_rows > last_vis) {
|
||||||
|
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
|
||||||
|
static_cast<float>(total_rows - last_vis) * row_h);
|
||||||
|
}
|
||||||
|
if (max_width_px_ > 0.0f) {
|
||||||
|
ImGui::SetCursorPosX(max_width_px_);
|
||||||
|
}
|
||||||
|
ImGui::Dummy(ImVec2(0, 0));
|
||||||
// Synchronize cursor and scrolling after rendering all lines so content size is known.
|
// Synchronize cursor and scrolling after rendering all lines so content size is known.
|
||||||
{
|
{
|
||||||
float child_h_actual = ImGui::GetWindowHeight();
|
float child_h_actual = ImGui::GetWindowHeight();
|
||||||
@@ -529,29 +544,40 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
last_row = first_row + vis_rows - 1;
|
last_row = first_row + vis_rows - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizontal scroll: ensure cursor column is visible
|
// Horizontal scroll: ensure cursor is visible (pixel-based for proportional fonts)
|
||||||
long vis_cols = static_cast<long>(std::round(child_w_actual / space_w));
|
float cursor_px_abs = 0.0f;
|
||||||
if (vis_cols < 1)
|
if (cy < nlines) {
|
||||||
vis_cols = 1;
|
std::string cur_line = buf->GetLineString(cy);
|
||||||
long first_col = static_cast<long>(scroll_x_now / space_w);
|
const std::size_t tabw = kte::kTabWidth;
|
||||||
long last_col = first_col + vis_cols - 1;
|
// Expand tabs for cursor line to measure pixel position
|
||||||
|
std::string cur_expanded;
|
||||||
std::size_t cursor_rx = 0;
|
cur_expanded.reserve(cur_line.size() + 16);
|
||||||
if (cy < lines.size()) {
|
std::size_t cur_rx = 0;
|
||||||
std::string cur_line = static_cast<std::string>(lines[cy]);
|
for (std::size_t ci = 0; ci < cur_line.size(); ++ci) {
|
||||||
const std::size_t tabw = 8;
|
if (cur_line[ci] == '\t') {
|
||||||
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
|
std::size_t adv = tabw - (cur_rx % tabw);
|
||||||
if (cur_line[i] == '\t') {
|
cur_expanded.append(adv, ' ');
|
||||||
cursor_rx += tabw - (cursor_rx % tabw);
|
cur_rx += adv;
|
||||||
} else {
|
} else {
|
||||||
|
cur_expanded.push_back(cur_line[ci]);
|
||||||
|
cur_rx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Compute rendered column of cursor
|
||||||
|
std::size_t cursor_rx = 0;
|
||||||
|
for (std::size_t ci = 0; ci < cx && ci < cur_line.size(); ++ci) {
|
||||||
|
if (cur_line[ci] == '\t')
|
||||||
|
cursor_rx += tabw - (cursor_rx % tabw);
|
||||||
|
else
|
||||||
cursor_rx += 1;
|
cursor_rx += 1;
|
||||||
}
|
}
|
||||||
|
std::size_t exp_end = std::min(cur_expanded.size(), cursor_rx);
|
||||||
|
if (exp_end > 0)
|
||||||
|
cursor_px_abs = ImGui::CalcTextSize(cur_expanded.c_str(),
|
||||||
|
cur_expanded.c_str() + exp_end).x;
|
||||||
}
|
}
|
||||||
}
|
if (cursor_px_abs < scroll_x_now || cursor_px_abs > scroll_x_now + child_w_actual) {
|
||||||
long cxr = static_cast<long>(cursor_rx);
|
float target_x = cursor_px_abs - (child_w_actual / 2.0f);
|
||||||
if (cxr < first_col || cxr > last_col) {
|
|
||||||
float target_x = static_cast<float>(cxr) * space_w;
|
|
||||||
target_x -= (child_w_actual / 2.0f);
|
|
||||||
if (target_x < 0.f)
|
if (target_x < 0.f)
|
||||||
target_x = 0.f;
|
target_x = 0.f;
|
||||||
float max_x = ImGui::GetScrollMaxX();
|
float max_x = ImGui::GetScrollMaxX();
|
||||||
@@ -704,7 +730,7 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
left += " *";
|
left += " *";
|
||||||
// Append total line count as "<n>L"
|
// Append total line count as "<n>L"
|
||||||
{
|
{
|
||||||
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
|
unsigned long lcount = static_cast<unsigned long>(buf->Nrows());
|
||||||
left += " ";
|
left += " ";
|
||||||
left += std::to_string(lcount);
|
left += std::to_string(lcount);
|
||||||
left += "L";
|
left += "L";
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
#include "Renderer.h"
|
#include "Renderer.h"
|
||||||
|
|
||||||
|
struct ImFont;
|
||||||
|
class Buffer;
|
||||||
|
|
||||||
class ImGuiRenderer final : public Renderer {
|
class ImGuiRenderer final : public Renderer {
|
||||||
public:
|
public:
|
||||||
ImGuiRenderer() = default;
|
ImGuiRenderer() = default;
|
||||||
@@ -11,4 +16,22 @@ public:
|
|||||||
~ImGuiRenderer() override = default;
|
~ImGuiRenderer() override = default;
|
||||||
|
|
||||||
void Draw(Editor &ed) override;
|
void Draw(Editor &ed) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Per-window scroll tracking for two-way sync between Buffer offsets and ImGui scroll.
|
||||||
|
// These must be per-instance (not static) so each window maintains independent state.
|
||||||
|
long prev_buf_rowoffs_ = -1;
|
||||||
|
long prev_buf_coloffs_ = -1;
|
||||||
|
float prev_scroll_y_ = -1.0f;
|
||||||
|
float prev_scroll_x_ = -1.0f;
|
||||||
|
bool mouse_selecting_ = false;
|
||||||
|
|
||||||
|
// Max-line-width cache for the horizontal scrollbar. Measuring every line
|
||||||
|
// every frame is prohibitively expensive on large files; we only update the
|
||||||
|
// running max from visible lines and reset when buffer/version/font changes.
|
||||||
|
const Buffer *max_width_buf_ = nullptr;
|
||||||
|
std::uint64_t max_width_version_ = 0;
|
||||||
|
ImFont *max_width_font_ = nullptr;
|
||||||
|
float max_width_font_size_ = 0.0f;
|
||||||
|
float max_width_px_ = 0.0f;
|
||||||
};
|
};
|
||||||
+16
-5
@@ -1,6 +1,4 @@
|
|||||||
#include <iostream>
|
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
#include <ostream>
|
|
||||||
|
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
|
|
||||||
@@ -40,6 +38,15 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (ascii_key == 'E') {
|
||||||
|
// Explicitly rejected and kept distinct from 'e' (OpenFileStart): the
|
||||||
|
// switch below operates on the lowercased key, so it can't otherwise
|
||||||
|
// tell 'E' and 'e' apart. Return false and let the caller show its
|
||||||
|
// normal "unknown k-command" status-line message - curses already
|
||||||
|
// owns the terminal here, so writing to stderr would corrupt the
|
||||||
|
// screen.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
switch (k_lower) {
|
switch (k_lower) {
|
||||||
case 'a':
|
case 'a':
|
||||||
@@ -66,9 +73,6 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'e':
|
case 'e':
|
||||||
out = CommandId::OpenFileStart;
|
out = CommandId::OpenFileStart;
|
||||||
return true;
|
return true;
|
||||||
case 'E':
|
|
||||||
std::cerr << "E is not a valid command" << std::endl;
|
|
||||||
return false;
|
|
||||||
case 'f':
|
case 'f':
|
||||||
out = CommandId::FlushKillRing;
|
out = CommandId::FlushKillRing;
|
||||||
return true;
|
return true;
|
||||||
@@ -84,6 +88,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'l':
|
case 'l':
|
||||||
out = CommandId::ReloadBuffer;
|
out = CommandId::ReloadBuffer;
|
||||||
return true;
|
return true;
|
||||||
|
case 'm':
|
||||||
|
out = CommandId::ToggleEditMode;
|
||||||
|
return true;
|
||||||
case 'n':
|
case 'n':
|
||||||
out = CommandId::BufferPrev;
|
out = CommandId::BufferPrev;
|
||||||
return true;
|
return true;
|
||||||
@@ -226,6 +233,10 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
|
|||||||
case 'q':
|
case 'q':
|
||||||
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
|
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
|
||||||
return true;
|
return true;
|
||||||
|
case '\n':
|
||||||
|
case '\r':
|
||||||
|
out = CommandId::SmartNewline; // Shift+Enter (some terminals send this as Alt+Enter sequences)
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+136
-22
@@ -1,5 +1,41 @@
|
|||||||
/*
|
/*
|
||||||
* PieceTable.h - Alternative to GapBuffer using a piece table representation
|
* PieceTable.h - Alternative to GapBuffer using a piece table representation
|
||||||
|
*
|
||||||
|
* PieceTable is kte's core text storage data structure. It provides efficient
|
||||||
|
* insert/delete operations without copying the entire buffer by maintaining a
|
||||||
|
* sequence of "pieces" that reference ranges in two underlying buffers:
|
||||||
|
* - original_: A loaded file's content (AdoptOriginal), never modified
|
||||||
|
* - add_: All text added during editing
|
||||||
|
*
|
||||||
|
* Key advantages:
|
||||||
|
* - O(1) append/prepend operations (common case)
|
||||||
|
* - O(n) insert/delete at arbitrary positions (n = number of pieces, not bytes)
|
||||||
|
* - Efficient undo: just restore the piece list
|
||||||
|
* - Memory efficient: no gap buffer waste
|
||||||
|
*
|
||||||
|
* Performance characteristics:
|
||||||
|
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
|
||||||
|
* - Materialization (Data() call) is O(total_size) but cached until next edit;
|
||||||
|
* a single-piece table (an unedited file) is viewed in place, not copied
|
||||||
|
* - Line index is lazily rebuilt on first line-based query after edits
|
||||||
|
* - Range and Find operations use lightweight caches for repeated queries
|
||||||
|
*
|
||||||
|
* API evolution:
|
||||||
|
* 1. Legacy API (GapBuffer compatibility):
|
||||||
|
* - Append/Prepend: Build content sequentially
|
||||||
|
* - Data(): Materialize entire buffer
|
||||||
|
*
|
||||||
|
* 2. New buffer-wide API (Phase 1):
|
||||||
|
* - Insert/Delete: Edit at arbitrary byte offsets
|
||||||
|
* - Line-based queries: LineCount, GetLine, GetLineRange
|
||||||
|
* - Position conversion: ByteOffsetToLineCol, LineColToByteOffset
|
||||||
|
* - Efficient extraction: GetRange, Find, WriteToStream
|
||||||
|
*
|
||||||
|
* Implementation notes:
|
||||||
|
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
|
||||||
|
* - Not thread-safe: even reads update caches. Buffers are used from the
|
||||||
|
* main thread only (the swap writer thread gets copies of the bytes).
|
||||||
|
* - Version tracking invalidates caches on mutations
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@@ -8,15 +44,15 @@
|
|||||||
#include <ostream>
|
#include <ostream>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <mutex>
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "TextSpan.h"
|
||||||
|
|
||||||
|
|
||||||
class PieceTable {
|
class PieceTable {
|
||||||
public:
|
public:
|
||||||
PieceTable();
|
PieceTable();
|
||||||
|
|
||||||
explicit PieceTable(std::size_t initialCapacity);
|
|
||||||
|
|
||||||
// Advanced constructor allowing configuration of consolidation heuristics
|
// Advanced constructor allowing configuration of consolidation heuristics
|
||||||
PieceTable(std::size_t initialCapacity,
|
PieceTable(std::size_t initialCapacity,
|
||||||
std::size_t piece_limit,
|
std::size_t piece_limit,
|
||||||
@@ -33,9 +69,6 @@ public:
|
|||||||
|
|
||||||
~PieceTable();
|
~PieceTable();
|
||||||
|
|
||||||
// Public API mirrors GapBuffer
|
|
||||||
void Reserve(std::size_t newCapacity);
|
|
||||||
|
|
||||||
void AppendChar(char c);
|
void AppendChar(char c);
|
||||||
|
|
||||||
void Append(const char *s, std::size_t len);
|
void Append(const char *s, std::size_t len);
|
||||||
@@ -51,18 +84,40 @@ public:
|
|||||||
// Content management
|
// Content management
|
||||||
void Clear();
|
void Clear();
|
||||||
|
|
||||||
|
// Replace the content with `bytes`, taken over without copying (a loaded
|
||||||
|
// file). Undo spans into the old storage are invalidated, as by Clear().
|
||||||
|
// If it throws (std::bad_alloc), the content is unchanged.
|
||||||
|
void AdoptOriginal(std::string &&bytes);
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
char *Data()
|
// The whole text, contiguous; nullptr when empty. Valid until the next
|
||||||
|
// modification.
|
||||||
|
[[nodiscard]] const char *Data() const
|
||||||
{
|
{
|
||||||
materialize();
|
const std::string_view v = ContentView();
|
||||||
return materialized_.empty() ? nullptr : materialized_.data();
|
return v.empty() ? nullptr : v.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] const char *Data() const
|
// The whole text as one view: in place for a single piece, else the
|
||||||
|
// materialized copy (built once per modification). Valid until the next
|
||||||
|
// modification.
|
||||||
|
[[nodiscard]] std::string_view ContentView() const;
|
||||||
|
|
||||||
|
// A view of [byte_offset, byte_offset + len), clamped to the content: in
|
||||||
|
// place when the range lies within one piece (no copy), else into the
|
||||||
|
// materialized text. Valid until the next modification.
|
||||||
|
[[nodiscard]] std::string_view View(std::size_t byte_offset, std::size_t len) const;
|
||||||
|
|
||||||
|
// Call fn(const char *data, std::size_t len) for each piece, in order:
|
||||||
|
// the content without materializing it.
|
||||||
|
template<typename Fn>
|
||||||
|
void ForEachChunk(Fn &&fn) const
|
||||||
{
|
{
|
||||||
const_cast<PieceTable *>(this)->materialize();
|
for (const Piece &p: pieces_) {
|
||||||
return materialized_.empty() ? nullptr : materialized_.data();
|
if (p.len > 0)
|
||||||
|
fn((p.src == Source::Original ? original_ : add_).data() + p.start, p.len);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -72,13 +127,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::size_t Capacity() const
|
|
||||||
{
|
|
||||||
// Capacity for piece table isn't directly meaningful; report materialized capacity
|
|
||||||
return materialized_.capacity();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ===== New buffer-wide API (Phase 1) =====
|
// ===== New buffer-wide API (Phase 1) =====
|
||||||
// Byte-based editing operations
|
// Byte-based editing operations
|
||||||
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
||||||
@@ -105,6 +153,36 @@ public:
|
|||||||
// Stream out content without materializing the entire buffer
|
// Stream out content without materializing the entire buffer
|
||||||
void WriteToStream(std::ostream &out) const;
|
void WriteToStream(std::ostream &out) const;
|
||||||
|
|
||||||
|
// ===== Span access (see TextSpan.h) =====
|
||||||
|
// The storage spans backing [byte_offset, byte_offset + len).
|
||||||
|
[[nodiscard]] std::vector<TextSpan> SpansInRange(std::size_t byte_offset, std::size_t len) const;
|
||||||
|
|
||||||
|
// Call fn(const char *data, std::size_t len) for each span's bytes, in order.
|
||||||
|
template<typename Fn>
|
||||||
|
void VisitSpans(const std::vector<TextSpan> &spans, Fn &&fn) const
|
||||||
|
{
|
||||||
|
for (const TextSpan &sp: spans) {
|
||||||
|
const std::string &src = sp.add ? add_ : original_;
|
||||||
|
if (sp.len > 0 && sp.start + sp.len <= src.size())
|
||||||
|
fn(src.data() + sp.start, sp.len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whether the spans hold exactly `text`.
|
||||||
|
[[nodiscard]] bool SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const;
|
||||||
|
|
||||||
|
// Insert the spans' bytes at byte_offset without copying them into
|
||||||
|
// storage again (the new pieces reference the existing bytes).
|
||||||
|
void InsertSpans(std::size_t byte_offset, const std::vector<TextSpan> &spans);
|
||||||
|
|
||||||
|
// Deletions of at least kCaptureDeletedMin bytes remember the spans they
|
||||||
|
// removed; this returns them if the most recent deletion was exactly
|
||||||
|
// [byte_offset, byte_offset + len), else nothing. Consumes the capture.
|
||||||
|
static constexpr std::size_t kCaptureDeletedMin = 4096;
|
||||||
|
|
||||||
|
[[nodiscard]] std::vector<TextSpan> TakeDeletedSpans(std::size_t byte_offset, std::size_t len);
|
||||||
|
|
||||||
// Heuristic configuration
|
// Heuristic configuration
|
||||||
void SetConsolidationParams(std::size_t piece_limit,
|
void SetConsolidationParams(std::size_t piece_limit,
|
||||||
std::size_t small_piece_threshold,
|
std::size_t small_piece_threshold,
|
||||||
@@ -141,10 +219,16 @@ private:
|
|||||||
// Line index support (rebuilt lazily on demand)
|
// Line index support (rebuilt lazily on demand)
|
||||||
void InvalidateLineIndex() const;
|
void InvalidateLineIndex() const;
|
||||||
|
|
||||||
|
// Keep a valid line index in step with an edit instead of rescanning the
|
||||||
|
// whole buffer on the next query. No-ops if the index is already dirty.
|
||||||
|
void lineIndexOnInsert(std::size_t offset, const char *text, std::size_t len) const;
|
||||||
|
|
||||||
|
void lineIndexOnDelete(std::size_t offset, std::size_t len) const;
|
||||||
|
|
||||||
void RebuildLineIndex() const;
|
void RebuildLineIndex() const;
|
||||||
|
|
||||||
// Underlying storages
|
// Underlying storages
|
||||||
std::string original_; // unused for builder use-case, but kept for API symmetry
|
std::string original_; // a loaded file's bytes (AdoptOriginal)
|
||||||
std::string add_;
|
std::string add_;
|
||||||
std::vector<Piece> pieces_;
|
std::vector<Piece> pieces_;
|
||||||
|
|
||||||
@@ -154,9 +238,34 @@ private:
|
|||||||
mutable std::uint64_t version_ = 0;
|
mutable std::uint64_t version_ = 0;
|
||||||
std::size_t total_size_ = 0;
|
std::size_t total_size_ = 0;
|
||||||
|
|
||||||
|
// End offset of each piece (prefix sums) for binary-search lookup,
|
||||||
|
// valid while piece_ends_version_ == version_ (every mutation bumps it).
|
||||||
|
mutable std::vector<std::size_t> piece_ends_;
|
||||||
|
mutable std::uint64_t piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
|
||||||
|
|
||||||
// Cached line index: starting byte offset of each line (always contains at least 1 entry: 0)
|
// Cached line index: starting byte offset of each line (always contains at least 1 entry: 0)
|
||||||
mutable std::vector<std::size_t> line_index_;
|
mutable std::vector<std::size_t> line_index_;
|
||||||
mutable bool line_index_dirty_ = true;
|
mutable bool line_index_dirty_ = true;
|
||||||
|
// A shift not yet applied to line_index_: entries from index
|
||||||
|
// line_shift_from_ on are really line_index_[i] + line_shift_by_ (mod
|
||||||
|
// 2^64, so it may be negative). An edit moves every later line start;
|
||||||
|
// applying that at once cost O(lines) per keystroke, so consecutive edits
|
||||||
|
// on one line only adjust the pending shift.
|
||||||
|
mutable std::size_t line_shift_from_ = std::numeric_limits<std::size_t>::max();
|
||||||
|
mutable std::size_t line_shift_by_ = 0;
|
||||||
|
|
||||||
|
// Start of line i (i < line_index_.size()), with the pending shift.
|
||||||
|
[[nodiscard]] std::size_t lineStart(std::size_t i) const
|
||||||
|
{
|
||||||
|
return i >= line_shift_from_ ? line_index_[i] + line_shift_by_ : line_index_[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Index of the first line starting after byte_offset.
|
||||||
|
[[nodiscard]] std::size_t lineUpperBound(std::size_t byte_offset) const;
|
||||||
|
|
||||||
|
// Fold the pending shift into line_index_.
|
||||||
|
void applyLineShift() const;
|
||||||
|
|
||||||
// Heuristic knobs
|
// Heuristic knobs
|
||||||
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
|
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
|
||||||
@@ -180,8 +289,13 @@ private:
|
|||||||
std::size_t result = std::numeric_limits<std::size_t>::max();
|
std::size_t result = std::numeric_limits<std::size_t>::max();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct DeletedCapture {
|
||||||
|
bool valid = false;
|
||||||
|
std::size_t offset = 0;
|
||||||
|
std::size_t len = 0;
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
} last_deleted_;
|
||||||
|
|
||||||
mutable RangeCache range_cache_;
|
mutable RangeCache range_cache_;
|
||||||
mutable FindCache find_cache_;
|
mutable FindCache find_cache_;
|
||||||
|
|
||||||
mutable std::mutex mutex_;
|
|
||||||
};
|
};
|
||||||
-985
@@ -1,985 +0,0 @@
|
|||||||
#include "QtFrontend.h"
|
|
||||||
|
|
||||||
#include <QApplication>
|
|
||||||
#include <QWidget>
|
|
||||||
#include <QKeyEvent>
|
|
||||||
#include <QTimer>
|
|
||||||
#include <QScreen>
|
|
||||||
#include <QFont>
|
|
||||||
#include <QFontMetrics>
|
|
||||||
#include <QFontDatabase>
|
|
||||||
#include <QFileDialog>
|
|
||||||
#include <QFontDialog>
|
|
||||||
#include <QPainter>
|
|
||||||
#include <QPaintEvent>
|
|
||||||
#include <QWheelEvent>
|
|
||||||
#include <regex>
|
|
||||||
|
|
||||||
#include "Editor.h"
|
|
||||||
#include "Command.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include "GUITheme.h"
|
|
||||||
#include "Highlight.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
class MainWindow : public QWidget {
|
|
||||||
public:
|
|
||||||
explicit MainWindow(class QtInputHandler &ih, QWidget *parent = nullptr)
|
|
||||||
: QWidget(parent), input_(ih)
|
|
||||||
{
|
|
||||||
// Match ImGui window title format
|
|
||||||
setWindowTitle(QStringLiteral("kge - kyle's graphical editor ")
|
|
||||||
+ QStringLiteral(KTE_VERSION_STR));
|
|
||||||
resize(1280, 800);
|
|
||||||
setFocusPolicy(Qt::StrongFocus);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
bool WasClosed() const
|
|
||||||
{
|
|
||||||
return closed_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetEditor(Editor *ed)
|
|
||||||
{
|
|
||||||
ed_ = ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetFontFamilyAndSize(QString family, int px)
|
|
||||||
{
|
|
||||||
if (family.isEmpty())
|
|
||||||
family = QStringLiteral("Brass Mono");
|
|
||||||
if (px <= 0)
|
|
||||||
px = 18;
|
|
||||||
font_family_ = std::move(family);
|
|
||||||
font_px_ = px;
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void keyPressEvent(QKeyEvent *event) override
|
|
||||||
{
|
|
||||||
// Route to editor keymap; if handled, accept and stop propagation so
|
|
||||||
// Qt doesn't trigger any default widget shortcuts.
|
|
||||||
if (input_.ProcessKeyEvent(*event)) {
|
|
||||||
event->accept();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QWidget::keyPressEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void paintEvent(QPaintEvent *event) override
|
|
||||||
{
|
|
||||||
Q_UNUSED(event);
|
|
||||||
QPainter p(this);
|
|
||||||
p.setRenderHint(QPainter::TextAntialiasing, true);
|
|
||||||
|
|
||||||
// Colors from GUITheme palette (Qt branch)
|
|
||||||
auto to_qcolor = [](const KteColor &c) -> QColor {
|
|
||||||
int r = int(std::round(c.x * 255.0f));
|
|
||||||
int g = int(std::round(c.y * 255.0f));
|
|
||||||
int b = int(std::round(c.z * 255.0f));
|
|
||||||
int a = int(std::round(c.w * 255.0f));
|
|
||||||
return QColor(r, g, b, a);
|
|
||||||
};
|
|
||||||
const auto pal = kte::GetPalette();
|
|
||||||
const QColor bg = to_qcolor(pal.bg);
|
|
||||||
const QColor fg = to_qcolor(pal.fg);
|
|
||||||
const QColor sel_bg = to_qcolor(pal.sel_bg);
|
|
||||||
const QColor cur_bg = to_qcolor(pal.cur_bg);
|
|
||||||
const QColor status_bg = to_qcolor(pal.status_bg);
|
|
||||||
const QColor status_fg = to_qcolor(pal.status_fg);
|
|
||||||
|
|
||||||
// Background
|
|
||||||
p.fillRect(rect(), bg);
|
|
||||||
|
|
||||||
// Font/metrics (configured or defaults)
|
|
||||||
QFont f(font_family_, font_px_);
|
|
||||||
p.setFont(f);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = fm.height();
|
|
||||||
const int ch_w = std::max(1, fm.horizontalAdvance(QStringLiteral(" ")));
|
|
||||||
|
|
||||||
// Layout metrics
|
|
||||||
const int pad_l = 8;
|
|
||||||
const int pad_t = 6;
|
|
||||||
const int pad_r = 8;
|
|
||||||
const int pad_b = 6;
|
|
||||||
const int status_h = line_h + 6; // status bar height
|
|
||||||
|
|
||||||
// Content area (text viewport)
|
|
||||||
const QRect content_rect(pad_l,
|
|
||||||
pad_t,
|
|
||||||
width() - pad_l - pad_r,
|
|
||||||
height() - pad_t - pad_b - status_h);
|
|
||||||
|
|
||||||
// Text viewport occupies all content area (no extra title row)
|
|
||||||
QRect viewport(content_rect.x(), content_rect.y(), content_rect.width(), content_rect.height());
|
|
||||||
|
|
||||||
// Draw buffer contents
|
|
||||||
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
|
|
||||||
const Buffer *buf = ed_->CurrentBuffer();
|
|
||||||
if (buf) {
|
|
||||||
const auto &lines = buf->Rows();
|
|
||||||
const std::size_t nrows = lines.size();
|
|
||||||
const std::size_t rowoffs = buf->Rowoffs();
|
|
||||||
const std::size_t coloffs = buf->Coloffs();
|
|
||||||
const std::size_t cy = buf->Cury();
|
|
||||||
const std::size_t cx = buf->Curx();
|
|
||||||
|
|
||||||
// Visible line count
|
|
||||||
const int max_lines = (line_h > 0) ? (viewport.height() / line_h) : 0;
|
|
||||||
const std::size_t last_row = std::min<std::size_t>(
|
|
||||||
nrows, rowoffs + std::max(0, max_lines));
|
|
||||||
|
|
||||||
// Tab width: follow ImGuiRenderer default of 4
|
|
||||||
const std::size_t tabw = 4;
|
|
||||||
|
|
||||||
// Prepare painter clip to viewport
|
|
||||||
p.save();
|
|
||||||
p.setClipRect(viewport);
|
|
||||||
|
|
||||||
// Iterate visible lines
|
|
||||||
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
|
|
||||||
// Materialize the Buffer::Line into a std::string for
|
|
||||||
// regex/iterator usage and general string ops.
|
|
||||||
const std::string line = static_cast<std::string>(lines[i]);
|
|
||||||
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
|
|
||||||
const int baseline = y + fm.ascent();
|
|
||||||
|
|
||||||
// Helper: convert src col -> rx with tab expansion
|
|
||||||
auto src_to_rx_line = [&](std::size_t src_col) -> std::size_t {
|
|
||||||
std::size_t rx = 0;
|
|
||||||
for (std::size_t k = 0; k < src_col && k < line.size(); ++k) {
|
|
||||||
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
|
|
||||||
}
|
|
||||||
return rx;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Search-match background highlights first (under text)
|
|
||||||
if (ed_->SearchActive() && !ed_->SearchQuery().empty()) {
|
|
||||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
|
||||||
// Compute ranges per line (source indices)
|
|
||||||
if (ed_->PromptActive() &&
|
|
||||||
(ed_->CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
|
|
||||||
ed_->CurrentPromptKind() ==
|
|
||||||
Editor::PromptKind::RegexReplaceFind)) {
|
|
||||||
try {
|
|
||||||
std::regex rx(ed_->SearchQuery());
|
|
||||||
for (auto it = std::sregex_iterator(
|
|
||||||
line.begin(), line.end(), rx);
|
|
||||||
it != std::sregex_iterator(); ++it) {
|
|
||||||
const auto &m = *it;
|
|
||||||
std::size_t sx = static_cast<std::size_t>(m.
|
|
||||||
position());
|
|
||||||
std::size_t ex =
|
|
||||||
sx + static_cast<std::size_t>(m.
|
|
||||||
length());
|
|
||||||
hl_src_ranges.emplace_back(sx, ex);
|
|
||||||
}
|
|
||||||
} catch (const std::regex_error &) {
|
|
||||||
// Invalid regex: ignore, status line already shows errors
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const std::string &q = ed_->SearchQuery();
|
|
||||||
if (!q.empty()) {
|
|
||||||
std::size_t pos = 0;
|
|
||||||
while ((pos = line.find(q, pos)) != std::string::npos) {
|
|
||||||
hl_src_ranges.emplace_back(pos, pos + q.size());
|
|
||||||
pos += q.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hl_src_ranges.empty()) {
|
|
||||||
const bool has_current =
|
|
||||||
ed_->SearchMatchLen() > 0 && ed_->SearchMatchY() == i;
|
|
||||||
const std::size_t cur_x = has_current ? ed_->SearchMatchX() : 0;
|
|
||||||
const std::size_t cur_end = has_current
|
|
||||||
? (ed_->SearchMatchX() + ed_->SearchMatchLen())
|
|
||||||
: 0;
|
|
||||||
for (const auto &rg: hl_src_ranges) {
|
|
||||||
std::size_t sx = rg.first, ex = rg.second;
|
|
||||||
std::size_t rx_s = src_to_rx_line(sx);
|
|
||||||
std::size_t rx_e = src_to_rx_line(ex);
|
|
||||||
if (rx_e <= coloffs)
|
|
||||||
continue; // fully left of view
|
|
||||||
int vx0 = viewport.x() + static_cast<int>((
|
|
||||||
(rx_s > coloffs ? rx_s - coloffs : 0)
|
|
||||||
* ch_w));
|
|
||||||
int vx1 = viewport.x() + static_cast<int>((
|
|
||||||
(rx_e - coloffs) * ch_w));
|
|
||||||
QRect r(vx0, y, std::max(0, vx1 - vx0), line_h);
|
|
||||||
if (r.width() <= 0)
|
|
||||||
continue;
|
|
||||||
bool is_current =
|
|
||||||
has_current && sx == cur_x && ex == cur_end;
|
|
||||||
QColor col = is_current
|
|
||||||
? QColor(255, 220, 120, 140)
|
|
||||||
: QColor(200, 200, 0, 90);
|
|
||||||
p.fillRect(r, col);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection background (if active on this line)
|
|
||||||
if (buf->MarkSet() && (
|
|
||||||
i == buf->MarkCury() || i == cy || (
|
|
||||||
i > std::min(buf->MarkCury(), cy) && i < std::max(
|
|
||||||
buf->MarkCury(), cy)))) {
|
|
||||||
std::size_t sx = 0, ex = 0;
|
|
||||||
if (buf->MarkCury() == i && cy == i) {
|
|
||||||
sx = std::min(buf->MarkCurx(), cx);
|
|
||||||
ex = std::max(buf->MarkCurx(), cx);
|
|
||||||
} else if (i == buf->MarkCury()) {
|
|
||||||
sx = buf->MarkCurx();
|
|
||||||
ex = line.size();
|
|
||||||
} else if (i == cy) {
|
|
||||||
sx = 0;
|
|
||||||
ex = cx;
|
|
||||||
} else {
|
|
||||||
sx = 0;
|
|
||||||
ex = line.size();
|
|
||||||
}
|
|
||||||
std::size_t rx_s = src_to_rx_line(sx);
|
|
||||||
std::size_t rx_e = src_to_rx_line(ex);
|
|
||||||
if (rx_e > coloffs) {
|
|
||||||
int vx0 = viewport.x() + static_cast<int>((rx_s > coloffs
|
|
||||||
? rx_s - coloffs
|
|
||||||
: 0) * ch_w);
|
|
||||||
int vx1 = viewport.x() + static_cast<int>(
|
|
||||||
(rx_e - coloffs) * ch_w);
|
|
||||||
QRect sel_r(vx0, y, std::max(0, vx1 - vx0), line_h);
|
|
||||||
if (sel_r.width() > 0)
|
|
||||||
p.fillRect(sel_r, sel_bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build expanded line (tabs -> spaces) for drawing
|
|
||||||
std::string expanded;
|
|
||||||
expanded.reserve(line.size() + 8);
|
|
||||||
std::size_t rx_acc = 0;
|
|
||||||
for (char c: line) {
|
|
||||||
if (c == '\t') {
|
|
||||||
std::size_t adv = (tabw - (rx_acc % tabw));
|
|
||||||
expanded.append(adv, ' ');
|
|
||||||
rx_acc += adv;
|
|
||||||
} else {
|
|
||||||
expanded.push_back(c);
|
|
||||||
rx_acc += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Syntax highlighting spans or plain text
|
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
|
||||||
HasHighlighter()) {
|
|
||||||
kte::LineHighlight lh = buf->Highlighter()->GetLine(
|
|
||||||
*buf, static_cast<int>(i), buf->Version());
|
|
||||||
struct SSpan {
|
|
||||||
std::size_t s;
|
|
||||||
std::size_t e;
|
|
||||||
kte::TokenKind k;
|
|
||||||
};
|
|
||||||
std::vector<SSpan> spans;
|
|
||||||
spans.reserve(lh.spans.size());
|
|
||||||
const std::size_t line_len = line.size();
|
|
||||||
for (const auto &sp: lh.spans) {
|
|
||||||
int s_raw = sp.col_start;
|
|
||||||
int e_raw = sp.col_end;
|
|
||||||
if (e_raw < s_raw)
|
|
||||||
std::swap(e_raw, s_raw);
|
|
||||||
std::size_t s = static_cast<std::size_t>(std::max(
|
|
||||||
0, std::min(s_raw, (int) line_len)));
|
|
||||||
std::size_t e = static_cast<std::size_t>(std::max(
|
|
||||||
(int) s, std::min(e_raw, (int) line_len)));
|
|
||||||
if (s < e)
|
|
||||||
spans.push_back({s, e, sp.kind});
|
|
||||||
}
|
|
||||||
std::sort(spans.begin(), spans.end(),
|
|
||||||
[](const SSpan &a, const SSpan &b) {
|
|
||||||
return a.s < b.s;
|
|
||||||
});
|
|
||||||
|
|
||||||
auto colorFor = [](kte::TokenKind k) -> QColor {
|
|
||||||
// GUITheme provides colors via ImGui vector; avoid direct dependency types
|
|
||||||
const auto v = kte::SyntaxInk(k);
|
|
||||||
return QColor(int(v.x * 255.0f), int(v.y * 255.0f),
|
|
||||||
int(v.z * 255.0f), int(v.w * 255.0f));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to convert src col to expanded rx
|
|
||||||
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
|
|
||||||
std::size_t rx = 0;
|
|
||||||
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
|
|
||||||
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
|
|
||||||
}
|
|
||||||
return rx;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (spans.empty()) {
|
|
||||||
// No highlight spans: draw the whole (visible) expanded line in default fg
|
|
||||||
if (coloffs < expanded.size()) {
|
|
||||||
const char *start =
|
|
||||||
expanded.c_str() + static_cast<int>(coloffs);
|
|
||||||
p.setPen(fg);
|
|
||||||
p.drawText(viewport.x(), baseline,
|
|
||||||
QString::fromUtf8(start));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Draw colored spans
|
|
||||||
for (const auto &sp: spans) {
|
|
||||||
std::size_t rx_s = src_to_rx_full(sp.s);
|
|
||||||
std::size_t rx_e = src_to_rx_full(sp.e);
|
|
||||||
if (rx_e <= coloffs)
|
|
||||||
continue; // left of viewport
|
|
||||||
std::size_t draw_start = (rx_s > coloffs)
|
|
||||||
? rx_s
|
|
||||||
: coloffs;
|
|
||||||
std::size_t draw_end = std::min<std::size_t>(
|
|
||||||
rx_e, expanded.size());
|
|
||||||
if (draw_end <= draw_start)
|
|
||||||
continue;
|
|
||||||
std::size_t screen_x = draw_start - coloffs;
|
|
||||||
int px = viewport.x() + int(screen_x * ch_w);
|
|
||||||
int len = int(draw_end - draw_start);
|
|
||||||
p.setPen(colorFor(sp.k));
|
|
||||||
p.drawText(px, baseline,
|
|
||||||
QString::fromUtf8(
|
|
||||||
expanded.c_str() + draw_start, len));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Draw expanded text clipped by coloffs
|
|
||||||
if (static_cast<std::size_t>(coloffs) < expanded.size()) {
|
|
||||||
const char *start =
|
|
||||||
expanded.c_str() + static_cast<int>(coloffs);
|
|
||||||
p.setPen(fg);
|
|
||||||
p.drawText(viewport.x(), baseline, QString::fromUtf8(start));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor indicator on current line
|
|
||||||
if (i == cy) {
|
|
||||||
std::size_t rx_cur = src_to_rx_line(cx);
|
|
||||||
if (rx_cur >= coloffs) {
|
|
||||||
// Compute exact pixel x by measuring expanded substring [coloffs, rx_cur)
|
|
||||||
std::size_t start = std::min<std::size_t>(
|
|
||||||
coloffs, expanded.size());
|
|
||||||
std::size_t end = std::min<
|
|
||||||
std::size_t>(rx_cur, expanded.size());
|
|
||||||
int px_advance = 0;
|
|
||||||
if (end > start) {
|
|
||||||
const QString sub = QString::fromUtf8(
|
|
||||||
expanded.c_str() + start,
|
|
||||||
static_cast<int>(end - start));
|
|
||||||
px_advance = fm.horizontalAdvance(sub);
|
|
||||||
}
|
|
||||||
int x0 = viewport.x() + px_advance;
|
|
||||||
QRect r(x0, y, ch_w, line_h);
|
|
||||||
p.fillRect(r, cur_bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status bar
|
|
||||||
const int bar_y = height() - status_h;
|
|
||||||
QRect status_rect(0, bar_y, width(), status_h);
|
|
||||||
p.fillRect(status_rect, status_bg);
|
|
||||||
p.setPen(status_fg);
|
|
||||||
if (ed_) {
|
|
||||||
const int pad = 6;
|
|
||||||
const int left_x = status_rect.x() + pad;
|
|
||||||
const int right_x_max = status_rect.x() + status_rect.width() - pad;
|
|
||||||
const int baseline_y = bar_y + (status_h + fm.ascent() - fm.descent()) / 2;
|
|
||||||
|
|
||||||
// If a prompt is active, mirror ImGui/TUI: show only the prompt across the bar
|
|
||||||
if (ed_->PromptActive()) {
|
|
||||||
std::string label = ed_->PromptLabel();
|
|
||||||
std::string text = ed_->PromptText();
|
|
||||||
|
|
||||||
// Map $HOME to ~ for path prompts (Open/Save/Chdir)
|
|
||||||
auto kind = ed_->CurrentPromptKind();
|
|
||||||
if (kind == Editor::PromptKind::OpenFile ||
|
|
||||||
kind == Editor::PromptKind::SaveAs ||
|
|
||||||
kind == Editor::PromptKind::Chdir) {
|
|
||||||
const char *home_c = std::getenv("HOME");
|
|
||||||
if (home_c && *home_c) {
|
|
||||||
std::string home(home_c);
|
|
||||||
if (text.rfind(home, 0) == 0) {
|
|
||||||
std::string rest = text.substr(home.size());
|
|
||||||
if (rest.empty())
|
|
||||||
text = "~";
|
|
||||||
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
|
|
||||||
text = std::string("~") + rest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string prefix;
|
|
||||||
if (kind == Editor::PromptKind::Command)
|
|
||||||
prefix = ": ";
|
|
||||||
else if (!label.empty())
|
|
||||||
prefix = label + ": ";
|
|
||||||
|
|
||||||
// Compose text and elide per behavior:
|
|
||||||
const int max_w = status_rect.width() - 2 * pad;
|
|
||||||
QString qprefix = QString::fromStdString(prefix);
|
|
||||||
QString qtext = QString::fromStdString(text);
|
|
||||||
int avail_w = std::max(0, max_w - fm.horizontalAdvance(qprefix));
|
|
||||||
Qt::TextElideMode mode = Qt::ElideRight;
|
|
||||||
if (kind == Editor::PromptKind::OpenFile ||
|
|
||||||
kind == Editor::PromptKind::SaveAs ||
|
|
||||||
kind == Editor::PromptKind::Chdir) {
|
|
||||||
mode = Qt::ElideLeft;
|
|
||||||
}
|
|
||||||
QString shown = fm.elidedText(qtext, mode, avail_w);
|
|
||||||
p.drawText(left_x, baseline_y, qprefix + shown);
|
|
||||||
} else {
|
|
||||||
// Build left segment: app/version, buffer idx/total, filename [+dirty], line count
|
|
||||||
QString left;
|
|
||||||
left += QStringLiteral("kge ");
|
|
||||||
left += QStringLiteral(KTE_VERSION_STR);
|
|
||||||
|
|
||||||
const Buffer *buf = ed_->CurrentBuffer();
|
|
||||||
if (buf) {
|
|
||||||
// buffer index/total
|
|
||||||
std::size_t total = ed_->BufferCount();
|
|
||||||
if (total > 0) {
|
|
||||||
std::size_t idx1 = ed_->CurrentBufferIndex() + 1; // 1-based
|
|
||||||
left += QStringLiteral(" [");
|
|
||||||
left += QString::number(static_cast<qlonglong>(idx1));
|
|
||||||
left += QStringLiteral("/");
|
|
||||||
left += QString::number(static_cast<qlonglong>(total));
|
|
||||||
left += QStringLiteral("] ");
|
|
||||||
} else {
|
|
||||||
left += QStringLiteral(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// buffer display name
|
|
||||||
std::string disp;
|
|
||||||
try {
|
|
||||||
disp = ed_->DisplayNameFor(*buf);
|
|
||||||
} catch (...) {
|
|
||||||
disp = buf->Filename();
|
|
||||||
}
|
|
||||||
if (disp.empty())
|
|
||||||
disp = "[No Name]";
|
|
||||||
left += QString::fromStdString(disp);
|
|
||||||
if (buf->Dirty())
|
|
||||||
left += QStringLiteral(" *");
|
|
||||||
|
|
||||||
// total lines suffix " <n>L"
|
|
||||||
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
|
|
||||||
left += QStringLiteral(" ");
|
|
||||||
left += QString::number(static_cast<qlonglong>(lcount));
|
|
||||||
left += QStringLiteral("L");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build right segment: cursor and mark
|
|
||||||
QString right;
|
|
||||||
if (buf) {
|
|
||||||
int row1 = static_cast<int>(buf->Cury()) + 1;
|
|
||||||
int col1 = static_cast<int>(buf->Curx()) + 1;
|
|
||||||
bool have_mark = buf->MarkSet();
|
|
||||||
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
|
|
||||||
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
|
|
||||||
if (have_mark)
|
|
||||||
right = QString("%1,%2 | M: %3,%4").arg(row1).arg(col1).arg(mrow1).arg(
|
|
||||||
mcol1);
|
|
||||||
else
|
|
||||||
right = QString("%1,%2 | M: not set").arg(row1).arg(col1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Middle message: status text
|
|
||||||
QString mid = QString::fromStdString(ed_->Status());
|
|
||||||
|
|
||||||
// Measure and layout
|
|
||||||
int left_w = fm.horizontalAdvance(left);
|
|
||||||
int right_w = fm.horizontalAdvance(right);
|
|
||||||
int lx = left_x;
|
|
||||||
int rx = std::max(left_x, right_x_max - right_w);
|
|
||||||
|
|
||||||
// If overlap, elide left to make space for right
|
|
||||||
if (lx + left_w + pad > rx) {
|
|
||||||
int max_left_w = std::max(0, rx - lx - pad);
|
|
||||||
left = fm.elidedText(left, Qt::ElideRight, max_left_w);
|
|
||||||
left_w = fm.horizontalAdvance(left);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw left and right
|
|
||||||
p.drawText(lx, baseline_y, left);
|
|
||||||
if (!right.isEmpty())
|
|
||||||
p.drawText(rx, baseline_y, right);
|
|
||||||
|
|
||||||
// Middle message clipped between end of left and start of right
|
|
||||||
int mid_left = lx + left_w + pad;
|
|
||||||
int mid_right = std::max(mid_left, rx - pad);
|
|
||||||
int mid_w = std::max(0, mid_right - mid_left);
|
|
||||||
if (mid_w > 0 && !mid.isEmpty()) {
|
|
||||||
QString mid_show = fm.elidedText(mid, Qt::ElideRight, mid_w);
|
|
||||||
p.save();
|
|
||||||
p.setClipRect(QRect(mid_left, bar_y, mid_w, status_h));
|
|
||||||
p.drawText(mid_left, baseline_y, mid_show);
|
|
||||||
p.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void resizeEvent(QResizeEvent *event) override
|
|
||||||
{
|
|
||||||
QWidget::resizeEvent(event);
|
|
||||||
if (!ed_)
|
|
||||||
return;
|
|
||||||
// Update editor dimensions based on new size
|
|
||||||
QFont f(font_family_, font_px_);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = std::max(12, fm.height());
|
|
||||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
|
|
||||||
const int pad_l = 8, pad_r = 8, pad_t = 6, pad_b = 6;
|
|
||||||
const int status_h = line_h + 6;
|
|
||||||
const int avail_w = std::max(0, width() - pad_l - pad_r);
|
|
||||||
const int avail_h = std::max(0, height() - pad_t - pad_b - status_h);
|
|
||||||
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h));
|
|
||||||
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
|
|
||||||
ed_->SetDimensions(rows, cols);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void wheelEvent(QWheelEvent *event) override
|
|
||||||
{
|
|
||||||
if (!ed_) {
|
|
||||||
QWidget::wheelEvent(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Buffer *buf = ed_->CurrentBuffer();
|
|
||||||
if (!buf) {
|
|
||||||
QWidget::wheelEvent(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recompute metrics to map pixel deltas to rows/cols
|
|
||||||
QFont f(font_family_, font_px_);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = std::max(12, fm.height());
|
|
||||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
|
|
||||||
|
|
||||||
// Determine scroll intent: use pixelDelta when available (trackpads), otherwise angleDelta
|
|
||||||
QPoint pixel = event->pixelDelta();
|
|
||||||
QPoint angle = event->angleDelta();
|
|
||||||
|
|
||||||
double v_lines_delta = 0.0;
|
|
||||||
double h_cols_delta = 0.0;
|
|
||||||
|
|
||||||
// Horizontal scroll with Shift or explicit horizontal delta
|
|
||||||
bool horiz_mode = (event->modifiers() & Qt::ShiftModifier) || (!pixel.isNull() && pixel.x() != 0) || (
|
|
||||||
!angle.isNull() && angle.x() != 0);
|
|
||||||
|
|
||||||
if (!pixel.isNull()) {
|
|
||||||
// Trackpad smooth scrolling (pixels)
|
|
||||||
v_lines_delta = -static_cast<double>(pixel.y()) / std::max(1, line_h);
|
|
||||||
h_cols_delta = -static_cast<double>(pixel.x()) / std::max(1, ch_w);
|
|
||||||
} else if (!angle.isNull()) {
|
|
||||||
// Mouse wheel: 120 units per notch; map one notch to 3 lines similar to ImGui UX
|
|
||||||
v_lines_delta = -static_cast<double>(angle.y()) / 120.0 * 3.0;
|
|
||||||
// For horizontal wheels, each notch scrolls 8 columns
|
|
||||||
h_cols_delta = -static_cast<double>(angle.x()) / 120.0 * 8.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accumulate fractional deltas across events
|
|
||||||
v_scroll_accum_ += v_lines_delta;
|
|
||||||
h_scroll_accum_ += h_cols_delta;
|
|
||||||
|
|
||||||
int d_rows = 0;
|
|
||||||
int d_cols = 0;
|
|
||||||
if (std::fabs(v_scroll_accum_) >= 1.0 && (!horiz_mode || std::fabs(v_scroll_accum_) > std::fabs(
|
|
||||||
h_scroll_accum_))) {
|
|
||||||
d_rows = static_cast<int>(v_scroll_accum_);
|
|
||||||
v_scroll_accum_ -= d_rows;
|
|
||||||
}
|
|
||||||
if (std::fabs(h_scroll_accum_) >= 1.0 && (horiz_mode || std::fabs(h_scroll_accum_) >= std::fabs(
|
|
||||||
v_scroll_accum_))) {
|
|
||||||
d_cols = static_cast<int>(h_scroll_accum_);
|
|
||||||
h_scroll_accum_ -= d_cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (d_rows != 0 || d_cols != 0) {
|
|
||||||
std::size_t new_rowoffs = buf->Rowoffs();
|
|
||||||
std::size_t new_coloffs = buf->Coloffs();
|
|
||||||
// Clamp vertical between 0 and last row (leaving at least one visible line)
|
|
||||||
if (d_rows != 0) {
|
|
||||||
long nr = static_cast<long>(new_rowoffs) + d_rows;
|
|
||||||
if (nr < 0)
|
|
||||||
nr = 0;
|
|
||||||
const auto nrows = static_cast<long>(buf->Rows().size());
|
|
||||||
if (nr > std::max(0L, nrows - 1))
|
|
||||||
nr = std::max(0L, nrows - 1);
|
|
||||||
new_rowoffs = static_cast<std::size_t>(nr);
|
|
||||||
}
|
|
||||||
if (d_cols != 0) {
|
|
||||||
long nc = static_cast<long>(new_coloffs) + d_cols;
|
|
||||||
if (nc < 0)
|
|
||||||
nc = 0;
|
|
||||||
new_coloffs = static_cast<std::size_t>(nc);
|
|
||||||
}
|
|
||||||
buf->SetOffsets(new_rowoffs, new_coloffs);
|
|
||||||
update();
|
|
||||||
event->accept();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QWidget::wheelEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void closeEvent(QCloseEvent *event) override
|
|
||||||
{
|
|
||||||
closed_ = true;
|
|
||||||
QWidget::closeEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
QtInputHandler &input_;
|
|
||||||
bool closed_ = false;
|
|
||||||
Editor *ed_ = nullptr;
|
|
||||||
double v_scroll_accum_ = 0.0;
|
|
||||||
double h_scroll_accum_ = 0.0;
|
|
||||||
QString font_family_ = QStringLiteral("Brass Mono");
|
|
||||||
int font_px_ = 18;
|
|
||||||
};
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool
|
|
||||||
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
|
||||||
{
|
|
||||||
app_ = new QApplication(argc, argv);
|
|
||||||
|
|
||||||
window_ = new MainWindow(input_);
|
|
||||||
window_->show();
|
|
||||||
// Ensure the window becomes the active, focused window so it receives key events
|
|
||||||
window_->activateWindow();
|
|
||||||
window_->raise();
|
|
||||||
window_->setFocus(Qt::OtherFocusReason);
|
|
||||||
|
|
||||||
renderer_.Attach(window_);
|
|
||||||
input_.Attach(&ed);
|
|
||||||
if (auto *mw = dynamic_cast<MainWindow *>(window_))
|
|
||||||
mw->SetEditor(&ed);
|
|
||||||
|
|
||||||
// Load GUI configuration (kge.ini) and configure font for Qt
|
|
||||||
config_ = GUIConfig::Load();
|
|
||||||
|
|
||||||
// Apply background mode from config to match ImGui frontend behavior
|
|
||||||
if (config_.background == "light")
|
|
||||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
|
||||||
else
|
|
||||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
|
||||||
|
|
||||||
// Apply theme by name for Qt palette-based theming (maps to named palettes).
|
|
||||||
// If unknown, falls back to the generic light/dark palette.
|
|
||||||
(void) kte::ApplyQtThemeByName(config_.theme);
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
|
|
||||||
// Map GUIConfig font name to a system family (Qt uses installed fonts)
|
|
||||||
auto choose_family = [](const std::string &name) -> QString {
|
|
||||||
QString fam;
|
|
||||||
std::string n = name;
|
|
||||||
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
|
|
||||||
return (char) std::tolower(c);
|
|
||||||
});
|
|
||||||
if (n.empty() || n == "default" || n == "brassmono" || n == "brassmonocode") {
|
|
||||||
fam = QStringLiteral("Brass Mono");
|
|
||||||
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
|
|
||||||
fam = QStringLiteral("JetBrains Mono");
|
|
||||||
} else if (n == "iosevka") {
|
|
||||||
fam = QStringLiteral("Iosevka");
|
|
||||||
} else if (n == "inconsolata" || n == "inconsolataex") {
|
|
||||||
fam = QStringLiteral("Inconsolata");
|
|
||||||
} else if (n == "space" || n == "spacemono" || n == "space mono") {
|
|
||||||
fam = QStringLiteral("Space Mono");
|
|
||||||
} else if (n == "go") {
|
|
||||||
fam = QStringLiteral("Go Mono");
|
|
||||||
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
|
|
||||||
fam = QStringLiteral("IBM Plex Mono");
|
|
||||||
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
|
|
||||||
fam = QStringLiteral("Fira Code");
|
|
||||||
} else if (!name.empty()) {
|
|
||||||
fam = QString::fromStdString(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate availability; choose a fallback if needed
|
|
||||||
const auto families = QFontDatabase::families();
|
|
||||||
if (!fam.isEmpty() && families.contains(fam)) {
|
|
||||||
return fam;
|
|
||||||
}
|
|
||||||
// Preferred fallback chain on macOS; otherwise, try common monospace families
|
|
||||||
const QStringList fallbacks = {
|
|
||||||
QStringLiteral("Brass Mono"),
|
|
||||||
QStringLiteral("JetBrains Mono"),
|
|
||||||
QStringLiteral("SF Mono"),
|
|
||||||
QStringLiteral("Menlo"),
|
|
||||||
QStringLiteral("Monaco"),
|
|
||||||
QStringLiteral("Courier New"),
|
|
||||||
QStringLiteral("Courier"),
|
|
||||||
QStringLiteral("Monospace")
|
|
||||||
};
|
|
||||||
for (const auto &fb: fallbacks) {
|
|
||||||
if (families.contains(fb))
|
|
||||||
return fb;
|
|
||||||
}
|
|
||||||
// As a last resort, return the request (Qt will substitute)
|
|
||||||
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
|
|
||||||
};
|
|
||||||
|
|
||||||
QString family = choose_family(config_.font);
|
|
||||||
int px_size = (config_.font_size > 0.0f) ? (int) std::lround(config_.font_size) : 18;
|
|
||||||
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
|
|
||||||
mw->SetFontFamilyAndSize(family, px_size);
|
|
||||||
}
|
|
||||||
// Track current font in globals for command/status queries
|
|
||||||
kte::gCurrentFontFamily = family.toStdString();
|
|
||||||
kte::gCurrentFontSize = static_cast<float>(px_size);
|
|
||||||
|
|
||||||
// Set initial dimensions based on font metrics
|
|
||||||
QFont f(family, px_size);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = std::max(12, fm.height());
|
|
||||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
|
|
||||||
const int w = window_->width();
|
|
||||||
const int h = window_->height();
|
|
||||||
const int pad = 16;
|
|
||||||
const int status_h = line_h + 4;
|
|
||||||
const int avail_w = std::max(0, w - 2 * pad);
|
|
||||||
const int avail_h = std::max(0, h - 2 * pad - status_h);
|
|
||||||
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
|
|
||||||
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
|
|
||||||
ed.SetDimensions(rows, cols);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void
|
|
||||||
GUIFrontend::Step(Editor &ed, bool &running)
|
|
||||||
{
|
|
||||||
// Pump Qt events
|
|
||||||
if (app_)
|
|
||||||
app_->processEvents();
|
|
||||||
|
|
||||||
// Allow deferred opens (including swap recovery prompts) to run.
|
|
||||||
ed.ProcessPendingOpens();
|
|
||||||
|
|
||||||
// Drain input queue
|
|
||||||
for (;;) {
|
|
||||||
MappedInput mi;
|
|
||||||
if (!input_.Poll(mi))
|
|
||||||
break;
|
|
||||||
if (mi.hasCommand) {
|
|
||||||
Execute(ed, mi.id, mi.arg, mi.count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ed.QuitRequested()) {
|
|
||||||
running = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Visual File Picker (Qt): invoked via CommandId::VisualFilePickerToggle ---
|
|
||||||
if (ed.FilePickerVisible()) {
|
|
||||||
QString startDir;
|
|
||||||
if (!ed.FilePickerDir().empty()) {
|
|
||||||
startDir = QString::fromStdString(ed.FilePickerDir());
|
|
||||||
}
|
|
||||||
QFileDialog dlg(window_, QStringLiteral("Open File"), startDir);
|
|
||||||
dlg.setFileMode(QFileDialog::ExistingFile);
|
|
||||||
if (dlg.exec() == QDialog::Accepted) {
|
|
||||||
const QStringList files = dlg.selectedFiles();
|
|
||||||
if (!files.isEmpty()) {
|
|
||||||
const QString fp = files.front();
|
|
||||||
ed.RequestOpenFile(fp.toStdString());
|
|
||||||
(void) ed.ProcessPendingOpens();
|
|
||||||
// Update picker dir for next time
|
|
||||||
QFileInfo info(fp);
|
|
||||||
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Close picker overlay regardless of outcome
|
|
||||||
ed.SetFilePickerVisible(false);
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply any queued theme change requests (from command handler)
|
|
||||||
if (kte::gThemeChangePending) {
|
|
||||||
if (!kte::gThemeChangeRequest.empty()) {
|
|
||||||
// Apply Qt palette theme by name; if unknown, keep current palette
|
|
||||||
(void) kte::ApplyQtThemeByName(kte::gThemeChangeRequest);
|
|
||||||
}
|
|
||||||
kte::gThemeChangePending = false;
|
|
||||||
kte::gThemeChangeRequest.clear();
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visual font picker request (Qt only)
|
|
||||||
if (kte::gFontDialogRequested) {
|
|
||||||
// Seed initial font from current or default
|
|
||||||
QFont seed;
|
|
||||||
if (!kte::gCurrentFontFamily.empty()) {
|
|
||||||
seed = QFont(QString::fromStdString(kte::gCurrentFontFamily),
|
|
||||||
(int) std::lround(kte::gCurrentFontSize > 0 ? kte::gCurrentFontSize : 18));
|
|
||||||
} else {
|
|
||||||
seed = window_ ? window_->font() : QFont();
|
|
||||||
}
|
|
||||||
bool ok = false;
|
|
||||||
const QFont chosen = QFontDialog::getFont(&ok, seed, window_, QStringLiteral("Choose Editor Font"));
|
|
||||||
if (ok) {
|
|
||||||
// Queue font change via existing hooks
|
|
||||||
kte::gFontFamilyRequest = chosen.family().toStdString();
|
|
||||||
// Use pixel size if available, otherwise convert from point size approximately
|
|
||||||
int px = chosen.pixelSize();
|
|
||||||
if (px <= 0) {
|
|
||||||
// Approximate points to pixels (96 DPI assumption); Qt will rasterize appropriately
|
|
||||||
px = (int) std::lround(chosen.pointSizeF() * 96.0 / 72.0);
|
|
||||||
if (px <= 0)
|
|
||||||
px = 18;
|
|
||||||
}
|
|
||||||
kte::gFontSizeRequest = static_cast<float>(px);
|
|
||||||
kte::gFontChangePending = true;
|
|
||||||
}
|
|
||||||
kte::gFontDialogRequested = false;
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply any queued font change requests (Qt)
|
|
||||||
if (kte::gFontChangePending) {
|
|
||||||
// Derive target family
|
|
||||||
auto map_family = [](const std::string &name) -> QString {
|
|
||||||
std::string n = name;
|
|
||||||
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
|
|
||||||
return (char) std::tolower(c);
|
|
||||||
});
|
|
||||||
QString fam;
|
|
||||||
if (n == "brass" || n == "brassmono" || n == "brass mono") {
|
|
||||||
fam = QStringLiteral("Brass Mono");
|
|
||||||
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
|
|
||||||
fam = QStringLiteral("JetBrains Mono");
|
|
||||||
} else if (n == "iosevka") {
|
|
||||||
fam = QStringLiteral("Iosevka");
|
|
||||||
} else if (n == "inconsolata" || n == "inconsolataex") {
|
|
||||||
fam = QStringLiteral("Inconsolata");
|
|
||||||
} else if (n == "space" || n == "spacemono" || n == "space mono") {
|
|
||||||
fam = QStringLiteral("Space Mono");
|
|
||||||
} else if (n == "go") {
|
|
||||||
fam = QStringLiteral("Go Mono");
|
|
||||||
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
|
|
||||||
fam = QStringLiteral("IBM Plex Mono");
|
|
||||||
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
|
|
||||||
fam = QStringLiteral("Fira Code");
|
|
||||||
} else if (!name.empty()) {
|
|
||||||
fam = QString::fromStdString(name);
|
|
||||||
}
|
|
||||||
// Validate availability; choose fallback if needed
|
|
||||||
const auto families = QFontDatabase::families();
|
|
||||||
if (!fam.isEmpty() && families.contains(fam)) {
|
|
||||||
return fam;
|
|
||||||
}
|
|
||||||
// Fallback chain
|
|
||||||
const QStringList fallbacks = {
|
|
||||||
QStringLiteral("Brass Mono"),
|
|
||||||
QStringLiteral("JetBrains Mono"),
|
|
||||||
QStringLiteral("SF Mono"),
|
|
||||||
QStringLiteral("Menlo"),
|
|
||||||
QStringLiteral("Monaco"),
|
|
||||||
QStringLiteral("Courier New"),
|
|
||||||
QStringLiteral("Courier"),
|
|
||||||
QStringLiteral("Monospace")
|
|
||||||
};
|
|
||||||
for (const auto &fb: fallbacks) {
|
|
||||||
if (families.contains(fb))
|
|
||||||
return fb;
|
|
||||||
}
|
|
||||||
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
|
|
||||||
};
|
|
||||||
|
|
||||||
QString target_family;
|
|
||||||
if (!kte::gFontFamilyRequest.empty()) {
|
|
||||||
target_family = map_family(kte::gFontFamilyRequest);
|
|
||||||
} else if (!kte::gCurrentFontFamily.empty()) {
|
|
||||||
target_family = QString::fromStdString(kte::gCurrentFontFamily);
|
|
||||||
}
|
|
||||||
int target_px = 0;
|
|
||||||
if (kte::gFontSizeRequest > 0.0f) {
|
|
||||||
target_px = (int) std::lround(kte::gFontSizeRequest);
|
|
||||||
} else if (kte::gCurrentFontSize > 0.0f) {
|
|
||||||
target_px = (int) std::lround(kte::gCurrentFontSize);
|
|
||||||
}
|
|
||||||
if (target_px <= 0)
|
|
||||||
target_px = 18;
|
|
||||||
if (target_family.isEmpty())
|
|
||||||
target_family = QStringLiteral("Monospace");
|
|
||||||
|
|
||||||
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
|
|
||||||
mw->SetFontFamilyAndSize(target_family, target_px);
|
|
||||||
}
|
|
||||||
// Update globals
|
|
||||||
kte::gCurrentFontFamily = target_family.toStdString();
|
|
||||||
kte::gCurrentFontSize = static_cast<float>(target_px);
|
|
||||||
// Reset requests
|
|
||||||
kte::gFontChangePending = false;
|
|
||||||
kte::gFontFamilyRequest.clear();
|
|
||||||
kte::gFontSizeRequest = 0.0f;
|
|
||||||
|
|
||||||
// Recompute editor dimensions to match new metrics
|
|
||||||
QFont f(target_family, target_px);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = std::max(12, fm.height());
|
|
||||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
|
|
||||||
const int w = window_ ? window_->width() : 0;
|
|
||||||
const int h = window_ ? window_->height() : 0;
|
|
||||||
const int pad = 16;
|
|
||||||
const int status_h = line_h + 4;
|
|
||||||
const int avail_w = std::max(0, w - 2 * pad);
|
|
||||||
const int avail_h = std::max(0, h - 2 * pad - status_h);
|
|
||||||
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
|
|
||||||
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
|
|
||||||
ed.SetDimensions(rows, cols);
|
|
||||||
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw current frame (request repaint)
|
|
||||||
renderer_.Draw(ed);
|
|
||||||
|
|
||||||
// Detect window close
|
|
||||||
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
|
|
||||||
if (mw->WasClosed()) {
|
|
||||||
running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void
|
|
||||||
GUIFrontend::Shutdown()
|
|
||||||
{
|
|
||||||
if (window_) {
|
|
||||||
window_->close();
|
|
||||||
delete window_;
|
|
||||||
window_ = nullptr;
|
|
||||||
}
|
|
||||||
if (app_) {
|
|
||||||
delete app_;
|
|
||||||
app_ = nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,538 +0,0 @@
|
|||||||
// Refactor: route all key processing through command subsystem, mirroring ImGuiInputHandler
|
|
||||||
|
|
||||||
#include "QtInputHandler.h"
|
|
||||||
|
|
||||||
#include <QKeyEvent>
|
|
||||||
|
|
||||||
#include <ncurses.h>
|
|
||||||
|
|
||||||
#include "Editor.h"
|
|
||||||
#include "KKeymap.h"
|
|
||||||
|
|
||||||
// Temporary verbose logging to debug macOS Qt key translation issues
|
|
||||||
// Default to off; enable by defining QT_IH_DEBUG=1 at compile time when needed.
|
|
||||||
#ifndef QT_IH_DEBUG
|
|
||||||
#define QT_IH_DEBUG 0
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if QT_IH_DEBUG
|
|
||||||
#include <cstdio>
|
|
||||||
static const char *
|
|
||||||
mods_str(Qt::KeyboardModifiers m)
|
|
||||||
{
|
|
||||||
static thread_local char buf[64];
|
|
||||||
buf[0] = '\0';
|
|
||||||
bool first = true;
|
|
||||||
auto add = [&](const char *s) {
|
|
||||||
if (!first)
|
|
||||||
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "|");
|
|
||||||
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "%s", s);
|
|
||||||
first = false;
|
|
||||||
};
|
|
||||||
if (m & Qt::ShiftModifier)
|
|
||||||
add("Shift");
|
|
||||||
if (m & Qt::ControlModifier)
|
|
||||||
add("Ctrl");
|
|
||||||
if (m & Qt::AltModifier)
|
|
||||||
add("Alt");
|
|
||||||
if (m & Qt::MetaModifier)
|
|
||||||
add("Meta");
|
|
||||||
if (first)
|
|
||||||
std::snprintf(buf, sizeof(buf), "none");
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
#define LOGF(...) std::fprintf(stderr, __VA_ARGS__)
|
|
||||||
#else
|
|
||||||
#define LOGF(...) ((void)0)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static bool
|
|
||||||
IsPrintableQt(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
// Printable if it yields non-empty text and no Ctrl/Meta modifier
|
|
||||||
if (e.modifiers() & (Qt::ControlModifier | Qt::MetaModifier))
|
|
||||||
return false;
|
|
||||||
const QString t = e.text();
|
|
||||||
return !t.isEmpty() && !t.at(0).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static int
|
|
||||||
ToAsciiKey(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
const QString t = e.text();
|
|
||||||
if (!t.isEmpty()) {
|
|
||||||
const QChar c = t.at(0);
|
|
||||||
if (!c.isNull())
|
|
||||||
return KLowerAscii(c.unicode());
|
|
||||||
}
|
|
||||||
// When modifiers (like Control) are held, Qt::text() can be empty on macOS.
|
|
||||||
// Fall back to mapping common virtual keys to ASCII.
|
|
||||||
switch (e.key()) {
|
|
||||||
case Qt::Key_A:
|
|
||||||
return 'a';
|
|
||||||
case Qt::Key_B:
|
|
||||||
return 'b';
|
|
||||||
case Qt::Key_C:
|
|
||||||
return 'c';
|
|
||||||
case Qt::Key_D:
|
|
||||||
return 'd';
|
|
||||||
case Qt::Key_E:
|
|
||||||
return 'e';
|
|
||||||
case Qt::Key_F:
|
|
||||||
return 'f';
|
|
||||||
case Qt::Key_G:
|
|
||||||
return 'g';
|
|
||||||
case Qt::Key_H:
|
|
||||||
return 'h';
|
|
||||||
case Qt::Key_I:
|
|
||||||
return 'i';
|
|
||||||
case Qt::Key_J:
|
|
||||||
return 'j';
|
|
||||||
case Qt::Key_K:
|
|
||||||
return 'k';
|
|
||||||
case Qt::Key_L:
|
|
||||||
return 'l';
|
|
||||||
case Qt::Key_M:
|
|
||||||
return 'm';
|
|
||||||
case Qt::Key_N:
|
|
||||||
return 'n';
|
|
||||||
case Qt::Key_O:
|
|
||||||
return 'o';
|
|
||||||
case Qt::Key_P:
|
|
||||||
return 'p';
|
|
||||||
case Qt::Key_Q:
|
|
||||||
return 'q';
|
|
||||||
case Qt::Key_R:
|
|
||||||
return 'r';
|
|
||||||
case Qt::Key_S:
|
|
||||||
return 's';
|
|
||||||
case Qt::Key_T:
|
|
||||||
return 't';
|
|
||||||
case Qt::Key_U:
|
|
||||||
return 'u';
|
|
||||||
case Qt::Key_V:
|
|
||||||
return 'v';
|
|
||||||
case Qt::Key_W:
|
|
||||||
return 'w';
|
|
||||||
case Qt::Key_X:
|
|
||||||
return 'x';
|
|
||||||
case Qt::Key_Y:
|
|
||||||
return 'y';
|
|
||||||
case Qt::Key_Z:
|
|
||||||
return 'z';
|
|
||||||
case Qt::Key_0:
|
|
||||||
return '0';
|
|
||||||
case Qt::Key_1:
|
|
||||||
return '1';
|
|
||||||
case Qt::Key_2:
|
|
||||||
return '2';
|
|
||||||
case Qt::Key_3:
|
|
||||||
return '3';
|
|
||||||
case Qt::Key_4:
|
|
||||||
return '4';
|
|
||||||
case Qt::Key_5:
|
|
||||||
return '5';
|
|
||||||
case Qt::Key_6:
|
|
||||||
return '6';
|
|
||||||
case Qt::Key_7:
|
|
||||||
return '7';
|
|
||||||
case Qt::Key_8:
|
|
||||||
return '8';
|
|
||||||
case Qt::Key_9:
|
|
||||||
return '9';
|
|
||||||
case Qt::Key_Comma:
|
|
||||||
return ',';
|
|
||||||
case Qt::Key_Period:
|
|
||||||
return '.';
|
|
||||||
case Qt::Key_Semicolon:
|
|
||||||
return ';';
|
|
||||||
case Qt::Key_Apostrophe:
|
|
||||||
return '\'';
|
|
||||||
case Qt::Key_Minus:
|
|
||||||
return '-';
|
|
||||||
case Qt::Key_Equal:
|
|
||||||
return '=';
|
|
||||||
case Qt::Key_Slash:
|
|
||||||
return '/';
|
|
||||||
case Qt::Key_Backslash:
|
|
||||||
return '\\';
|
|
||||||
case Qt::Key_BracketLeft:
|
|
||||||
return '[';
|
|
||||||
case Qt::Key_BracketRight:
|
|
||||||
return ']';
|
|
||||||
case Qt::Key_QuoteLeft:
|
|
||||||
return '`';
|
|
||||||
case Qt::Key_Space:
|
|
||||||
return ' ';
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Case-preserving ASCII derivation for k-prefix handling where we need to
|
|
||||||
// distinguish between 'C' and 'c'. Falls back to virtual-key mapping if
|
|
||||||
// event text is unavailable (common when Control/Meta held on macOS).
|
|
||||||
static int
|
|
||||||
ToAsciiKeyPreserveCase(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
const QString t = e.text();
|
|
||||||
if (!t.isEmpty()) {
|
|
||||||
const QChar c = t.at(0);
|
|
||||||
if (!c.isNull())
|
|
||||||
return c.unicode();
|
|
||||||
}
|
|
||||||
// Fall back to virtual key mapping (letters as uppercase A..Z)
|
|
||||||
switch (e.key()) {
|
|
||||||
case Qt::Key_A:
|
|
||||||
return 'A';
|
|
||||||
case Qt::Key_B:
|
|
||||||
return 'B';
|
|
||||||
case Qt::Key_C:
|
|
||||||
return 'C';
|
|
||||||
case Qt::Key_D:
|
|
||||||
return 'D';
|
|
||||||
case Qt::Key_E:
|
|
||||||
return 'E';
|
|
||||||
case Qt::Key_F:
|
|
||||||
return 'F';
|
|
||||||
case Qt::Key_G:
|
|
||||||
return 'G';
|
|
||||||
case Qt::Key_H:
|
|
||||||
return 'H';
|
|
||||||
case Qt::Key_I:
|
|
||||||
return 'I';
|
|
||||||
case Qt::Key_J:
|
|
||||||
return 'J';
|
|
||||||
case Qt::Key_K:
|
|
||||||
return 'K';
|
|
||||||
case Qt::Key_L:
|
|
||||||
return 'L';
|
|
||||||
case Qt::Key_M:
|
|
||||||
return 'M';
|
|
||||||
case Qt::Key_N:
|
|
||||||
return 'N';
|
|
||||||
case Qt::Key_O:
|
|
||||||
return 'O';
|
|
||||||
case Qt::Key_P:
|
|
||||||
return 'P';
|
|
||||||
case Qt::Key_Q:
|
|
||||||
return 'Q';
|
|
||||||
case Qt::Key_R:
|
|
||||||
return 'R';
|
|
||||||
case Qt::Key_S:
|
|
||||||
return 'S';
|
|
||||||
case Qt::Key_T:
|
|
||||||
return 'T';
|
|
||||||
case Qt::Key_U:
|
|
||||||
return 'U';
|
|
||||||
case Qt::Key_V:
|
|
||||||
return 'V';
|
|
||||||
case Qt::Key_W:
|
|
||||||
return 'W';
|
|
||||||
case Qt::Key_X:
|
|
||||||
return 'X';
|
|
||||||
case Qt::Key_Y:
|
|
||||||
return 'Y';
|
|
||||||
case Qt::Key_Z:
|
|
||||||
return 'Z';
|
|
||||||
case Qt::Key_Comma:
|
|
||||||
return ',';
|
|
||||||
case Qt::Key_Period:
|
|
||||||
return '.';
|
|
||||||
case Qt::Key_Semicolon:
|
|
||||||
return ';';
|
|
||||||
case Qt::Key_Apostrophe:
|
|
||||||
return '\'';
|
|
||||||
case Qt::Key_Minus:
|
|
||||||
return '-';
|
|
||||||
case Qt::Key_Equal:
|
|
||||||
return '=';
|
|
||||||
case Qt::Key_Slash:
|
|
||||||
return '/';
|
|
||||||
case Qt::Key_Backslash:
|
|
||||||
return '\\';
|
|
||||||
case Qt::Key_BracketLeft:
|
|
||||||
return '[';
|
|
||||||
case Qt::Key_BracketRight:
|
|
||||||
return ']';
|
|
||||||
case Qt::Key_QuoteLeft:
|
|
||||||
return '`';
|
|
||||||
case Qt::Key_Space:
|
|
||||||
return ' ';
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
bool
|
|
||||||
QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
const Qt::KeyboardModifiers mods = e.modifiers();
|
|
||||||
LOGF("[QtIH] keyPress key=0x%X mods=%s text='%s' k_prefix=%d k_ctrl_pending=%d esc_meta=%d\n",
|
|
||||||
e.key(), mods_str(mods), e.text().toUtf8().constData(), (int)k_prefix_, (int)k_ctrl_pending_,
|
|
||||||
(int)esc_meta_);
|
|
||||||
|
|
||||||
// Control-chord detection: only treat the physical Control key as control-like.
|
|
||||||
// Do NOT include Meta (Command) here so that ⌘-letter shortcuts do not fall into
|
|
||||||
// the Ctrl map (prevents ⌘-T being mistaken for C-t).
|
|
||||||
const bool ctrl_like = (mods & Qt::ControlModifier);
|
|
||||||
|
|
||||||
// 1) Universal argument digits (when active), consume digits without enqueuing commands
|
|
||||||
if (ed_ &&ed_
|
|
||||||
|
|
||||||
->
|
|
||||||
UArg() != 0
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
|
|
||||||
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
|
|
||||||
int d = e.key() - Qt::Key_0;
|
|
||||||
ed_->UArgDigit(d);
|
|
||||||
// request status refresh
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::UArgStatus, std::string(), 0});
|
|
||||||
LOGF("[QtIH] UArg digit %d -> enqueue UArgStatus\n", d);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Enter k-prefix on C-k
|
|
||||||
if (ctrl_like && (e.key() == Qt::Key_K)) {
|
|
||||||
k_prefix_ = true;
|
|
||||||
k_ctrl_pending_ = false;
|
|
||||||
LOGF("[QtIH] Enter KPrefix\n");
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::KPrefix, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) If currently in k-prefix, resolve next key via KLookupKCommand
|
|
||||||
if (k_prefix_) {
|
|
||||||
// ESC/meta prefix should not interfere with k-suffix resolution
|
|
||||||
esc_meta_ = false;
|
|
||||||
// Support literal 'C' (uppercase) or '^' to indicate the next key is Ctrl-qualified.
|
|
||||||
// Use case-preserving derivation so that 'c' (lowercase) can still be a valid suffix
|
|
||||||
// like C-k c (BufferClose).
|
|
||||||
int ascii_raw = ToAsciiKeyPreserveCase(e);
|
|
||||||
if (ascii_raw == 'C' || ascii_raw == '^') {
|
|
||||||
k_ctrl_pending_ = true;
|
|
||||||
if (ed_)
|
|
||||||
ed_->SetStatus("C-k C _");
|
|
||||||
LOGF("[QtIH] KPrefix: set k_ctrl_pending via '%c'\n", (ascii_raw == 'C') ? 'C' : '^');
|
|
||||||
return true; // consume, wait for next key
|
|
||||||
}
|
|
||||||
int ascii_key = (ascii_raw != 0) ? ascii_raw : ToAsciiKey(e);
|
|
||||||
int lower = KLowerAscii(ascii_key);
|
|
||||||
// Only pass a control suffix for specific supported keys (d/x/q),
|
|
||||||
// matching ImGui behavior so that holding Ctrl during the suffix
|
|
||||||
// doesn't break other mappings like C-k c (BufferClose).
|
|
||||||
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
|
|
||||||
bool pass_ctrl = (ctrl_like || k_ctrl_pending_) && ctrl_suffix_supported;
|
|
||||||
k_ctrl_pending_ = false; // consume pending qualifier on any suffix
|
|
||||||
LOGF("[QtIH] KPrefix: ascii_key=%d lower=%d pass_ctrl=%d\n", ascii_key, lower, (int)pass_ctrl);
|
|
||||||
if (ascii_key != 0) {
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
|
|
||||||
LOGF("[QtIH] KPrefix: mapped to command id=%d\n", (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
} else {
|
|
||||||
// Unknown k-command: notify
|
|
||||||
std::string a;
|
|
||||||
a.push_back(static_cast<char>(ascii_key));
|
|
||||||
LOGF("[QtIH] KPrefix: unknown command for '%c'\n", (char)ascii_key);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::UnknownKCommand, a, 0});
|
|
||||||
}
|
|
||||||
k_prefix_ = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// If not resolvable, consume and exit k-prefix
|
|
||||||
k_prefix_ = false;
|
|
||||||
LOGF("[QtIH] KPrefix: unresolved key; exiting prefix\n");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3.5) GUI shortcut: Command/Meta + T opens the visual font picker (Qt only).
|
|
||||||
// Require Meta present and Control NOT present so Ctrl-T never triggers this.
|
|
||||||
if ((mods & Qt::MetaModifier) && !(mods & Qt::ControlModifier) && e.key() == Qt::Key_T) {
|
|
||||||
LOGF("[QtIH] Meta/Super-T -> VisualFontPickerToggle\n");
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::VisualFontPickerToggle, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) ESC as Meta prefix (set state). Alt/Meta chord handled below directly.
|
|
||||||
if (e.key() == Qt::Key_Escape) {
|
|
||||||
esc_meta_ = true;
|
|
||||||
LOGF("[QtIH] ESC: set esc_meta\n");
|
|
||||||
return true; // consumed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Alt/Meta bindings (ESC f/b equivalent). Handle either Alt/Meta or pending esc_meta_
|
|
||||||
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
|
|
||||||
#if defined(__APPLE__)
|
|
||||||
if (esc_meta_ || (mods & Qt::AltModifier)) {
|
|
||||||
|
|
||||||
|
|
||||||
#else
|
|
||||||
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
|
|
||||||
#endif
|
|
||||||
int ascii_key = 0;
|
|
||||||
if (e.key() == Qt::Key_Backspace) {
|
|
||||||
ascii_key = KEY_BACKSPACE;
|
|
||||||
} else if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
|
|
||||||
ascii_key = 'a' + (e.key() - Qt::Key_A);
|
|
||||||
} else if (e.key() == Qt::Key_Comma) {
|
|
||||||
ascii_key = '<';
|
|
||||||
} else if (e.key() == Qt::Key_Period) {
|
|
||||||
ascii_key = '>';
|
|
||||||
}
|
|
||||||
// If still unknown, try deriving from text (covers digits, punctuation, locale)
|
|
||||||
if (ascii_key == 0) {
|
|
||||||
ascii_key = ToAsciiKey(e);
|
|
||||||
}
|
|
||||||
esc_meta_ = false; // one-shot regardless
|
|
||||||
if (ascii_key != 0) {
|
|
||||||
ascii_key = KLowerAscii(ascii_key);
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupEscCommand(ascii_key, id)) {
|
|
||||||
LOGF("[QtIH] ESC/Meta: mapped '%d' -> id=%d\n", ascii_key, (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
// Report invalid ESC sequence just like ImGui path
|
|
||||||
LOGF("[QtIH] ESC/Meta: unknown command for ascii=%d\n", ascii_key);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::UnknownEscCommand, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nothing derivable: consume (ESC prefix cleared) and do not insert text
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Control-chord direct mappings (e.g., C-n/C-p/C-f/C-b...)
|
|
||||||
if (ctrl_like) {
|
|
||||||
// Universal argument handling: C-u starts collection; C-g cancels
|
|
||||||
if (e.key() == Qt::Key_U) {
|
|
||||||
if (ed_)
|
|
||||||
ed_->UArgStart();
|
|
||||||
LOGF("[QtIH] Ctrl-chord: start universal argument\n");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (e.key() == Qt::Key_G) {
|
|
||||||
if (ed_)
|
|
||||||
ed_->UArgClear();
|
|
||||||
k_ctrl_pending_ = false;
|
|
||||||
k_prefix_ = false;
|
|
||||||
LOGF("[QtIH] Ctrl-chord: cancel universal argument and k-prefix via C-g\n");
|
|
||||||
// Fall through to map C-g to Refresh via ctrl map
|
|
||||||
}
|
|
||||||
if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
|
|
||||||
int ascii_key = 'a' + (e.key() - Qt::Key_A);
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupCtrlCommand(ascii_key, id)) {
|
|
||||||
LOGF("[QtIH] Ctrl-chord: 'C-%c' -> id=%d\n", (char)ascii_key, (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no mapping, continue to allow other keys below
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7) Special navigation/edit keys (match ImGui behavior)
|
|
||||||
{
|
|
||||||
CommandId id;
|
|
||||||
bool has = false;
|
|
||||||
switch (e.key()) {
|
|
||||||
case Qt::Key_Return:
|
|
||||||
case Qt::Key_Enter:
|
|
||||||
id = CommandId::Newline;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Backspace:
|
|
||||||
id = CommandId::Backspace;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Delete:
|
|
||||||
id = CommandId::DeleteChar;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Left:
|
|
||||||
id = CommandId::MoveLeft;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Right:
|
|
||||||
id = CommandId::MoveRight;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Up:
|
|
||||||
id = CommandId::MoveUp;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Down:
|
|
||||||
id = CommandId::MoveDown;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Home:
|
|
||||||
id = CommandId::MoveHome;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_End:
|
|
||||||
id = CommandId::MoveEnd;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_PageUp:
|
|
||||||
id = CommandId::PageUp;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_PageDown:
|
|
||||||
id = CommandId::PageDown;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (has) {
|
|
||||||
LOGF("[QtIH] Special key -> id=%d\n", (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8) Insert printable text
|
|
||||||
if (IsPrintableQt(e)) {
|
|
||||||
std::string s = e.text().toStdString();
|
|
||||||
if (!s.empty()) {
|
|
||||||
LOGF("[QtIH] InsertText '%s'\n", s.c_str());
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::InsertText, s, 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGF("[QtIH] Unhandled key\n");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
bool
|
|
||||||
QtInputHandler::Poll(MappedInput &out)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
|
||||||
if (q_.empty())
|
|
||||||
return false;
|
|
||||||
out = q_.front();
|
|
||||||
q_.pop();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -39,15 +39,13 @@ subject to refinement):
|
|||||||
`C-g`.
|
`C-g`.
|
||||||
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
|
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
|
||||||
`C-k q` (quit with confirm), `C-k C-q` (quit immediately).
|
`C-k q` (quit with confirm), `C-k C-q` (quit immediately).
|
||||||
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-k
|
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
|
||||||
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u`
|
region), `C-y` (yank), `C-u` (universal argument).
|
||||||
(universal argument).
|
|
||||||
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
||||||
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
|
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
|
||||||
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
|
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
|
||||||
(close), `C-k C-r` (reload).
|
(close), `C-k l` (reload).
|
||||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g`
|
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
|
||||||
(goto line).
|
|
||||||
|
|
||||||
See `ke.md` for the canonical ke reference retained for now.
|
See `ke.md` for the canonical ke reference retained for now.
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -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
|
||||||
@@ -10,10 +10,12 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
|
#include <deque>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
|
||||||
#include "SwapRecorder.h"
|
#include "SwapRecorder.h"
|
||||||
|
#include "ErrorRecovery.h"
|
||||||
|
|
||||||
class Buffer;
|
class Buffer;
|
||||||
|
|
||||||
@@ -22,9 +24,17 @@ namespace kte {
|
|||||||
enum class SwapRecType : std::uint8_t {
|
enum class SwapRecType : std::uint8_t {
|
||||||
INS = 1,
|
INS = 1,
|
||||||
DEL = 2,
|
DEL = 2,
|
||||||
|
// SPLIT and JOIN are no longer written; replay still reads them.
|
||||||
SPLIT = 3,
|
SPLIT = 3,
|
||||||
JOIN = 4,
|
JOIN = 4,
|
||||||
META = 0xF0,
|
META = 0xF0,
|
||||||
|
// A checkpoint too large for one record (a record's length field is 24
|
||||||
|
// bits) is written as BEGIN [encver u8][total u64][crc32 u32], DATA
|
||||||
|
// chunks (raw bytes), END [encver u8]. Replay applies it only at END,
|
||||||
|
// with the size and CRC-32 matching; an unfinished one is ignored.
|
||||||
|
CHKPT_BEGIN = 0xFB,
|
||||||
|
CHKPT_DATA = 0xFC,
|
||||||
|
CHKPT_END = 0xFD,
|
||||||
CHKPT = 0xFE,
|
CHKPT = 0xFE,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,6 +88,19 @@ public:
|
|||||||
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
||||||
void Checkpoint(Buffer *buf = nullptr);
|
void Checkpoint(Buffer *buf = nullptr);
|
||||||
|
|
||||||
|
// Main thread, called once per frame: request the recovery checkpoint of
|
||||||
|
// any journal left with a gap (a lost record) whose last attempt is more
|
||||||
|
// than the retry interval ago. Otherwise that checkpoint is only
|
||||||
|
// requested by the next edit, and a journal that lost a record just
|
||||||
|
// before the user stopped typing would stay behind indefinitely.
|
||||||
|
void RetryGapCheckpoints();
|
||||||
|
|
||||||
|
// Main thread: the oldest pending message for the user (a journal that
|
||||||
|
// another kte holds, or that fell behind because writes failed), or ""
|
||||||
|
// when there is none. Such failures otherwise went only to the error
|
||||||
|
// log, leaving crash recovery silently off or incomplete.
|
||||||
|
std::string TakeUserNotice();
|
||||||
|
|
||||||
|
|
||||||
void SetConfig(const SwapConfig &cfg)
|
void SetConfig(const SwapConfig &cfg)
|
||||||
{
|
{
|
||||||
@@ -92,6 +115,15 @@ public:
|
|||||||
// Detach(buf) or SwapManager destruction.
|
// Detach(buf) or SwapManager destruction.
|
||||||
SwapRecorder *RecorderFor(Buffer *buf);
|
SwapRecorder *RecorderFor(Buffer *buf);
|
||||||
|
|
||||||
|
// Re-key an attached buffer's journal/recorder entries after its Buffer object
|
||||||
|
// has moved to a new address (e.g. std::vector<Buffer> reallocation/erase-shift).
|
||||||
|
// Callers must ensure no swap records for old_addr are in flight (see Flush())
|
||||||
|
// before calling this, and must not call it with an address that isn't
|
||||||
|
// currently attached. Returns the recorder for new_addr (nullptr if old_addr
|
||||||
|
// wasn't attached); the caller is responsible for calling
|
||||||
|
// new_buf->SetSwapRecorder() with the result.
|
||||||
|
SwapRecorder *Rehome(Buffer *old_addr, Buffer *new_addr);
|
||||||
|
|
||||||
// Notify that the buffer's filename changed (e.g., SaveAs)
|
// Notify that the buffer's filename changed (e.g., SaveAs)
|
||||||
void NotifyFilenameChanged(Buffer &buf);
|
void NotifyFilenameChanged(Buffer &buf);
|
||||||
|
|
||||||
@@ -100,15 +132,34 @@ public:
|
|||||||
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
|
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
|
||||||
// state results from applying records up to the failure point; callers should
|
// state results from applying records up to the failure point; callers should
|
||||||
// treat this as a recovery failure and surface `err`.
|
// treat this as a recovery failure and surface `err`.
|
||||||
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
|
//
|
||||||
|
// A record cut short at end of file (a crash mid-append) ends the replay
|
||||||
|
// successfully; err then describes the incomplete record. If valid_bytes
|
||||||
|
// is given, it receives the length of the journal's valid prefix, so a
|
||||||
|
// caller that keeps using the journal can truncate the torn tail first.
|
||||||
|
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err,
|
||||||
|
std::uint64_t *valid_bytes = nullptr);
|
||||||
|
|
||||||
// Compute the swap path for a file-backed buffer by filename.
|
// Compute the swap path for a file-backed buffer by filename.
|
||||||
// Returns empty string if filename is empty.
|
// Returns empty string if filename is empty.
|
||||||
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
|
// True if another process currently holds the journal at swap_path (it
|
||||||
|
// is live, not left by a crash): it must be neither replayed nor removed.
|
||||||
|
static bool JournalInUse(const std::string &swap_path);
|
||||||
|
|
||||||
|
// False if the journal records which version of `file_path` it applies
|
||||||
|
// to and the file on disk no longer matches (edited or replaced since
|
||||||
|
// the journal started); replaying it would apply edits to the wrong text.
|
||||||
|
// Journals without that record (older kte) are assumed to match.
|
||||||
|
static bool JournalMatchesFile(const std::string &swap_path, const std::string &file_path);
|
||||||
|
|
||||||
// Test-only hook to keep swap path logic centralized.
|
// Test-only hook to keep swap path logic centralized.
|
||||||
// (Avoid duplicating naming rules in unit tests.)
|
// (Avoid duplicating naming rules in unit tests.)
|
||||||
#ifdef KTE_TESTS
|
#ifdef KTE_TESTS
|
||||||
|
// Base files larger than this get their CRC on the writer thread.
|
||||||
|
static void SetSyncCrcLimitForTests(std::uint64_t bytes);
|
||||||
|
|
||||||
static std::string ComputeSwapPathForTests(const Buffer &buf)
|
static std::string ComputeSwapPathForTests(const Buffer &buf)
|
||||||
{
|
{
|
||||||
return ComputeSidecarPath(buf);
|
return ComputeSidecarPath(buf);
|
||||||
@@ -131,6 +182,20 @@ public:
|
|||||||
// Per-buffer toggle
|
// Per-buffer toggle
|
||||||
void SetSuspended(Buffer &buf, bool on);
|
void SetSuspended(Buffer &buf, bool on);
|
||||||
|
|
||||||
|
// Error reporting for background thread
|
||||||
|
struct SwapError {
|
||||||
|
std::uint64_t timestamp_ns{0};
|
||||||
|
std::string message;
|
||||||
|
std::string buffer_name; // filename or "<unnamed>"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query error state (thread-safe)
|
||||||
|
bool HasErrors() const;
|
||||||
|
|
||||||
|
std::string GetLastError() const;
|
||||||
|
|
||||||
|
std::size_t GetErrorCount() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
class BufferRecorder final : public SwapRecorder {
|
class BufferRecorder final : public SwapRecorder {
|
||||||
public:
|
public:
|
||||||
@@ -149,10 +214,6 @@ private:
|
|||||||
|
|
||||||
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
|
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
|
||||||
|
|
||||||
void RecordSplit(Buffer &buf, int row, int col);
|
|
||||||
|
|
||||||
void RecordJoin(Buffer &buf, int row);
|
|
||||||
|
|
||||||
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
||||||
|
|
||||||
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
||||||
@@ -167,16 +228,53 @@ private:
|
|||||||
std::uint64_t last_chkpt_ns{0};
|
std::uint64_t last_chkpt_ns{0};
|
||||||
std::uint64_t edit_bytes_since_chkpt{0};
|
std::uint64_t edit_bytes_since_chkpt{0};
|
||||||
std::uint64_t approx_size_bytes{0};
|
std::uint64_t approx_size_bytes{0};
|
||||||
|
// A record was lost (write failure, breaker open, oversize). Records are
|
||||||
|
// position-based, so later ones no longer apply to the journal's state:
|
||||||
|
// drop them until a checkpoint re-establishes the full content.
|
||||||
|
bool gap{false};
|
||||||
|
bool gap_notified{false}; // the user was told about the current gap
|
||||||
|
bool chkpt_queued{false}; // a checkpoint is queued and not yet being written
|
||||||
|
std::uint32_t header_version{0}; // of the open journal file (0: none yet)
|
||||||
|
bool lockout_notified{false}; // told about the lock; kept across ResetJournal
|
||||||
|
std::uint64_t gap_chkpt_request_ns{0};
|
||||||
|
bool gap_unfixable_reported{false}; // gap on a buffer too large to checkpoint
|
||||||
|
// Another kte process holds this journal's lock: do not write to it.
|
||||||
|
bool locked_out{false};
|
||||||
|
// Identity of the file the journal's records apply to (captured when
|
||||||
|
// the journal starts) and written into its header, so recovery can
|
||||||
|
// tell whether the file changed since.
|
||||||
|
bool has_base{false};
|
||||||
|
std::uint64_t base_size{0};
|
||||||
|
std::int64_t base_mtime_ns{0};
|
||||||
|
bool has_base_crc{false}; // CRC-32 of the base file's content
|
||||||
|
std::uint32_t base_crc{0};
|
||||||
|
// A large base file's CRC is computed by the writer thread before it
|
||||||
|
// creates the journal (see process_one), not on open/save.
|
||||||
|
std::string base_crc_file;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Pending {
|
struct Pending {
|
||||||
Buffer *buf{nullptr};
|
Buffer *buf{nullptr};
|
||||||
SwapRecType type{SwapRecType::INS};
|
SwapRecType type{SwapRecType::INS};
|
||||||
std::vector<std::uint8_t> payload; // framed payload only
|
std::vector<std::uint8_t> payload; // framed payload only
|
||||||
|
std::string chkpt; // CHKPT: the buffer's content (framed by the writer)
|
||||||
bool urgent_flush{false};
|
bool urgent_flush{false};
|
||||||
std::uint64_t seq{0};
|
std::uint64_t seq{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A record (or, for a large checkpoint, several) as segments to write:
|
||||||
|
// small framing buffers owned here, and a reference to the checkpoint
|
||||||
|
// content (not copied).
|
||||||
|
struct RecordFrames {
|
||||||
|
std::deque<std::vector<std::uint8_t> > owned;
|
||||||
|
std::vector<std::pair<const std::uint8_t *, std::size_t> > segs;
|
||||||
|
std::size_t total = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void frame_pending(const Pending &p, RecordFrames &f);
|
||||||
|
|
||||||
|
static bool write_frames(int fd, const RecordFrames &f);
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||||
|
|
||||||
@@ -188,16 +286,39 @@ private:
|
|||||||
|
|
||||||
static std::string SwapDirRoot();
|
static std::string SwapDirRoot();
|
||||||
|
|
||||||
static bool write_header(int fd);
|
static bool write_header(int fd, const JournalCtx &ctx);
|
||||||
|
|
||||||
static bool open_ctx(JournalCtx &ctx, const std::string &path);
|
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err, bool *locked_out = nullptr);
|
||||||
|
|
||||||
static void close_ctx(JournalCtx &ctx);
|
static void close_ctx(JournalCtx &ctx);
|
||||||
|
|
||||||
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record);
|
static bool compact_to_checkpoint(JournalCtx &ctx, const RecordFrames &chkpt_record,
|
||||||
|
std::string &err);
|
||||||
|
|
||||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||||
|
|
||||||
|
// CRC-32 of a file's content; false if it cannot be read.
|
||||||
|
static bool file_crc32(const std::string &path, std::uint32_t &out);
|
||||||
|
|
||||||
|
// Identity (size, mtime, content CRC) of the file a journal's records
|
||||||
|
// apply to. Computed without holding mtx_ (it reads the file), then
|
||||||
|
// stored into the JournalCtx under it.
|
||||||
|
struct BaseId {
|
||||||
|
bool has{false};
|
||||||
|
std::uint64_t size{0};
|
||||||
|
std::int64_t mtime_ns{0};
|
||||||
|
bool has_crc{false};
|
||||||
|
std::uint32_t crc{0};
|
||||||
|
std::string crc_file; // set when the CRC is deferred to the writer thread
|
||||||
|
};
|
||||||
|
|
||||||
|
static BaseId compute_base(const std::string &file);
|
||||||
|
|
||||||
|
// Writer thread: compute a deferred base CRC (see BaseId::crc_file).
|
||||||
|
void complete_base_crc(JournalCtx &ctx);
|
||||||
|
|
||||||
|
static void apply_base(JournalCtx &ctx, const BaseId &id);
|
||||||
|
|
||||||
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
|
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
|
||||||
|
|
||||||
static void put_le64(std::uint8_t dst[8], std::uint64_t v);
|
static void put_le64(std::uint8_t dst[8], std::uint64_t v);
|
||||||
@@ -210,11 +331,19 @@ private:
|
|||||||
|
|
||||||
void process_one(const Pending &p);
|
void process_one(const Pending &p);
|
||||||
|
|
||||||
|
// Error reporting helper (called from writer thread)
|
||||||
|
void report_error(const std::string &message, Buffer *buf = nullptr);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
SwapConfig cfg_{};
|
SwapConfig cfg_{};
|
||||||
std::unordered_map<Buffer *, JournalCtx> journals_;
|
std::unordered_map<Buffer *, JournalCtx> journals_;
|
||||||
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
||||||
std::mutex mtx_;
|
mutable std::mutex mtx_;
|
||||||
|
// Serialises closing journal fds with the writer's periodic fsync, so
|
||||||
|
// neither can act on an fd the other has closed (or that was reused).
|
||||||
|
// Taken before mtx_ when both are needed; edits (which take mtx_) never
|
||||||
|
// wait for an fsync.
|
||||||
|
mutable std::mutex io_mtx_;
|
||||||
std::condition_variable cv_;
|
std::condition_variable cv_;
|
||||||
std::vector<Pending> queue_;
|
std::vector<Pending> queue_;
|
||||||
std::uint64_t next_seq_{0};
|
std::uint64_t next_seq_{0};
|
||||||
@@ -222,5 +351,16 @@ private:
|
|||||||
std::uint64_t inflight_{0};
|
std::uint64_t inflight_{0};
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
std::thread worker_;
|
std::thread worker_;
|
||||||
|
|
||||||
|
// Error tracking (protected by mtx_)
|
||||||
|
std::deque<SwapError> errors_; // bounded to max 100 entries
|
||||||
|
std::deque<std::string> user_notices_; // see TakeUserNotice(); guarded by mtx_
|
||||||
|
|
||||||
|
// Queue a message for the user (mtx_ must be held).
|
||||||
|
void notify_user_locked_(std::string msg);
|
||||||
|
std::size_t total_error_count_{0};
|
||||||
|
|
||||||
|
// Circuit breaker for swap operations (protected by mtx_)
|
||||||
|
CircuitBreaker circuit_breaker_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#include "SyscallWrappers.h"
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
namespace syscall {
|
||||||
|
int
|
||||||
|
Open(const char *path, int flags, mode_t mode)
|
||||||
|
{
|
||||||
|
int fd;
|
||||||
|
do {
|
||||||
|
fd = ::open(path, flags, mode);
|
||||||
|
} while (fd == -1 && errno == EINTR);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Close(int fd)
|
||||||
|
{
|
||||||
|
// Do not retry on EINTR: on Linux (and most systems) the descriptor is
|
||||||
|
// released even when close() is interrupted, so a retry could close an
|
||||||
|
// fd that another thread (e.g. the swap writer) has just opened.
|
||||||
|
const int ret = ::close(fd);
|
||||||
|
if (ret == -1 && errno == EINTR)
|
||||||
|
return 0;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Fsync(int fd)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
do {
|
||||||
|
ret = ::fsync(fd);
|
||||||
|
} while (ret == -1 && errno == EINTR);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Fstat(int fd, struct stat *buf)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
do {
|
||||||
|
ret = ::fstat(fd, buf);
|
||||||
|
} while (ret == -1 && errno == EINTR);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Ftruncate(int fd, off_t length)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
do {
|
||||||
|
ret = ::ftruncate(fd, length);
|
||||||
|
} while (ret == -1 && errno == EINTR);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Fchmod(int fd, mode_t mode)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
do {
|
||||||
|
ret = ::fchmod(fd, mode);
|
||||||
|
} while (ret == -1 && errno == EINTR);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Mkstemp(char *template_str)
|
||||||
|
{
|
||||||
|
int fd;
|
||||||
|
do {
|
||||||
|
fd = ::mkstemp(template_str);
|
||||||
|
} while (fd == -1 && errno == EINTR);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
} // namespace syscall
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// SyscallWrappers.h - EINTR-safe syscall wrappers for kte
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
namespace syscall {
|
||||||
|
// EINTR-safe wrapper for open(2).
|
||||||
|
// Returns file descriptor on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Open(const char *path, int flags, mode_t mode = 0);
|
||||||
|
|
||||||
|
// Wrapper for close(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// EINTR is treated as success and never retried: the descriptor has already
|
||||||
|
// been released on Linux, and retrying could close an unrelated, reused fd.
|
||||||
|
int Close(int fd);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for fsync(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Fsync(int fd);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for fstat(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Fstat(int fd, struct stat *buf);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for fchmod(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Fchmod(int fd, mode_t mode);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for ftruncate(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Ftruncate(int fd, off_t length);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for mkstemp(3).
|
||||||
|
// Returns file descriptor on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
// Note: template_str must be a mutable buffer ending in "XXXXXX".
|
||||||
|
int Mkstemp(char *template_str);
|
||||||
|
|
||||||
|
// Note: rename(2) and unlink(2) are not wrapped because they operate on
|
||||||
|
// filesystem metadata and typically complete atomically without EINTR.
|
||||||
|
// If interrupted, they either succeed or fail without partial state.
|
||||||
|
} // namespace syscall
|
||||||
|
} // namespace kte
|
||||||
+72
@@ -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_{};
|
||||||
|
|||||||
+168
-41
@@ -1,4 +1,6 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <climits>
|
||||||
|
#include <cwchar>
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
|
|
||||||
#include "TerminalInputHandler.h"
|
#include "TerminalInputHandler.h"
|
||||||
@@ -12,6 +14,22 @@ CTRL(char c)
|
|||||||
{
|
{
|
||||||
return c & 0x1F;
|
return c & 0x1F;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Encode a single wide character in the process locale's multibyte encoding
|
||||||
|
// (UTF-8, given main.cc's setlocale(LC_ALL, "")). Returns false if the
|
||||||
|
// codepoint can't be represented, leaving `out` untouched.
|
||||||
|
bool
|
||||||
|
wchar_to_mb(wchar_t wc, std::string &out)
|
||||||
|
{
|
||||||
|
std::mbstate_t state{};
|
||||||
|
char buf[MB_LEN_MAX];
|
||||||
|
std::size_t n = std::wcrtomb(buf, wc, &state);
|
||||||
|
if (n == static_cast<std::size_t>(-1))
|
||||||
|
return false;
|
||||||
|
out.assign(buf, n);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TerminalInputHandler::TerminalInputHandler() = default;
|
TerminalInputHandler::TerminalInputHandler() = default;
|
||||||
@@ -21,6 +39,7 @@ TerminalInputHandler::~TerminalInputHandler() = default;
|
|||||||
|
|
||||||
static bool
|
static bool
|
||||||
map_key_to_command(const int ch,
|
map_key_to_command(const int ch,
|
||||||
|
const bool is_keycode,
|
||||||
bool &k_prefix,
|
bool &k_prefix,
|
||||||
bool &esc_meta,
|
bool &esc_meta,
|
||||||
bool &k_ctrl_pending,
|
bool &k_ctrl_pending,
|
||||||
@@ -28,9 +47,22 @@ map_key_to_command(const int ch,
|
|||||||
Editor *ed,
|
Editor *ed,
|
||||||
MappedInput &out)
|
MappedInput &out)
|
||||||
{
|
{
|
||||||
// Handle special keys from ncurses
|
// Handle special keys from ncurses. These are only meaningful when
|
||||||
|
// get_wch() reported KEY_CODE_YES: a regular (possibly non-ASCII) wide
|
||||||
|
// character can numerically collide with a KEY_* constant otherwise
|
||||||
|
// (e.g. U+0107 'ć' equals KEY_BACKSPACE's value), which would wrongly
|
||||||
|
// swallow it as a special key instead of inserting it.
|
||||||
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
||||||
switch (ch) {
|
//
|
||||||
|
// ESC marks the next key as Meta. Consume that flag on every other key up
|
||||||
|
// front: keys handled before the Meta lookup below (arrows, mouse, Enter,
|
||||||
|
// control chords) used to leave it set, turning a later plain key into a
|
||||||
|
// Meta command (ESC, Left, d => delete word).
|
||||||
|
const bool meta = esc_meta && ch != 27;
|
||||||
|
if (ch != 27)
|
||||||
|
esc_meta = false;
|
||||||
|
|
||||||
|
switch (is_keycode ? ch : -1) {
|
||||||
case KEY_ENTER:
|
case KEY_ENTER:
|
||||||
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
@@ -67,13 +99,20 @@ map_key_to_command(const int ch,
|
|||||||
if (pressed) {
|
if (pressed) {
|
||||||
mouse_selecting = true;
|
mouse_selecting = true;
|
||||||
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||||
if (Buffer *b = ed->CurrentBuffer()) {
|
// We don't set the mark on simple click anymore in ncurses either,
|
||||||
b->SetMark(b->Curx(), b->Cury());
|
// to be consistent. ncurses doesn't easily support double-click
|
||||||
}
|
// or drag-threshold in a platform-independent way here,
|
||||||
|
// but we can at least only set mark on MOVED.
|
||||||
out.hasCommand = false;
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (mouse_selecting && moved) {
|
if (mouse_selecting && moved) {
|
||||||
|
if (Buffer *b = ed->CurrentBuffer()) {
|
||||||
|
if (!b->MarkSet()) {
|
||||||
|
// Set mark at CURRENT cursor position (which is where we were before this move)
|
||||||
|
b->SetMark(b->Curx(), b->Cury());
|
||||||
|
}
|
||||||
|
}
|
||||||
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||||
out.hasCommand = false;
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
@@ -135,9 +174,10 @@ map_key_to_command(const int ch,
|
|||||||
out = {true, CommandId::DeleteChar, "", 0};
|
out = {true, CommandId::DeleteChar, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_RESIZE:
|
case KEY_RESIZE:
|
||||||
k_prefix = false;
|
// TerminalFrontend::Step picks up the new size before the next draw.
|
||||||
k_ctrl_pending = false;
|
// Refresh is the C-g cancel handler, so issuing it here would clear the
|
||||||
out = {true, CommandId::Refresh, "", 0};
|
// mark and cancel open prompts on every window resize.
|
||||||
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -153,6 +193,37 @@ map_key_to_command(const int ch,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meta (ESC-prefixed) keys: backspace, Enter and printable keys use the
|
||||||
|
// ESC keymap. Checked before control chords so ESC ^H (meta-backspace) is
|
||||||
|
// not taken as C-h, and before Enter so ESC Enter reaches SmartNewline.
|
||||||
|
// Other control chords after ESC act as themselves.
|
||||||
|
const bool is_backspace = (is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H');
|
||||||
|
const bool meta_key = meta && (is_backspace || (!is_keycode && (ch == '\n' || ch == '\r' || ch >= 0x20)));
|
||||||
|
if (meta_key) {
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
|
int ascii_key = ch;
|
||||||
|
if (is_backspace) {
|
||||||
|
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
||||||
|
} else if (ch == ',') {
|
||||||
|
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
||||||
|
ascii_key = '<';
|
||||||
|
} else if (ch == '.') {
|
||||||
|
// Likewise, map '.' to '>'
|
||||||
|
ascii_key = '>';
|
||||||
|
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
|
||||||
|
ascii_key = ascii_key - 'A' + 'a';
|
||||||
|
}
|
||||||
|
CommandId id;
|
||||||
|
if (KLookupEscCommand(ascii_key, id)) {
|
||||||
|
out = {true, id, "", 0};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Unhandled ESC sequence: exit escape mode and show status
|
||||||
|
out = {true, CommandId::UnknownEscCommand, "", 0};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Control keys
|
// Control keys
|
||||||
if (ch == CTRL('K')) {
|
if (ch == CTRL('K')) {
|
||||||
// C-k prefix
|
// C-k prefix
|
||||||
@@ -247,34 +318,8 @@ map_key_to_command(const int ch,
|
|||||||
out = {true, CommandId::Newline, "", 0};
|
out = {true, CommandId::Newline, "", 0};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// If previous key was ESC, interpret as meta and use ESC keymap
|
|
||||||
if (esc_meta) {
|
|
||||||
esc_meta = false;
|
|
||||||
int ascii_key = ch;
|
|
||||||
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
|
|
||||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
|
||||||
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
|
||||||
} else if (ch == ',') {
|
|
||||||
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
|
||||||
ascii_key = '<';
|
|
||||||
} else if (ch == '.') {
|
|
||||||
// Likewise, map '.' to '>'
|
|
||||||
ascii_key = '>';
|
|
||||||
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
|
|
||||||
ascii_key = ascii_key - 'A' + 'a';
|
|
||||||
}
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupEscCommand(ascii_key, id)) {
|
|
||||||
out = {true, id, "", 0};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Unhandled ESC sequence: exit escape mode and show status
|
|
||||||
out = {true, CommandId::UnknownEscCommand, "", 0};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
||||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
k_ctrl_pending = false;
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::Backspace, "", 0};
|
out = {true, CommandId::Backspace, "", 0};
|
||||||
@@ -290,11 +335,20 @@ map_key_to_command(const int ch,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Printable ASCII
|
// Printable character: ASCII, or (for a regular decoded wide character,
|
||||||
if (ch >= 0x20 && ch <= 0x7E) {
|
// not a keycode) any other printable Unicode codepoint - e.g. accented
|
||||||
|
// Latin, Cyrillic, CJK, etc.
|
||||||
|
if (!is_keycode && ch >= 0x20 && ch != 0x7F) {
|
||||||
|
std::string mb;
|
||||||
|
if (ch <= 0x7E) {
|
||||||
|
mb.assign(1, static_cast<char>(ch));
|
||||||
|
} else if (!wchar_to_mb(static_cast<wchar_t>(ch), mb)) {
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
out.hasCommand = true;
|
out.hasCommand = true;
|
||||||
out.id = CommandId::InsertText;
|
out.id = CommandId::InsertText;
|
||||||
out.arg.assign(1, static_cast<char>(ch));
|
out.arg = mb;
|
||||||
out.count = 0;
|
out.count = 0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -304,15 +358,88 @@ map_key_to_command(const int ch,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Read one key: an ncurses KEY_* code, or a character decoded from UTF-8
|
||||||
|
// here rather than by get_wch(). With a zero timeout (the frontend reads the
|
||||||
|
// rest of a batch without waiting), get_wch() gave up on a character whose
|
||||||
|
// bytes had not all arrived yet, and ncurses then discarded the remaining
|
||||||
|
// bytes as an invalid sequence: text pasted over a slow link lost
|
||||||
|
// characters, and whatever followed them in the same read.
|
||||||
|
// Returns false when there is no input.
|
||||||
|
static bool
|
||||||
|
read_key(int &ch, bool &is_keycode)
|
||||||
|
{
|
||||||
|
const int c = getch();
|
||||||
|
if (c == ERR)
|
||||||
|
return false;
|
||||||
|
is_keycode = c > 0xFF;
|
||||||
|
ch = c;
|
||||||
|
if (is_keycode || c < 0x80)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Lead byte of a multibyte character: how many continuation bytes follow.
|
||||||
|
int need;
|
||||||
|
char32_t cp;
|
||||||
|
if (c >= 0xC2 && c <= 0xDF) {
|
||||||
|
need = 1;
|
||||||
|
cp = static_cast<char32_t>(c & 0x1F);
|
||||||
|
} else if (c >= 0xE0 && c <= 0xEF) {
|
||||||
|
need = 2;
|
||||||
|
cp = static_cast<char32_t>(c & 0x0F);
|
||||||
|
} else if (c >= 0xF0 && c <= 0xF4) {
|
||||||
|
need = 3;
|
||||||
|
cp = static_cast<char32_t>(c & 0x07);
|
||||||
|
} else {
|
||||||
|
ch = -1; // stray continuation or invalid byte: ignore it
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// The rest of the character is on its way; wait for it (restoring the
|
||||||
|
// caller's timeout), but not long if it never comes: terminals that send
|
||||||
|
// Alt as a raw 8-bit byte (rxvt meta8) produce lone lead bytes, and
|
||||||
|
// every such key would otherwise stall input.
|
||||||
|
const int saved_delay = wgetdelay(stdscr);
|
||||||
|
timeout(250);
|
||||||
|
for (int i = 0; i < need; ++i) {
|
||||||
|
const int b = getch();
|
||||||
|
if (b == ERR || b > 0xFF || (b & 0xC0) != 0x80) {
|
||||||
|
if (b != ERR)
|
||||||
|
ungetch(b); // not part of this character; read it next
|
||||||
|
ch = -1;
|
||||||
|
timeout(saved_delay);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cp = (cp << 6) | static_cast<char32_t>(b & 0x3F);
|
||||||
|
}
|
||||||
|
timeout(saved_delay);
|
||||||
|
// Reject overlong forms and surrogates.
|
||||||
|
if ((need == 2 && cp < 0x800) || (need == 3 && cp < 0x10000) || (cp >= 0xD800 && cp <= 0xDFFF) ||
|
||||||
|
cp > 0x10FFFF) {
|
||||||
|
ch = -1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ch = static_cast<int>(cp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
TerminalInputHandler::decode_(MappedInput &out)
|
TerminalInputHandler::decode_(MappedInput &out)
|
||||||
{
|
{
|
||||||
int ch = getch();
|
int ch = 0;
|
||||||
if (ch == ERR) {
|
bool is_keycode = false;
|
||||||
|
if (!read_key(ch, is_keycode))
|
||||||
return false; // no input
|
return false; // no input
|
||||||
|
if (ch < 0) {
|
||||||
|
// Invalid byte sequence, dropped. It still ends a pending ESC or
|
||||||
|
// C-k prefix, as any other key would; left set, it applied to the
|
||||||
|
// next key instead.
|
||||||
|
esc_meta_ = false;
|
||||||
|
k_prefix_ = false;
|
||||||
|
k_ctrl_pending_ = false;
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
bool consumed = map_key_to_command(
|
bool consumed = map_key_to_command(
|
||||||
ch,
|
ch, is_keycode,
|
||||||
k_prefix_, esc_meta_,
|
k_prefix_, esc_meta_,
|
||||||
k_ctrl_pending_,
|
k_ctrl_pending_,
|
||||||
mouse_selecting_,
|
mouse_selecting_,
|
||||||
|
|||||||
@@ -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};
|
||||||
|
};
|
||||||
+35
@@ -1,6 +1,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "TextSpan.h"
|
||||||
|
|
||||||
|
|
||||||
enum class UndoType : std::uint8_t {
|
enum class UndoType : std::uint8_t {
|
||||||
@@ -9,6 +12,13 @@ enum class UndoType : std::uint8_t {
|
|||||||
Paste,
|
Paste,
|
||||||
Newline,
|
Newline,
|
||||||
DeleteRow,
|
DeleteRow,
|
||||||
|
InsertRow,
|
||||||
|
// Inverse of Newline: forward = join_lines(row) [removes the newline at the
|
||||||
|
// end of `row`], backward = split_line(row, col) [recreates the original
|
||||||
|
// two lines]. Used by backspace-at-col-0 and delete-at-eol, whose forward
|
||||||
|
// action is a join, not a split — Newline's apply() semantics are the
|
||||||
|
// wrong direction for those.
|
||||||
|
JoinLines,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct UndoNode {
|
struct UndoNode {
|
||||||
@@ -17,7 +27,32 @@ struct UndoNode {
|
|||||||
int col{};
|
int col{};
|
||||||
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
||||||
std::string text;
|
std::string text;
|
||||||
|
// A large Insert/Paste/Delete text is held as references to the buffer's
|
||||||
|
// storage instead (text is then empty and span_len is its length): undo
|
||||||
|
// of a replace-all over a 100 MB file otherwise kept 200 MB of copies.
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
std::size_t span_len = 0;
|
||||||
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
||||||
UndoNode *child = nullptr; // next in current timeline
|
UndoNode *child = nullptr; // next in current timeline
|
||||||
UndoNode *next = nullptr; // redo branch
|
UndoNode *next = nullptr; // redo branch
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool HasSpans() const
|
||||||
|
{
|
||||||
|
return !spans.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Length of the node's text, however it is held.
|
||||||
|
[[nodiscard]] std::size_t TextSize() const
|
||||||
|
{
|
||||||
|
return HasSpans() ? span_len : text.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Approximate heap and object bytes held by this node.
|
||||||
|
[[nodiscard]] std::size_t CostBytes() const
|
||||||
|
{
|
||||||
|
return sizeof(UndoNode) + text.capacity() + spans.capacity() * sizeof(TextSpan);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <stack>
|
|
||||||
#include <vector>
|
|
||||||
#include <memory>
|
|
||||||
#include "UndoNode.h"
|
|
||||||
|
|
||||||
// Pool allocator for UndoNode to eliminate frequent malloc/free.
|
|
||||||
// Uses fixed-size blocks to keep node addresses stable.
|
|
||||||
class UndoNodePool {
|
|
||||||
public:
|
|
||||||
explicit UndoNodePool(std::size_t block_size = 64)
|
|
||||||
: block_size_(block_size) {}
|
|
||||||
|
|
||||||
|
|
||||||
UndoNode *acquire()
|
|
||||||
{
|
|
||||||
if (available_.empty())
|
|
||||||
allocate_block();
|
|
||||||
auto *node = available_.top();
|
|
||||||
available_.pop();
|
|
||||||
// Node comes zeroed; ensure links are reset
|
|
||||||
node->text.clear();
|
|
||||||
node->parent = nullptr;
|
|
||||||
node->child = nullptr;
|
|
||||||
node->next = nullptr;
|
|
||||||
node->row = node->col = 0;
|
|
||||||
node->type = UndoType{};
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void release(UndoNode *node)
|
|
||||||
{
|
|
||||||
if (!node)
|
|
||||||
return;
|
|
||||||
// Clear heavy fields to free memory held by strings
|
|
||||||
node->text.clear();
|
|
||||||
node->parent = nullptr;
|
|
||||||
node->child = nullptr;
|
|
||||||
node->next = nullptr;
|
|
||||||
node->row = node->col = 0;
|
|
||||||
node->type = UndoType{};
|
|
||||||
available_.push(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
void allocate_block()
|
|
||||||
{
|
|
||||||
// allocate a new block; keep ownership so memory stays valid
|
|
||||||
std::unique_ptr<UndoNode[]> block(new UndoNode[block_size_]);
|
|
||||||
UndoNode *base = block.get();
|
|
||||||
blocks_.push_back(std::move(block));
|
|
||||||
for (std::size_t i = 0; i < block_size_; ++i) {
|
|
||||||
// ensure the node is reset; rely on default constructor/zero init
|
|
||||||
available_.push(&base[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
std::size_t block_size_;
|
|
||||||
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
|
|
||||||
std::stack<UndoNode *> available_;
|
|
||||||
};
|
|
||||||
+311
-34
@@ -1,7 +1,9 @@
|
|||||||
#include "UndoSystem.h"
|
#include "UndoSystem.h"
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
|
#include <algorithm>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
||||||
@@ -15,6 +17,9 @@ UndoSystem::BeginGroup()
|
|||||||
commit();
|
commit();
|
||||||
if (active_group_id_ == 0)
|
if (active_group_id_ == 0)
|
||||||
active_group_id_ = next_group_id_++;
|
active_group_id_ = next_group_id_++;
|
||||||
|
// Groups nest (e.g. SmartNewline wraps Newline, which groups its own
|
||||||
|
// visual-line splits); only the outermost EndGroup closes the group.
|
||||||
|
++group_depth_;
|
||||||
return active_group_id_;
|
return active_group_id_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +28,12 @@ void
|
|||||||
UndoSystem::EndGroup()
|
UndoSystem::EndGroup()
|
||||||
{
|
{
|
||||||
commit();
|
commit();
|
||||||
|
if (group_depth_ > 0)
|
||||||
|
--group_depth_;
|
||||||
|
if (group_depth_ == 0) {
|
||||||
active_group_id_ = 0;
|
active_group_id_ = 0;
|
||||||
|
maybe_prune();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +46,8 @@ UndoSystem::Begin(UndoType type)
|
|||||||
const int col = static_cast<int>(buf_->Curx());
|
const int col = static_cast<int>(buf_->Curx());
|
||||||
|
|
||||||
// Some operations should always be standalone undo steps.
|
// Some operations should always be standalone undo steps.
|
||||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow);
|
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
||||||
|
UndoType::InsertRow || type == UndoType::JoinLines);
|
||||||
if (always_standalone) {
|
if (always_standalone) {
|
||||||
commit();
|
commit();
|
||||||
}
|
}
|
||||||
@@ -75,6 +86,8 @@ UndoSystem::Begin(UndoType type)
|
|||||||
}
|
}
|
||||||
case UndoType::Newline:
|
case UndoType::Newline:
|
||||||
case UndoType::DeleteRow:
|
case UndoType::DeleteRow:
|
||||||
|
case UndoType::InsertRow:
|
||||||
|
case UndoType::JoinLines:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +153,9 @@ UndoSystem::commit()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try_convert_to_spans(tree_.pending);
|
||||||
|
bytes_ += tree_.pending->CostBytes();
|
||||||
|
|
||||||
if (!tree_.root) {
|
if (!tree_.root) {
|
||||||
tree_.root = tree_.pending;
|
tree_.root = tree_.pending;
|
||||||
tree_.pending->parent = nullptr;
|
tree_.pending->parent = nullptr;
|
||||||
@@ -167,6 +183,8 @@ UndoSystem::commit()
|
|||||||
tree_.pending = nullptr;
|
tree_.pending = nullptr;
|
||||||
pending_mode_ = PendingAppendMode::Append;
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
|
if (group_depth_ == 0)
|
||||||
|
maybe_prune();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +256,24 @@ UndoSystem::mark_saved()
|
|||||||
{
|
{
|
||||||
commit();
|
commit();
|
||||||
tree_.saved = tree_.current;
|
tree_.saved = tree_.current;
|
||||||
|
base_unsaved_ = false;
|
||||||
|
update_dirty_flag();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::AbortGroups()
|
||||||
|
{
|
||||||
|
commit();
|
||||||
|
group_depth_ = 0;
|
||||||
|
active_group_id_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::mark_base_unsaved()
|
||||||
|
{
|
||||||
|
base_unsaved_ = true;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,19 +293,61 @@ void
|
|||||||
UndoSystem::clear()
|
UndoSystem::clear()
|
||||||
{
|
{
|
||||||
discard_pending();
|
discard_pending();
|
||||||
free_node(tree_.root);
|
// The root may have siblings (edits made after undoing to the start).
|
||||||
|
FreeUndoForest(tree_.root);
|
||||||
tree_.root = nullptr;
|
tree_.root = nullptr;
|
||||||
tree_.current = nullptr;
|
tree_.current = nullptr;
|
||||||
tree_.saved = nullptr;
|
tree_.saved = nullptr;
|
||||||
|
base_unsaved_ = false;
|
||||||
|
bytes_ = 0;
|
||||||
active_group_id_ = 0;
|
active_group_id_ = 0;
|
||||||
|
group_depth_ = 0;
|
||||||
next_group_id_ = 1;
|
next_group_id_ = 1;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Position just after the node's text inserted at (node->col, node->row).
|
||||||
|
// The text may span lines: the column is then the length after its last
|
||||||
|
// newline, not col + size (which left the cursor past the end of the line,
|
||||||
|
// so later edits and their undo records went to the wrong place).
|
||||||
|
static void
|
||||||
|
end_of_text(const Buffer &buf, const UndoNode *node, std::size_t &x, std::size_t &y)
|
||||||
|
{
|
||||||
|
y = static_cast<std::size_t>(node->row);
|
||||||
|
x = static_cast<std::size_t>(node->col);
|
||||||
|
auto scan = [&](const char *d, std::size_t n) {
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
if (d[i] == '\n') {
|
||||||
|
++y;
|
||||||
|
x = 0;
|
||||||
|
} else {
|
||||||
|
++x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (node->HasSpans())
|
||||||
|
buf.VisitSpans(node->spans, scan);
|
||||||
|
else
|
||||||
|
scan(node->text.data(), node->text.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Insert the node's text at its position (from spans without copying).
|
||||||
|
static void
|
||||||
|
insert_node_text(Buffer &buf, const UndoNode *node)
|
||||||
|
{
|
||||||
|
if (node->HasSpans())
|
||||||
|
buf.insert_spans(node->row, node->col, node->spans);
|
||||||
|
else
|
||||||
|
buf.insert_text(node->row, node->col, node->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::apply(const UndoNode *node, int direction)
|
UndoSystem::apply(const UndoNode *node, int direction)
|
||||||
{
|
{
|
||||||
|
std::size_t ex = 0, ey = 0;
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
||||||
@@ -278,22 +356,22 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
case UndoType::Insert:
|
case UndoType::Insert:
|
||||||
case UndoType::Paste:
|
case UndoType::Paste:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
insert_node_text(*buf_, node);
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
end_of_text(*buf_, node, ex, ey);
|
||||||
static_cast<std::size_t>(node->row));
|
buf_->SetCursor(ex, ey);
|
||||||
} else {
|
} else {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->TextSize());
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Delete:
|
case UndoType::Delete:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->TextSize());
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
insert_node_text(*buf_, node);
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
end_of_text(*buf_, node, ex, ey);
|
||||||
static_cast<std::size_t>(node->row));
|
buf_->SetCursor(ex, ey);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Newline:
|
case UndoType::Newline:
|
||||||
@@ -314,6 +392,25 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case UndoType::InsertRow:
|
||||||
|
if (direction > 0) {
|
||||||
|
buf_->insert_row(node->row, node->text);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||||
|
} else {
|
||||||
|
buf_->delete_row(node->row);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case UndoType::JoinLines:
|
||||||
|
// Mirror image of Newline: forward removes a newline, backward restores it.
|
||||||
|
if (direction > 0) {
|
||||||
|
buf_->join_lines(node->row);
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
|
} else {
|
||||||
|
buf_->split_line(node->row, node->col);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,20 +418,13 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
void
|
void
|
||||||
UndoSystem::free_node(UndoNode *node)
|
UndoSystem::free_node(UndoNode *node)
|
||||||
{
|
{
|
||||||
|
// Free this node and its child subtree, but not its siblings.
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
// Free child subtree(s) and sibling branches
|
UndoNode *children = node->child;
|
||||||
if (node->child) {
|
|
||||||
// Free entire redo list starting at child, including each subtree
|
|
||||||
UndoNode *branch = node->child;
|
|
||||||
while (branch) {
|
|
||||||
UndoNode *next = branch->next;
|
|
||||||
free_node(branch);
|
|
||||||
branch = next;
|
|
||||||
}
|
|
||||||
node->child = nullptr;
|
node->child = nullptr;
|
||||||
}
|
|
||||||
delete node;
|
delete node;
|
||||||
|
FreeUndoForest(children);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -342,26 +432,27 @@ void
|
|||||||
UndoSystem::free_branch(UndoNode *node)
|
UndoSystem::free_branch(UndoNode *node)
|
||||||
{
|
{
|
||||||
// Free a branch list (node and its next siblings) including their subtrees
|
// Free a branch list (node and its next siblings) including their subtrees
|
||||||
while (node) {
|
FreeUndoForest(node);
|
||||||
UndoNode *next = node->next;
|
|
||||||
free_node(node);
|
|
||||||
node = next;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Iterative search for `target` below `cur`; sets out_parent to its parent.
|
||||||
static bool
|
static bool
|
||||||
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
||||||
{
|
{
|
||||||
if (!cur)
|
if (!cur)
|
||||||
return false;
|
return false;
|
||||||
for (UndoNode *child = cur->child; child != nullptr; child = child->next) {
|
std::vector<UndoNode *> stack{cur};
|
||||||
|
while (!stack.empty()) {
|
||||||
|
UndoNode *node = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
for (UndoNode *child = node->child; child != nullptr; child = child->next) {
|
||||||
if (child == target) {
|
if (child == target) {
|
||||||
out_parent = cur;
|
out_parent = node;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (dfs_find_parent(child, target, out_parent))
|
stack.push_back(child);
|
||||||
return true;
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -380,11 +471,190 @@ UndoSystem::find_parent(UndoNode *from, UndoNode *target)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Texts at least this long are held as storage spans when possible.
|
||||||
|
static constexpr std::size_t kSpanMinBytes = PieceTable::kCaptureDeletedMin;
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::try_convert_to_spans(UndoNode *node)
|
||||||
|
{
|
||||||
|
if (!buf_ || !node || node->text.size() < kSpanMinBytes)
|
||||||
|
return;
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
switch (node->type) {
|
||||||
|
case UndoType::Insert:
|
||||||
|
case UndoType::Paste:
|
||||||
|
// The inserted text is in the buffer at the node's position.
|
||||||
|
spans = buf_->SpansAt(node->row, node->col, node->text.size());
|
||||||
|
break;
|
||||||
|
case UndoType::Delete:
|
||||||
|
// The deleted text is still in storage; the deletion remembered where.
|
||||||
|
spans = buf_->TakeDeletedSpans(node->row, node->col, node->text.size());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only when the spans hold exactly the recorded text; otherwise (the
|
||||||
|
// text is not where the node says) keep the copy.
|
||||||
|
if (spans.empty() || !buf_->SpansEqual(spans, node->text))
|
||||||
|
return;
|
||||||
|
node->span_len = node->text.size();
|
||||||
|
node->spans = std::move(spans);
|
||||||
|
node->spans.shrink_to_fit();
|
||||||
|
std::string().swap(node->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::maybe_prune()
|
||||||
|
{
|
||||||
|
std::size_t budget = budget_;
|
||||||
|
if (budget == 0) {
|
||||||
|
const std::size_t content = buf_ ? buf_->ContentBytes() : 0;
|
||||||
|
budget = std::max<std::size_t>(std::size_t{64} << 20, content * 2);
|
||||||
|
}
|
||||||
|
if (bytes_ <= budget)
|
||||||
|
return;
|
||||||
|
// Prune well below the budget so this does not run on every commit.
|
||||||
|
prune_to(budget - budget / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::forget_saved_state()
|
||||||
|
{
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
base_unsaved_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Total bytes held by a node, its children and its siblings.
|
||||||
|
static std::size_t
|
||||||
|
forest_bytes(const UndoNode *first)
|
||||||
|
{
|
||||||
|
std::size_t total = 0;
|
||||||
|
std::vector<const UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const UndoNode *n = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
total += n->CostBytes();
|
||||||
|
if (n->child)
|
||||||
|
stack.push_back(n->child);
|
||||||
|
if (n->next)
|
||||||
|
stack.push_back(n->next);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
forest_contains(const UndoNode *first, const UndoNode *target)
|
||||||
|
{
|
||||||
|
if (!target)
|
||||||
|
return false;
|
||||||
|
std::vector<const UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const UndoNode *n = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
if (n == target)
|
||||||
|
return true;
|
||||||
|
if (n->child)
|
||||||
|
stack.push_back(n->child);
|
||||||
|
if (n->next)
|
||||||
|
stack.push_back(n->next);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::prune_to(const std::size_t target)
|
||||||
|
{
|
||||||
|
// The line kept: root..current, then current's active redo line.
|
||||||
|
std::vector<UndoNode *> line;
|
||||||
|
for (UndoNode *n = tree_.current; n; n = n->parent)
|
||||||
|
line.push_back(n);
|
||||||
|
std::reverse(line.begin(), line.end());
|
||||||
|
{
|
||||||
|
UndoNode *n = tree_.current ? tree_.current->child : tree_.root;
|
||||||
|
for (; n; n = n->child)
|
||||||
|
line.push_back(n);
|
||||||
|
}
|
||||||
|
if (line.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Detach and free the siblings of `keep` in the list starting at *head.
|
||||||
|
auto drop_siblings = [&](UndoNode **head, UndoNode *keep) {
|
||||||
|
UndoNode *n = *head;
|
||||||
|
*head = keep;
|
||||||
|
while (n) {
|
||||||
|
UndoNode *nx = n->next;
|
||||||
|
if (n != keep) {
|
||||||
|
n->next = nullptr;
|
||||||
|
if (forest_contains(n, tree_.saved))
|
||||||
|
forget_saved_state();
|
||||||
|
FreeUndoForest(n);
|
||||||
|
}
|
||||||
|
n = nx;
|
||||||
|
}
|
||||||
|
keep->next = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Branches off the kept line: reachable only by choosing a redo branch.
|
||||||
|
drop_siblings(&tree_.root, line.front());
|
||||||
|
for (std::size_t i = 0; i + 1 < line.size(); ++i)
|
||||||
|
drop_siblings(&line[i]->child, line[i + 1]);
|
||||||
|
bytes_ = forest_bytes(tree_.root);
|
||||||
|
|
||||||
|
// 2. The oldest edits, while they are older than the current state. An
|
||||||
|
// undo group goes whole: undoing what remained of it would stop at a
|
||||||
|
// state inside the group, which the user never saw.
|
||||||
|
std::size_t i = 0;
|
||||||
|
bool mid_group = false;
|
||||||
|
while ((bytes_ > target || mid_group) && i < line.size() && line[i] != tree_.current && tree_.current) {
|
||||||
|
UndoNode *old = line[i];
|
||||||
|
UndoNode *nx = old->child;
|
||||||
|
if (!nx)
|
||||||
|
break;
|
||||||
|
if (old->group_id != 0 && old->group_id == tree_.current->group_id)
|
||||||
|
break; // the current state's own group stays whole
|
||||||
|
// Dropping `old` loses the state before it. `old`'s own state
|
||||||
|
// becomes the first state in history.
|
||||||
|
if (tree_.saved == nullptr)
|
||||||
|
forget_saved_state();
|
||||||
|
else if (tree_.saved == old)
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
mid_group = old->group_id != 0 && nx->group_id == old->group_id;
|
||||||
|
bytes_ -= old->CostBytes();
|
||||||
|
old->child = nullptr;
|
||||||
|
delete old;
|
||||||
|
nx->parent = nullptr;
|
||||||
|
tree_.root = nx;
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The redo line beyond the current state, newest first.
|
||||||
|
if (bytes_ > target && tree_.current && tree_.current->child) {
|
||||||
|
UndoNode *future = tree_.current->child;
|
||||||
|
if (forest_contains(future, tree_.saved))
|
||||||
|
forget_saved_state();
|
||||||
|
tree_.current->child = nullptr;
|
||||||
|
bytes_ -= forest_bytes(future);
|
||||||
|
FreeUndoForest(future);
|
||||||
|
}
|
||||||
|
update_dirty_flag();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::update_dirty_flag()
|
UndoSystem::update_dirty_flag()
|
||||||
{
|
{
|
||||||
// dirty if current != saved
|
// dirty if current != saved
|
||||||
bool dirty = (tree_.current != tree_.saved);
|
bool dirty = base_unsaved_ || (tree_.current != tree_.saved);
|
||||||
buf_->SetDirty(dirty);
|
buf_->SetDirty(dirty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +681,10 @@ UndoSystem::type_str(UndoType t)
|
|||||||
return "Newline";
|
return "Newline";
|
||||||
case UndoType::DeleteRow:
|
case UndoType::DeleteRow:
|
||||||
return "DeleteRow";
|
return "DeleteRow";
|
||||||
|
case UndoType::InsertRow:
|
||||||
|
return "InsertRow";
|
||||||
|
case UndoType::JoinLines:
|
||||||
|
return "JoinLines";
|
||||||
}
|
}
|
||||||
return "?";
|
return "?";
|
||||||
}
|
}
|
||||||
@@ -421,11 +695,14 @@ UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
|
|||||||
{
|
{
|
||||||
if (!root || !target)
|
if (!root || !target)
|
||||||
return false;
|
return false;
|
||||||
if (root == target)
|
std::vector<UndoNode *> stack{root};
|
||||||
return true;
|
while (!stack.empty()) {
|
||||||
for (UndoNode *child = root->child; child != nullptr; child = child->next) {
|
UndoNode *node = stack.back();
|
||||||
if (is_descendant(child, target))
|
stack.pop_back();
|
||||||
|
if (node == target)
|
||||||
return true;
|
return true;
|
||||||
|
for (UndoNode *child = node->child; child != nullptr; child = child->next)
|
||||||
|
stack.push_back(child);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* UndoSystem.h - undo/redo system with tree-based branching
|
||||||
|
*
|
||||||
|
* UndoSystem manages the undo/redo history for a Buffer. It provides:
|
||||||
|
*
|
||||||
|
* - Tree-based undo: Multiple redo branches at each node (not just linear history)
|
||||||
|
* - Atomic grouping: Multiple operations can be undone/redone as a single step
|
||||||
|
* - Dirty tracking: Marks when buffer matches last saved state
|
||||||
|
* - Efficient storage: Nodes stored in UndoTree, operations applied to Buffer
|
||||||
|
*
|
||||||
|
* Key concepts:
|
||||||
|
*
|
||||||
|
* 1. Undo tree structure:
|
||||||
|
* - Each edit creates a node in the tree
|
||||||
|
* - Undo moves up the tree (toward root)
|
||||||
|
* - Redo moves down the tree (toward leaves)
|
||||||
|
* - Multiple redo branches preserved (not lost on new edits after undo)
|
||||||
|
*
|
||||||
|
* 2. Operation lifecycle:
|
||||||
|
* - Begin(type): Start recording an operation (insert/delete)
|
||||||
|
* - Append(text): Add content to the pending operation
|
||||||
|
* - commit(): Finalize and add to undo tree
|
||||||
|
* - discard_pending(): Cancel without recording
|
||||||
|
*
|
||||||
|
* 3. Atomic grouping:
|
||||||
|
* - BeginGroup()/EndGroup(): Bracket multiple operations
|
||||||
|
* - All operations in a group share the same group_id
|
||||||
|
* - Undo/redo treats the entire group as one step
|
||||||
|
*
|
||||||
|
* 4. Integration with Buffer:
|
||||||
|
* - UndoSystem holds a reference to its owning Buffer
|
||||||
|
* - apply() executes undo/redo by calling Buffer's editing methods
|
||||||
|
* - Buffer's dirty flag updated automatically
|
||||||
|
*
|
||||||
|
* Usage pattern:
|
||||||
|
* undo_system.Begin(UndoType::Insert);
|
||||||
|
* undo_system.Append("text");
|
||||||
|
* undo_system.commit(); // Now undoable
|
||||||
|
*
|
||||||
|
* See also: UndoTree.h (storage), UndoNode.h (node structure)
|
||||||
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@@ -35,12 +76,35 @@ public:
|
|||||||
|
|
||||||
void mark_saved();
|
void mark_saved();
|
||||||
|
|
||||||
|
// No undo state matches the file on disk any more (e.g. after crash
|
||||||
|
// recovery replayed edits onto it): the buffer stays dirty in every state
|
||||||
|
// until the next mark_saved() or clear().
|
||||||
|
void mark_base_unsaved();
|
||||||
|
|
||||||
|
// Close any open group (after an interrupted command) and seal pending.
|
||||||
|
void AbortGroups();
|
||||||
|
|
||||||
void discard_pending();
|
void discard_pending();
|
||||||
|
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
void UpdateBufferReference(Buffer &new_buf);
|
void UpdateBufferReference(Buffer &new_buf);
|
||||||
|
|
||||||
|
// History memory budget in bytes (node texts, spans and nodes). When
|
||||||
|
// committed history exceeds it, branches off the current path and then
|
||||||
|
// the oldest edits are dropped. 0 (the default) means max(64 MiB, twice
|
||||||
|
// the buffer's size).
|
||||||
|
void SetByteBudget(std::size_t bytes)
|
||||||
|
{
|
||||||
|
budget_ = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t HistoryBytes() const
|
||||||
|
{
|
||||||
|
return bytes_;
|
||||||
|
}
|
||||||
|
|
||||||
#if defined(KTE_TESTS)
|
#if defined(KTE_TESTS)
|
||||||
// Test-only introspection hook.
|
// Test-only introspection hook.
|
||||||
const UndoTree &TreeForTests() const
|
const UndoTree &TreeForTests() const
|
||||||
@@ -70,9 +134,26 @@ private:
|
|||||||
|
|
||||||
void update_dirty_flag();
|
void update_dirty_flag();
|
||||||
|
|
||||||
|
// Hold a large committed text as storage spans (see UndoNode::spans).
|
||||||
|
void try_convert_to_spans(UndoNode *node);
|
||||||
|
|
||||||
|
// Enforce the byte budget (outside groups).
|
||||||
|
void maybe_prune();
|
||||||
|
|
||||||
|
void prune_to(std::size_t target);
|
||||||
|
|
||||||
|
// The saved state's node is being dropped: no state in history matches
|
||||||
|
// the file any more.
|
||||||
|
void forget_saved_state();
|
||||||
|
|
||||||
|
std::size_t bytes_ = 0; // approximate bytes held by committed history
|
||||||
|
std::size_t budget_ = 0;
|
||||||
|
|
||||||
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
||||||
|
|
||||||
std::uint64_t active_group_id_ = 0;
|
std::uint64_t active_group_id_ = 0;
|
||||||
|
int group_depth_ = 0; // nesting depth of BeginGroup/EndGroup
|
||||||
|
bool base_unsaved_ = false; // see mark_base_unsaved()
|
||||||
std::uint64_t next_group_id_ = 1;
|
std::uint64_t next_group_id_ = 1;
|
||||||
|
|
||||||
Buffer *buf_;
|
Buffer *buf_;
|
||||||
|
|||||||
+30
-2
@@ -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.
|
||||||
|
|||||||
+46
-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,31 +22,56 @@ let
|
|||||||
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
|
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
|
||||||
in
|
in
|
||||||
stdenv.mkDerivation {
|
stdenv.mkDerivation {
|
||||||
pname = "kte";
|
pname = if graphical then "kge" else "kte";
|
||||||
inherit version;
|
inherit version;
|
||||||
|
|
||||||
src = lib.cleanSource ./.;
|
src = lib.cleanSource ./.;
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
cmake
|
cmake
|
||||||
ncurses
|
pkg-config
|
||||||
installShellFiles
|
installShellFiles
|
||||||
]
|
] ++ lib.optionals graphical [
|
||||||
++ lib.optionals graphical [
|
copyDesktopItems
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = [
|
||||||
|
ncurses
|
||||||
|
pcre2 # regex engine (optional; std::regex without it)
|
||||||
|
] ++ lib.optionals graphical [
|
||||||
SDL2
|
SDL2
|
||||||
libGL
|
libGL
|
||||||
xorg.libX11
|
xorg.libX11
|
||||||
]
|
|
||||||
++ lib.optionals graphical-qt [
|
|
||||||
kdePackages.qt6ct
|
|
||||||
qt6Packages.qtbase
|
|
||||||
qt6Packages.wrapQtAppsHook
|
|
||||||
];
|
];
|
||||||
|
|
||||||
cmakeFlags = [
|
cmakeFlags = [
|
||||||
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
|
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
|
||||||
"-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}"
|
|
||||||
"-DCMAKE_BUILD_TYPE=Debug"
|
"-DCMAKE_BUILD_TYPE=Debug"
|
||||||
|
"-DKTE_STATIC_LINK=OFF"
|
||||||
|
];
|
||||||
|
|
||||||
|
desktopItems = lib.optionals graphical [
|
||||||
|
(makeDesktopItem {
|
||||||
|
name = "kge";
|
||||||
|
desktopName = "kge";
|
||||||
|
genericName = "Text Editor";
|
||||||
|
comment = "kyle's graphical text editor";
|
||||||
|
exec = "kge %F";
|
||||||
|
icon = "kge";
|
||||||
|
terminal = false;
|
||||||
|
categories = [ "Utility" "TextEditor" "Development" ];
|
||||||
|
mimeTypes = [
|
||||||
|
"text/plain"
|
||||||
|
"text/x-c"
|
||||||
|
"text/x-c++"
|
||||||
|
"text/x-python"
|
||||||
|
"text/x-go"
|
||||||
|
"text/x-rust"
|
||||||
|
"application/json"
|
||||||
|
"text/markdown"
|
||||||
|
"text/x-shellscript"
|
||||||
|
];
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
@@ -58,14 +82,7 @@ stdenv.mkDerivation {
|
|||||||
installManPage ../docs/kte.1
|
installManPage ../docs/kte.1
|
||||||
|
|
||||||
${lib.optionalString graphical ''
|
${lib.optionalString graphical ''
|
||||||
mkdir -p $out/bin
|
|
||||||
|
|
||||||
${if graphical-qt then ''
|
|
||||||
cp kge $out/bin/kge-qt
|
|
||||||
'' else ''
|
|
||||||
cp kge $out/bin/kge
|
cp kge $out/bin/kge
|
||||||
''}
|
|
||||||
|
|
||||||
installManPage ../docs/kge.1
|
installManPage ../docs/kge.1
|
||||||
|
|
||||||
mkdir -p $out/share/icons/hicolor/256x256/apps
|
mkdir -p $out/share/icons/hicolor/256x256/apps
|
||||||
@@ -74,4 +91,10 @@ stdenv.mkDerivation {
|
|||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "kyle's text editor" + lib.optionalString graphical " (graphical)";
|
||||||
|
platforms = lib.platforms.unix;
|
||||||
|
mainProgram = if graphical then "kge" else "kte";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Helper script to test Linux builds using Docker/Podman
|
||||||
|
# This script mounts the current source tree into a Linux container,
|
||||||
|
# builds kte in terminal-only mode, and runs the test suite.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Detect whether to use docker or podman
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
CONTAINER_CMD="docker"
|
||||||
|
elif command -v podman &> /dev/null; then
|
||||||
|
CONTAINER_CMD="podman"
|
||||||
|
else
|
||||||
|
echo "Error: Neither docker nor podman found in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE_NAME="kte-linux"
|
||||||
|
|
||||||
|
# Check if image exists, if not, build it
|
||||||
|
if ! $CONTAINER_CMD image inspect "$IMAGE_NAME" &> /dev/null; then
|
||||||
|
echo "Building $IMAGE_NAME image..."
|
||||||
|
$CONTAINER_CMD build -t "$IMAGE_NAME" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the container with the current directory mounted
|
||||||
|
echo "Running Linux build and tests..."
|
||||||
|
$CONTAINER_CMD run --rm -v "$(pwd):/kte" "$IMAGE_NAME"
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# kte Benchmarking and Testing Guide
|
||||||
|
|
||||||
|
This document describes the benchmarking infrastructure and testing
|
||||||
|
improvements added to ensure high performance and correctness of core
|
||||||
|
operations.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The kte test suite now includes comprehensive benchmarks and migration
|
||||||
|
coverage tests to:
|
||||||
|
|
||||||
|
- Measure performance of core operations (PieceTable, Buffer, syntax
|
||||||
|
highlighting)
|
||||||
|
- Ensure no performance regressions from refactorings
|
||||||
|
- Validate correctness of API migrations (Buffer::Rows() →
|
||||||
|
GetLineString/GetLineView)
|
||||||
|
- Provide performance baselines for future optimizations
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### All Tests (including benchmarks)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
|
||||||
|
search, etc.
|
||||||
|
- **15 benchmark tests**: Performance measurements for critical
|
||||||
|
operations
|
||||||
|
- **30 migration coverage tests**: Edge cases and correctness validation
|
||||||
|
|
||||||
|
Total: **98 tests**
|
||||||
|
|
||||||
|
## Benchmark Results
|
||||||
|
|
||||||
|
### Buffer Iteration Patterns (5,000 lines)
|
||||||
|
|
||||||
|
| Pattern | Time | Speedup vs Rows() |
|
||||||
|
|-----------------------------------------|---------|-------------------|
|
||||||
|
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
|
||||||
|
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
|
||||||
|
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
|
||||||
|
|
||||||
|
**Key Insight**: `GetLineView()` provides zero-copy access and is
|
||||||
|
dramatically faster than materializing the entire rows cache.
|
||||||
|
|
||||||
|
### PieceTable Operations (10,000 lines)
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
|-----------------------------|---------|
|
||||||
|
| Sequential inserts (10K) | 2.1 ms |
|
||||||
|
| Random inserts (5K) | 32.9 ms |
|
||||||
|
| `GetLine()` sequential | 4.7 ms |
|
||||||
|
| `GetLineRange()` sequential | 1.3 ms |
|
||||||
|
|
||||||
|
### Buffer Operations
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
|--------------------------------------|---------|
|
||||||
|
| `Nrows()` (1M calls) | 13.0 ms |
|
||||||
|
| `GetLineString()` (10K lines) | 4.8 ms |
|
||||||
|
| `GetLineView()` (10K lines) | 1.6 ms |
|
||||||
|
| `Rows()` materialization (10K lines) | 6.2 ms |
|
||||||
|
|
||||||
|
### Syntax Highlighting
|
||||||
|
|
||||||
|
| Operation | Time | Notes |
|
||||||
|
|------------------------------------|---------|----------------|
|
||||||
|
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
|
||||||
|
| HighlighterEngine cache population | 19.9 ms | |
|
||||||
|
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
|
||||||
|
|
||||||
|
### Large File Performance
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
|---------------------------------|---------|
|
||||||
|
| Insert 50K lines | 0.53 ms |
|
||||||
|
| Iterate 50K lines (GetLineView) | 2.7 ms |
|
||||||
|
| Random access (10K accesses) | 1.8 ms |
|
||||||
|
|
||||||
|
## API Differences: GetLineString vs GetLineView
|
||||||
|
|
||||||
|
Understanding the difference between these APIs is critical:
|
||||||
|
|
||||||
|
### `GetLineString(row)`
|
||||||
|
|
||||||
|
- Returns: `std::string` (copy)
|
||||||
|
- Content: Line text **without** trailing newline
|
||||||
|
- Use case: When you need to modify the string or store it
|
||||||
|
- Example: `"hello"` for line `"hello\n"`
|
||||||
|
|
||||||
|
### `GetLineView(row)`
|
||||||
|
|
||||||
|
- Returns: `std::string_view` (zero-copy)
|
||||||
|
- Content: Raw line range **including** trailing newline
|
||||||
|
- Use case: Read-only access, maximum performance
|
||||||
|
- Example: `"hello\n"` for line `"hello\n"`
|
||||||
|
- **Warning**: View becomes invalid after buffer modifications
|
||||||
|
|
||||||
|
### `Rows()`
|
||||||
|
|
||||||
|
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
|
||||||
|
- Content: Lines **without** trailing newlines
|
||||||
|
- Use case: Legacy code, being phased out
|
||||||
|
- Performance: Slower due to materialization overhead
|
||||||
|
|
||||||
|
## Migration Coverage Tests
|
||||||
|
|
||||||
|
The `test_migration_coverage.cc` file provides 30 tests covering:
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Empty buffers
|
||||||
|
- Single lines (with/without newlines)
|
||||||
|
- Very long lines (10,000 characters)
|
||||||
|
- Many empty lines (1,000 newlines)
|
||||||
|
|
||||||
|
### Consistency
|
||||||
|
|
||||||
|
- `GetLineString()` vs `GetLineView()` vs `Rows()`
|
||||||
|
- Consistency after edits (insert, delete, split, join)
|
||||||
|
|
||||||
|
### Boundary Conditions
|
||||||
|
|
||||||
|
- First line access
|
||||||
|
- Last line access
|
||||||
|
- Line range boundaries
|
||||||
|
|
||||||
|
### Special Characters
|
||||||
|
|
||||||
|
- Tabs, carriage returns, null bytes
|
||||||
|
- Unicode (UTF-8 multibyte characters)
|
||||||
|
|
||||||
|
### Stress Tests
|
||||||
|
|
||||||
|
- Large files (10,000 lines)
|
||||||
|
- Many small operations (100+ inserts)
|
||||||
|
- Alternating insert/delete patterns
|
||||||
|
|
||||||
|
### Regression Tests
|
||||||
|
|
||||||
|
- Shebang detection pattern (Editor.cc)
|
||||||
|
- Empty buffer check pattern (Editor.cc)
|
||||||
|
- Syntax highlighter pattern (all highlighters)
|
||||||
|
- Swap snapshot pattern (Swap.cc)
|
||||||
|
|
||||||
|
## Performance Recommendations
|
||||||
|
|
||||||
|
Based on benchmark results:
|
||||||
|
|
||||||
|
1. **Prefer `GetLineView()` for read-only access**
|
||||||
|
- 11x faster than `Rows()` for iteration
|
||||||
|
- Zero-copy, minimal overhead
|
||||||
|
- Use immediately (view invalidates on edit)
|
||||||
|
|
||||||
|
2. **Use `GetLineString()` when you need a copy**
|
||||||
|
- Still 1.7x faster than `Rows()`
|
||||||
|
- Safe to store and modify
|
||||||
|
- Strips trailing newlines automatically
|
||||||
|
|
||||||
|
3. **Avoid `Rows()` in hot paths**
|
||||||
|
- Materializes entire line cache
|
||||||
|
- Slower for large files
|
||||||
|
- Being phased out (legacy API)
|
||||||
|
|
||||||
|
4. **Cache `Nrows()` in tight loops**
|
||||||
|
- Very fast (13ms for 1M calls)
|
||||||
|
- But still worth caching in inner loops
|
||||||
|
|
||||||
|
5. **Leverage HighlighterEngine caching**
|
||||||
|
- 38x speedup on cache hits
|
||||||
|
- Automatically invalidates on edits
|
||||||
|
- Prefetch viewport for smooth scrolling
|
||||||
|
|
||||||
|
## Adding New Benchmarks
|
||||||
|
|
||||||
|
To add a new benchmark:
|
||||||
|
|
||||||
|
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
|
||||||
|
2. Use `BenchmarkTimer` to measure critical sections:
|
||||||
|
```cpp
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Operation description");
|
||||||
|
// ... code to benchmark ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Print section headers with `std::cout` for clarity
|
||||||
|
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
TEST(Benchmark_MyOperation) {
|
||||||
|
std::cout << "\n=== My Operation Benchmark ===\n";
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_test_data();
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
std::size_t result = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("My operation on 10K lines");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
result += my_operation(buf, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(result > 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Continuous Performance Monitoring
|
||||||
|
|
||||||
|
Run benchmarks regularly to detect regressions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests and save output
|
||||||
|
./cmake-build-debug/kte_tests > benchmark_results.txt
|
||||||
|
|
||||||
|
# Compare with baseline
|
||||||
|
diff benchmark_baseline.txt benchmark_results.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
|
||||||
|
- Significant time increases (>20%) in any benchmark
|
||||||
|
- New operations that are slower than expected
|
||||||
|
- Cache effectiveness degradation
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The benchmark suite provides:
|
||||||
|
|
||||||
|
- **Performance validation**: Ensures migrations don't regress
|
||||||
|
performance
|
||||||
|
- **Optimization guidance**: Identifies fastest APIs for each use case
|
||||||
|
- **Regression detection**: Catches performance issues early
|
||||||
|
- **Documentation**: Demonstrates correct API usage patterns
|
||||||
|
|
||||||
|
All 98 tests pass with 0 failures, confirming both correctness and
|
||||||
|
performance of the migrated codebase.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,678 @@
|
|||||||
|
# Bug and Performance Audit
|
||||||
|
|
||||||
|
**Project:** kte (Kyle's Text Editor)
|
||||||
|
**Date:** 2026-09-23
|
||||||
|
**Auditor:** Claude Code (automated review, four parallel reviewers plus
|
||||||
|
spot verification)
|
||||||
|
**Baseline:** `d8d2ae8` (debug build clean, `kte_tests` 0 failures)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
The tree was split into four review areas: core text model (PieceTable,
|
||||||
|
Buffer, OptimizedSearch), undo and swap (UndoSystem/Tree/Node, Swap,
|
||||||
|
ErrorRecovery, SyscallWrappers), commands (Command.cc, KKeymap), and
|
||||||
|
frontends/syntax (Terminal, ImGui, Qt, Editor, `syntax/`). Every finding
|
||||||
|
below was traced to a concrete scenario. Findings marked **[repro]** were
|
||||||
|
reproduced with a small test program; the rest were confirmed by reading
|
||||||
|
the code.
|
||||||
|
|
||||||
|
Finding IDs are stable and are referenced by fix commits:
|
||||||
|
|
||||||
|
- **M** — memory safety (crash, out-of-bounds, use-after-free, data race)
|
||||||
|
- **D** — data integrity (undo, swap journal, text corruption)
|
||||||
|
- **B** — incorrect behaviour
|
||||||
|
- **P** — performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M — Memory safety
|
||||||
|
|
||||||
|
### M1. PieceTable assignment keeps a stale line index **[repro]**
|
||||||
|
|
||||||
|
`PieceTable.cc:47-98`. Copy and move assignment copy `pieces_`, `add_`,
|
||||||
|
and `total_size_`, but leave the destination's `line_index_` and
|
||||||
|
`line_index_dirty_` untouched. `Editor::CloseBuffer` erases from
|
||||||
|
`std::vector<Buffer>`, which move-assigns every later buffer into the
|
||||||
|
previous slot.
|
||||||
|
|
||||||
|
Scenario: open A (`"line1\nline2\nline3\n"`), open B (`"x"`), render
|
||||||
|
both, close A. Slot 0 now holds B's text with A's index: `LineCount()`
|
||||||
|
returns 4 and `GetLineView(1)` builds a view over bytes 6–12 of a 1-byte
|
||||||
|
buffer.
|
||||||
|
|
||||||
|
Fix: mark the line index dirty (and clear it) in both assignment
|
||||||
|
operators.
|
||||||
|
|
||||||
|
### M2. Background highlighter thread races buffer mutation
|
||||||
|
|
||||||
|
`syntax/HighlighterEngine.cc:184-249`. `PrefetchViewport` (called from
|
||||||
|
the terminal and ImGui renderers) queues rows for a worker thread which
|
||||||
|
calls `buf.GetLineString`, walking `pieces_` and reading `add_`. The main
|
||||||
|
thread's `PieceTable::Insert`/`Delete` mutate both without any shared
|
||||||
|
lock; `add_.append` may reallocate. The worker also stores a raw
|
||||||
|
`Buffer*` (`pending_.buf`) which dangles when `AddBuffer`'s `push_back`
|
||||||
|
reallocates or `CloseBuffer`'s `erase` shifts buffers.
|
||||||
|
|
||||||
|
Fix: remove the background warm-up, or feed it an immutable snapshot.
|
||||||
|
|
||||||
|
### M3. Per-window SwapManager leaves dangling recorders (ImGui multi-window)
|
||||||
|
|
||||||
|
`ImGuiFrontend.cc:381-385`, `Editor.cc:205-213`. Each window's `Editor`
|
||||||
|
owns its own `SwapManager` but shares the buffer vector. A buffer created
|
||||||
|
from a secondary window keeps a `swap_rec_` pointing into that window's
|
||||||
|
manager; closing the window destroys it, and the next edit in the main
|
||||||
|
window calls into freed memory. `Rehome` from the wrong manager also
|
||||||
|
returns `nullptr`, silently disabling journaling for the primary's
|
||||||
|
buffers.
|
||||||
|
|
||||||
|
Fix: share one `SwapManager` across windows, and detach every buffer an
|
||||||
|
`Editor` attached when that `Editor` is destroyed.
|
||||||
|
|
||||||
|
### M4. Recursive undo-tree free overflows the stack **[repro]**
|
||||||
|
|
||||||
|
`UndoTree.cc:12` (`free_node_graph`), also `UndoSystem.cc:354`
|
||||||
|
(`is_descendant`, `dfs_find_parent`). Recursion depth equals history
|
||||||
|
length. About 300k nodes segfaults a debug build on an 8 MB stack;
|
||||||
|
replace-all over ~150k matches (two nodes each) followed by close or
|
||||||
|
reload is enough.
|
||||||
|
|
||||||
|
Fix: free iteratively with an explicit stack.
|
||||||
|
|
||||||
|
### M5. Jump-to-mark places the cursor past the end of the buffer
|
||||||
|
|
||||||
|
`Command.cc:3720`. Marks are not adjusted on edits and
|
||||||
|
`cmd_jump_to_mark` does not clamp. After deleting lines below a mark,
|
||||||
|
C-k j puts the cursor on a nonexistent row; `cmd_move_left` and
|
||||||
|
`cmd_move_up` (`:3866`, `:3988`) then index `rows[y]` out of bounds.
|
||||||
|
|
||||||
|
Fix: clamp in `cmd_jump_to_mark` and in `compute_mark_region`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D — Data integrity
|
||||||
|
|
||||||
|
### D1. Smart-newline undo deletes the wrong text **[repro]**
|
||||||
|
|
||||||
|
`Command.cc:3139-3146`. The indent is inserted and the cursor moved past
|
||||||
|
it before `u->Begin(UndoType::Insert)`; `UndoSystem::Begin` records the
|
||||||
|
cursor position, so the node is placed `indent.size()` columns too far
|
||||||
|
right. `" foobar"` + SmartNewline + two undos yields
|
||||||
|
`" foo next"` (the following line's text is spliced in and `bar`
|
||||||
|
is lost).
|
||||||
|
|
||||||
|
Fix: `Begin` at `new_x`, then insert, then move the cursor; group the
|
||||||
|
newline and the indent.
|
||||||
|
|
||||||
|
### D2. Cursor column left past end-of-line corrupts undo
|
||||||
|
|
||||||
|
`cmd_scroll_up`/`cmd_scroll_down` (`Command.cc:4178`, `:4215`, bound to
|
||||||
|
the mouse wheel), replace-all's cursor restore (`:2580`), and
|
||||||
|
`cmd_jump_to_mark` call `SetCursor` without clamping x. `delete_text`
|
||||||
|
does not clamp; it treats the excess as crossing the newline.
|
||||||
|
Backspace then joins lines with an empty (dropped) undo node, and
|
||||||
|
typing followed by undo deletes a newline instead of the typed
|
||||||
|
character.
|
||||||
|
|
||||||
|
Fix: clamp x to the line length in `Buffer::SetCursor`.
|
||||||
|
|
||||||
|
### D3. `delete_row` on a final line without trailing newline leaves a stray `\n`
|
||||||
|
|
||||||
|
`Buffer.cc:803`. Affects `delete_region` (C-w, M-d, M-Backspace), regex
|
||||||
|
replace (`Command.cc:2996`), reflow (`:4884`), and the kill ring.
|
||||||
|
`"foo\nbar"` + M-d at end of `foo` yields `"foo\n"`.
|
||||||
|
|
||||||
|
### D4. Phantom empty row after a trailing newline
|
||||||
|
|
||||||
|
`PieceTable::LineCount()` counts the empty row after a final `\n`.
|
||||||
|
C-k C-d on that row records a `DeleteRow("")` whose undo inserts a
|
||||||
|
line (`Command.cc:3516`); regex replace of `^` matches it and appends
|
||||||
|
an extra line (`:2985`).
|
||||||
|
|
||||||
|
### D5. Missing or wrong undo grouping
|
||||||
|
|
||||||
|
- Plain replace-all (`Command.cc:2537-2577`) has no group: N
|
||||||
|
replacements need 2N undos, and the final Insert stays pending so the
|
||||||
|
next typed characters coalesce into it.
|
||||||
|
- Visual-line newline (`:3043-3075`) records no undo at all.
|
||||||
|
|
||||||
|
### D6. Reflow on a blank line merges the adjacent paragraphs
|
||||||
|
|
||||||
|
`Command.cc:4592`. `"aaa\n\nbbb"` with the cursor on the blank line and
|
||||||
|
ESC q gives `"aaa bbb"`.
|
||||||
|
|
||||||
|
### D7. Swap journal is fragile exactly where it matters
|
||||||
|
|
||||||
|
- `Swap.cc:1344-1373`: a torn final record (the power-loss case) makes
|
||||||
|
the whole file unreadable instead of replaying the valid prefix.
|
||||||
|
- `Swap.cc:1134-1157`, `:1187`, `:1224`: after any dropped record
|
||||||
|
(breaker open, >16 MB payload, partial write) later records still
|
||||||
|
replay against a different base state; a partial write leaves bytes
|
||||||
|
that later records are appended behind.
|
||||||
|
- `Swap.cc:1040` vs `:1187`: checkpoints allow 4 GB but the record
|
||||||
|
length is 24 bits; buffers over 16 MB are never checkpointed and the
|
||||||
|
journal grows without bound.
|
||||||
|
- `Swap.cc:644`, `:708`: a failed header write leaves an fd with no
|
||||||
|
header; answering N to "delete corrupt swap?" appends new edits to the
|
||||||
|
corrupt file.
|
||||||
|
|
||||||
|
### D8. Circuit breaker failure window never expires
|
||||||
|
|
||||||
|
`ErrorRecovery.cc:77`. `last_failure_time_` is updated before
|
||||||
|
`IsWindowExpired()` reads it, so elapsed time is always zero; five
|
||||||
|
failures hours apart still open the breaker.
|
||||||
|
|
||||||
|
### D9. Stale syntax highlighting after undo-to-clean or reload
|
||||||
|
|
||||||
|
`Buffer.h:391-400`. `version_` is bumped only in `SetDirty(true)`. Undo
|
||||||
|
back to the saved node calls `SetDirty(false)`, and `OpenFromFile`
|
||||||
|
(reload) never bumps it, so `HighlighterEngine` keeps serving spans for
|
||||||
|
the old text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B — Behaviour
|
||||||
|
|
||||||
|
### B1. `set_escdelay` is compiled out
|
||||||
|
|
||||||
|
`TerminalFrontend.cc:46`. `set_escdelay` is a function in ncurses, not a
|
||||||
|
macro, so `#ifdef set_escdelay` is always false and ESC waits the
|
||||||
|
1000 ms default.
|
||||||
|
|
||||||
|
### B2. Terminal resize runs C-g
|
||||||
|
|
||||||
|
`TerminalInputHandler.cc:167-171` maps `KEY_RESIZE` to
|
||||||
|
`CommandId::Refresh`, the cancel handler: it clears the mark, cancels
|
||||||
|
prompts (including the swap-recovery prompt, so the file is never
|
||||||
|
opened), and resets the universal argument.
|
||||||
|
|
||||||
|
### B3. ESC-as-Meta flag is not cleared on most paths
|
||||||
|
|
||||||
|
`TerminalInputHandler.cc:56-279`. ESC followed by an arrow, Enter, Tab,
|
||||||
|
or a control chord leaves `esc_meta` set; the next plain `d` runs M-d.
|
||||||
|
Same in `ImGuiInputHandler.cc:43-73`.
|
||||||
|
|
||||||
|
### B4. C-h shadows Backspace in the terminal
|
||||||
|
|
||||||
|
The generic control lookup (`TerminalInputHandler.cc:264`) maps ^H to
|
||||||
|
SearchReplace before the Backspace branch (`:307`) runs.
|
||||||
|
|
||||||
|
### B5. Byte-based editing splits UTF-8
|
||||||
|
|
||||||
|
`Command.cc:3259`, `:3352`, `:3861-3947`. Backspace, delete, and
|
||||||
|
horizontal motion step by byte: Backspace on `é` leaves a lone `0xC3`,
|
||||||
|
and C-b/C-f land inside code points.
|
||||||
|
|
||||||
|
### B6. Horizontal scroll counts bytes, renderer counts cells
|
||||||
|
|
||||||
|
`compute_render_x` (`Command.cc:52-60`) counts bytes while
|
||||||
|
`TerminalRenderer.cc:269-322` skips display cells: a long CJK line
|
||||||
|
scrolls to blank with the cursor off-screen.
|
||||||
|
|
||||||
|
### B7. GUI draws non-ASCII text as replacement glyphs
|
||||||
|
|
||||||
|
Highlighters emit one span per non-ASCII byte and `ImGuiRenderer.cc`
|
||||||
|
(~510) / `QtFrontend.cc` (~333) decode each span separately.
|
||||||
|
|
||||||
|
### B8. ImGui drops the character after Ctrl+V
|
||||||
|
|
||||||
|
`ImGuiInputHandler.cc:362`, `:382`, `:401` set a suppress-next-text flag
|
||||||
|
for chords that produce no text event.
|
||||||
|
|
||||||
|
### B9. Control characters mis-measured in the terminal
|
||||||
|
|
||||||
|
`TerminalRenderer.cc:314`, `:364`, `:413` count C0 controls as width 1;
|
||||||
|
ncurses draws them as `^X`.
|
||||||
|
|
||||||
|
### B10. Qt tab width disagrees with the command layer
|
||||||
|
|
||||||
|
`QtFrontend.cc:138` uses 4; `Command.cc` uses 8.
|
||||||
|
|
||||||
|
### B11. Smart newline edits the old buffer while a prompt is open
|
||||||
|
|
||||||
|
`Command.cc:3131`. With a prompt active, `cmd_newline` accepts it and
|
||||||
|
the saved indent is inserted through a possibly stale `Buffer*`.
|
||||||
|
|
||||||
|
### B12. Line count stale after merged appends **[repro]**
|
||||||
|
|
||||||
|
`PieceTable.cc:220-260`. `addPieceBack`/`addPieceFront` merge paths
|
||||||
|
skip `InvalidateLineIndex()`. Latent today.
|
||||||
|
|
||||||
|
### B13. Syntax highlighter edge cases
|
||||||
|
|
||||||
|
Go raw strings and Rust multi-line strings do not carry state across
|
||||||
|
lines; shell `$#` is highlighted as a comment; a C++ `#define` line
|
||||||
|
opening `/*` does not carry the comment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P — Performance
|
||||||
|
|
||||||
|
### P1. Whole-file `Rows()` rebuild per frame
|
||||||
|
|
||||||
|
Renderers call `buf->Rows()` every frame (`TerminalRenderer.cc:45`,
|
||||||
|
`ImGuiRenderer.cc:69`, `QtFrontend.cc:477`), and
|
||||||
|
`ensure_cursor_visible` (`Command.cc:91`) after every command. After an
|
||||||
|
edit this rebuilds a `std::string` per line, each via two `GetRange`
|
||||||
|
calls.
|
||||||
|
|
||||||
|
### P2. Whole-buffer materialization for one line
|
||||||
|
|
||||||
|
`Buffer::GetLineView` (`Buffer.cc:633`) calls `content_.Data()`, which
|
||||||
|
copies the entire file; the line index is rebuilt byte-by-byte after
|
||||||
|
every edit.
|
||||||
|
|
||||||
|
### P3. Re-highlight from row 0 on every keystroke
|
||||||
|
|
||||||
|
`SetDirty(true)` calls `InvalidateFrom(0)`; stateful highlighters then
|
||||||
|
re-lex from the top of the file to the viewport.
|
||||||
|
|
||||||
|
### P4. Quadratic region commands
|
||||||
|
|
||||||
|
Replace-all, `delete_region`, indent/unindent, reflow, and repeated
|
||||||
|
backspace re-fetch `Rows()` after each mutation inside their loops.
|
||||||
|
|
||||||
|
### P5. Opening a file checkpoints into a journal that is then deleted
|
||||||
|
|
||||||
|
`Swap.cc:493` snapshots, writes, and fsyncs the whole file into
|
||||||
|
`unnamed-N.swp` before removing it.
|
||||||
|
|
||||||
|
### P6. Boyer–Moore bad-character shift degenerates to 1
|
||||||
|
|
||||||
|
`OptimizedSearch.cc:63`, `:96`: when the mismatched byte is absent from
|
||||||
|
the pattern the shift is 1 instead of `j`. Test-only today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended order
|
||||||
|
|
||||||
|
1. M1 (one-line fix)
|
||||||
|
2. M2 (remove or snapshot the warm-up thread)
|
||||||
|
3. D2 + M5 (clamp in `SetCursor` and jump-to-mark)
|
||||||
|
4. D1 (smart-newline undo order)
|
||||||
|
5. D7 (accept a torn final swap record)
|
||||||
|
6. P1/P2 (`Nrows()` + `GetLineString` in hot paths)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix status
|
||||||
|
|
||||||
|
Updated as fixes land on `claude/funny-fermat-gj2vm5`.
|
||||||
|
|
||||||
|
| ID | Status |
|
||||||
|
|----|--------|
|
||||||
|
| M1 | fixed: assignment operators invalidate the line index |
|
||||||
|
| M2 | fixed: background warm-up thread removed; visible rows highlighted synchronously |
|
||||||
|
| M3 | fixed: editors sharing buffers share the owner's SwapManager |
|
||||||
|
| M4 | fixed: iterative free/search; `clear()` also leaked root siblings, now frees all |
|
||||||
|
| M5 | fixed: jump-to-mark clamps; move up/down clamp the row (move-down also underflowed) |
|
||||||
|
| D1 | fixed: `Begin` before the cursor moves; newline + indent grouped |
|
||||||
|
| D2 | fixed for scroll up/down and replace-all restore |
|
||||||
|
| D3 | fixed: kill-region, regex replace, reflow and kill-line edit byte ranges in place |
|
||||||
|
| D4 | fixed: regex replace skips the row after a final newline (a zero-width pattern used to hang the editor); kill-line there is a no-op |
|
||||||
|
| D5 | fixed: replace-all one undo group; visual-line newline recorded as one group |
|
||||||
|
| D6 | fixed: reflow on a blank line is a no-op |
|
||||||
|
| D7 | fixed: torn tail replays the valid prefix; lost records mark a gap resynced by checkpoint; partial writes truncated; header failure closes; large inserts split; oversize checkpoints skipped; declined corrupt swap moved to `.corrupt` |
|
||||||
|
| D8 | fixed: window checked before recording the failure |
|
||||||
|
| D9 | fixed: every text mutation bumps the version (including undo to the saved state); reload and replace also clear undo/highlighting |
|
||||||
|
| B1 | fixed: guard on `NCURSES_VERSION` |
|
||||||
|
| B2 | fixed: `KEY_RESIZE` issues no command |
|
||||||
|
| B3 | fixed: ESC-meta flag consumed on every key; ESC ^H and ESC Enter reach their bindings |
|
||||||
|
| B4 | not changed: C-h is a deliberate binding (search & replace); terminals whose terminfo `kbs` is ^H are translated to KEY_BACKSPACE by ncurses |
|
||||||
|
| B5 | fixed: backspace/delete/left/right step over whole UTF-8 characters; up/down snap to a boundary |
|
||||||
|
| B6 | fixed: horizontal scroll and mouse column use display cells (mbrtowc/wcwidth) |
|
||||||
|
| B7 | fixed: spans are widened to whole UTF-8 characters before the GUIs draw them (`SanitizeSpans`) |
|
||||||
|
| B8 | fixed: a text-event suppression lasts only until the next key press |
|
||||||
|
| B9 | fixed: C0 controls and DEL drawn and measured as two-cell caret notation (`TermWidth.h`) |
|
||||||
|
| B10 | fixed: one tab width (`kte::kTabWidth`); the Qt frontend has since been removed |
|
||||||
|
| B13 | open (highlighter edge cases) |
|
||||||
|
| B11 | fixed: SmartNewline with a prompt open delegates to Newline |
|
||||||
|
| B12 | fixed: merged appends invalidate the line index |
|
||||||
|
| P1 | fixed: `ensure_cursor_visible` and the terminal/ImGui renderers no longer call `Rows()` |
|
||||||
|
| P2 | fixed: line index updated in place on insert/delete (14.7 ms -> 0.28 ms per keystroke on a 38 MB buffer) |
|
||||||
|
| P3 | fixed: highlighting invalidated from the edited row (46 ms -> 0.14 ms per keystroke near the end of a 100k-line C++ file) |
|
||||||
|
| P4 | fixed: replace-all, delete-region, indent/unindent and visual-line edits each apply as one edit |
|
||||||
|
| P5 | fixed: no throwaway checkpoint on filename change |
|
||||||
|
| P6 | moot: `OptimizedSearch` was test-only and has been removed |
|
||||||
|
|
||||||
|
Regression tests: `tests/test_audit_regressions.cc`. Each test fails on the
|
||||||
|
unfixed code (M4 by crashing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-up review rounds
|
||||||
|
|
||||||
|
After the first batch of fixes, further review rounds (an adversarial
|
||||||
|
review of each batch of changes, plus fresh reviews of areas the first
|
||||||
|
audit covered lightly) found further defects. All of the following are
|
||||||
|
fixed on this branch, each with a regression test that fails on the code
|
||||||
|
before the fix (unless noted).
|
||||||
|
|
||||||
|
**Crash / hang**
|
||||||
|
- Opening a directory (bad_alloc exit), a FIFO (open blocked forever), or
|
||||||
|
a device node (later replaced by a regular file on save).
|
||||||
|
- Filesystem exceptions (ENAMETOOLONG, EACCES, deleted cwd) and any other
|
||||||
|
exception escaping a command terminated the editor.
|
||||||
|
- Regex search/replace, and renderer match highlighting, overflowed the
|
||||||
|
stack on long lines (std::regex recursion); a zero-width regex replace
|
||||||
|
hung the editor (D4).
|
||||||
|
- Piped (non-tty) stdin was executed as commands, then spun at full CPU.
|
||||||
|
|
||||||
|
**Data loss / corruption**
|
||||||
|
- Recovery deleted the swap it was about to replay when another named
|
||||||
|
buffer was current (pre-existing).
|
||||||
|
- Recovered sessions appended behind a torn final record.
|
||||||
|
- Reload left the old journal (discarded edits came back on recovery);
|
||||||
|
reload discarded unsaved edits without confirmation and "reloaded" a
|
||||||
|
deleted file as empty.
|
||||||
|
- Two buffers (duplicate open, save-as onto an open file) or two
|
||||||
|
sessions shared one journal; closing/saving one deleted the other's
|
||||||
|
live journal. Journals are now flock()ed; a session never removes a
|
||||||
|
journal it does not own; unnamed journals include the pid; distinct
|
||||||
|
paths no longer map to one swap name.
|
||||||
|
- Journals now record the base file's size, mtime and CRC-32 and are
|
||||||
|
not replayed onto a file that changed afterwards.
|
||||||
|
- Quit checked only the current buffer; Enter at "Recover?" / "Save
|
||||||
|
changes?" took the destructive branch; the close prompt's save
|
||||||
|
overwrote on-disk changes; save-as overwrote other files silently.
|
||||||
|
- Kill-line with a count, stale marks in kill-region, commands running
|
||||||
|
behind an open prompt (read-only bypass, wrong buffer closed), paste
|
||||||
|
into a prompt, nested undo groups, UTF-8 splitting in word motions.
|
||||||
|
- Save: fsync retried after failure (could report success for lost
|
||||||
|
data); new files got 0600; symlinks replaced; hard links split;
|
||||||
|
owner and setuid/setgid bits lost; umask race with the writer thread.
|
||||||
|
- Writer-thread fsync raced with closes (fd reuse; kept a closed
|
||||||
|
journal's lock alive); crc32 table init race; Close() retried EINTR.
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- Motion/delete commands rebuilt every line after an edit (80-90 ms per
|
||||||
|
keystroke on 500k lines; C-u 3000 C-d took 273 s, now 0.25 s).
|
||||||
|
- Line index invalidated by consolidation; incremental index added.
|
||||||
|
- Idle redraw every 16 ms rescanned long lines (one core busy); paste
|
||||||
|
handled one key per frame; RowsView retained unbounded copies.
|
||||||
|
|
||||||
|
**Fourth round (stress testing)**
|
||||||
|
- Undo/redo of multi-line text left the cursor past the end of the
|
||||||
|
line; later edits and their undo records went astray and
|
||||||
|
delete-word-prev read out of bounds (ASan).
|
||||||
|
- A recovered buffer looked clean after one edit and its undo; closing
|
||||||
|
it discarded the recovered text and journal. Save-and-quit did not
|
||||||
|
record the saved state; stale visual-line selections pointed past the
|
||||||
|
end of the buffer; open undo groups survived an exception.
|
||||||
|
- A reload that ran out of memory emptied the buffer, and the next save
|
||||||
|
truncated the file. Exceptions from deferred opens ended the editor.
|
||||||
|
A leftover hard-link copy blocked every later save; a file in a
|
||||||
|
read-only directory could not be saved.
|
||||||
|
- The journal writer's periodic fsync held the lock every keystroke
|
||||||
|
needs (typing froze on slow disks); every open and save re-read the
|
||||||
|
whole file under that lock; two ThreadSanitizer races.
|
||||||
|
- Replace-all, yank and repeat counts issued one edit per match, line
|
||||||
|
or repetition (quadratic: minutes for 229k matches, now 0.14 s).
|
||||||
|
- With LANG unset, multibyte input was dropped. Renderer caches and the
|
||||||
|
reload confirmation were keyed on buffer addresses, which are reused
|
||||||
|
after a close; a reload confirmation survived other commands; a swap
|
||||||
|
recovery prompt from a deferred open was not drawn until a key.
|
||||||
|
|
||||||
|
**Fifth round (review of the fourth round's changes)**
|
||||||
|
- The save fallbacks added in round four (write in place when no temp
|
||||||
|
file or hard-link copy can be made) also fired on ENOSPC/EDQUOT/EIO,
|
||||||
|
truncating the file and then failing: the file on disk was left
|
||||||
|
corrupt. They now apply only when the directory refuses a new name
|
||||||
|
(EACCES, EPERM, ENAMETOOLONG); otherwise the save fails and the file
|
||||||
|
is untouched.
|
||||||
|
- Whole-buffer replace recorded the entire old and new text for undo on
|
||||||
|
every run (about 150 MB more per replace-all on a 50 MB file); only
|
||||||
|
the span between the common prefix and suffix is now recorded.
|
||||||
|
- Regex search silently never found matches on lines over 20,000 bytes;
|
||||||
|
the skip now applies only to search-as-you-type (at 2,000 bytes, where
|
||||||
|
the worst case is well under a second), says so in the status line,
|
||||||
|
and moving to the next/previous match searches every line.
|
||||||
|
- After a lost journal record, the recovery checkpoint was requested
|
||||||
|
only by later edits, at most once a second; if the first attempt
|
||||||
|
failed and the user then stopped typing, the journal stayed behind
|
||||||
|
indefinitely. The editor loop now retries it each second while idle
|
||||||
|
(this was also why SwapReplay_LostRecord_ResyncsWithCheckpoint was
|
||||||
|
flaky under load).
|
||||||
|
- Undoing C-k C-k (and redoing an inserted row) journaled the row and
|
||||||
|
its newline as two records; a checkpoint triggered by the first
|
||||||
|
already contained the newline, so recovery silently produced an
|
||||||
|
extra blank line.
|
||||||
|
- Visual-line yank with the cursor moved out of the selection (C-k a,
|
||||||
|
Enter) left it past the end of a line or the buffer, and text typed
|
||||||
|
there could not be undone. Every command now leaves the cursor
|
||||||
|
inside the buffer (checked in the command dispatcher).
|
||||||
|
- ENABLE_ASAN instrumented only C sources, i.e. none of the project;
|
||||||
|
it now applies to C++ as well. (Earlier "ASan clean" results in this
|
||||||
|
audit came from that uninstrumented build; the full suite has since
|
||||||
|
passed under a correctly instrumented ASan build with leak checks,
|
||||||
|
and the stress fuzzers ran with ASan and UBSan.)
|
||||||
|
|
||||||
|
**Sixth round (real terminal, file I/O, recovery end to end)**
|
||||||
|
- UTF-8 typed or pasted over a slow link lost characters: with the
|
||||||
|
input batch read at zero timeout, ncurses gave up on a character split
|
||||||
|
across reads and discarded the rest as invalid. kte now assembles
|
||||||
|
UTF-8 itself and waits for continuation bytes. (This regression came
|
||||||
|
from this audit's own input batching.)
|
||||||
|
- Visual-line (multi-line) insert, delete, backspace and newline used
|
||||||
|
the cursor's byte column on every selected line, splitting multibyte
|
||||||
|
characters on the others; the column is now carried in characters.
|
||||||
|
- Journal failures reached only the error log: a session locked out of
|
||||||
|
the journal by another kte (which only happens when the other opened
|
||||||
|
the file before this one's first edit), or whose journal writes
|
||||||
|
failed, silently had no or partial crash recovery. The status line now
|
||||||
|
says so (once per failure).
|
||||||
|
- Basenames over about 233 bytes produced a swap name over NAME_MAX, so
|
||||||
|
such files were never journaled. (The first fix capped the name at
|
||||||
|
exactly 255 bytes, which broke compaction's ".tmp" sibling and let
|
||||||
|
the journal grow without bound; it now leaves room for it.)
|
||||||
|
|
||||||
|
**Seventh round (highlighters, region and paragraph commands, buffers)**
|
||||||
|
- Indent-region on a selection ending in the empty row after a final
|
||||||
|
newline added a tab-only last line (empty lines are now skipped).
|
||||||
|
- Reflow emitted a prefix-only line before a first word longer than the
|
||||||
|
width (a blank line before a long URL, a bullet split from its text),
|
||||||
|
adding another on every reflow; joined paragraphs separated by a
|
||||||
|
whitespace-only line; and moved the CRs of CRLF lines into the middle
|
||||||
|
of lines.
|
||||||
|
- A counted backspace/delete that joined lines took several undos.
|
||||||
|
- The C++ highlighter scanned to the end of the line for a raw-string
|
||||||
|
'(' at every R", quadratic on long lines.
|
||||||
|
- Fuzzing every highlighter (incremental vs full), 20,000 random command
|
||||||
|
sequences, and 6,300 random buffer-list sessions found nothing else.
|
||||||
|
|
||||||
|
**Eighth round (reflow review; large-file performance)**
|
||||||
|
- Reflow's CRLF handling applied only when every line had a CR, so the
|
||||||
|
last paragraph of a CRLF file without a final newline still got CRs
|
||||||
|
mid-line; wrapping could start a line with "-" or "1.", which the next
|
||||||
|
reflow read as a list.
|
||||||
|
- Large files (26-105 MB): the stateful highlighter cached spans for
|
||||||
|
every row above the viewport (854 MB for a 78 MB C++ file after
|
||||||
|
jumping to the end; now 197 MB); search built and kept a string per
|
||||||
|
line on every keystroke after an edit (now one scan of the
|
||||||
|
materialized text: 42 -> 28 ms per keystroke on 26 MB); indent,
|
||||||
|
unindent and visual-line insert/delete/backspace/newline/yank made one
|
||||||
|
piece-table edit per row (1.1 s per keystroke with 5,000 rows
|
||||||
|
selected; now 4 ms, as one edit).
|
||||||
|
|
||||||
|
**Ninth round (review of the eighth round's changes)**
|
||||||
|
- No serious findings. Highlighting (cached vs fresh), search (against
|
||||||
|
the old per-line code, 3,200 random buffers) and the row-range edits
|
||||||
|
(differential against the previous commit, ~88k random steps: text
|
||||||
|
identical after every step; undo-all and journal replay exact) all
|
||||||
|
agreed. Redo-all after heavy branching can end on an older branch of
|
||||||
|
the undo tree (a state that did exist), as before.
|
||||||
|
|
||||||
|
**Follow-up: undo memory**
|
||||||
|
- Undo texts of 4 KiB or more are held as references to the piece
|
||||||
|
table's (append-only) storage instead of copies, verified byte for
|
||||||
|
byte when converted; undo/redo re-splice the stored bytes instead of
|
||||||
|
copying them into storage again. A replace-all session on a 26 MB file
|
||||||
|
ends at 181 MB instead of 332 MB; undo/redo cycles of a 6.6 MB kill
|
||||||
|
stay at 75 MB instead of growing by 6.6 MB each.
|
||||||
|
- History has a byte budget (default max(64 MiB, twice the buffer)):
|
||||||
|
over it, branches off the current path, then the oldest edits (whole
|
||||||
|
undo groups), then the redo line are dropped. If the saved state goes,
|
||||||
|
the buffer stays modified until the next save.
|
||||||
|
- replace_all_bytes clears undo history (it was recorded against, and
|
||||||
|
may reference, the previous content).
|
||||||
|
|
||||||
|
**Follow-up: search**
|
||||||
|
- Search finds the next match from the search origin (then from the
|
||||||
|
current match) and stops there; it no longer collects every match in
|
||||||
|
the buffer on every keystroke and next/previous. The i/N count stops
|
||||||
|
at 1,000 matches (and, for regex, after 4 MiB). On 26 MB: plain
|
||||||
|
keystroke 30 ms -> 0.1 ms, next 24 -> 3 ms, regex keystroke 434 ->
|
||||||
|
1.6 ms. Previous-match scans backwards in 1 MiB forward-searched
|
||||||
|
blocks (string_view::rfind: 511 ms -> 6 ms on 105 MB).
|
||||||
|
- Semantics change: the first match is the first one at or after the
|
||||||
|
cursor (it was the first in the buffer), and Left/Right also step
|
||||||
|
through matches in the regex replace prompt; Up/Down in a regex prompt
|
||||||
|
use the regex.
|
||||||
|
|
||||||
|
**Follow-up: journals for large buffers**
|
||||||
|
- Buffers over 16 MiB are checkpointed as CHKPT_BEGIN (size, CRC-32),
|
||||||
|
data chunks and CHKPT_END; replay applies the checkpoint only when it
|
||||||
|
is complete and its CRC matches, and treats an unfinished one as a
|
||||||
|
torn tail. Such buffers could not be checkpointed at all: a lost
|
||||||
|
record left the journal permanently behind and it was never
|
||||||
|
compacted. The journal header is now version 2 (version 1 journals
|
||||||
|
are still read; older kte builds will not read version 2).
|
||||||
|
- Checkpoint thresholds scale with the buffer: after edits amounting to
|
||||||
|
half its size, on the timer only after a sixteenth, compaction at
|
||||||
|
twice its size, gap retries spaced by size. A checkpoint that would
|
||||||
|
compact is written straight into the compacted journal.
|
||||||
|
- The recovery prompt keeps the content it replayed for the preview;
|
||||||
|
answering y installs it instead of replaying the journal again.
|
||||||
|
- The CRC of a base file over 64 MiB is computed by the journal writer
|
||||||
|
thread (it was skipped), so a touched but unchanged large file no
|
||||||
|
longer makes its journal unusable for recovery.
|
||||||
|
|
||||||
|
**Follow-up: regex engine**
|
||||||
|
- kte::Regex (RegexEngine.h) wraps PCRE2 with its JIT when libpcre2-8
|
||||||
|
is found (KTE_USE_PCRE2, default ON), std::regex otherwise. Search,
|
||||||
|
regex replace-all and the terminal and ImGui renderers use it (the Qt
|
||||||
|
frontend's highlighting still calls std::regex). PCRE2 has a match
|
||||||
|
limit, so catastrophic backtracking ("^(a+)+$") stops with a status
|
||||||
|
note instead of hanging, and does not recurse on the C stack, so its
|
||||||
|
per-keystroke line limit is 1 MiB instead of 2,000 bytes. Replacement
|
||||||
|
strings keep std::regex_replace semantics with either engine.
|
||||||
|
- On 26 MB: regex keystroke 1.6 -> 0.14 ms, next 62 -> 6 ms,
|
||||||
|
replace-all 929 -> 438 ms; a rarely matching regex over 105 MB:
|
||||||
|
1.55 s -> 0.10 s per keystroke.
|
||||||
|
- Only one checkpoint per journal is queued at a time; whole-file edits
|
||||||
|
in a row on a large buffer each queued a full copy of it.
|
||||||
|
- Review of this work found, and these are fixed: with PCRE2, '.'
|
||||||
|
matched the CR of CRLF lines (regex replace-all deleted it) — PCRE2
|
||||||
|
now uses the ANYCRLF newline convention and $ matches only at the end;
|
||||||
|
$0, out-of-range $n and $nn in replacements now follow libstdc++;
|
||||||
|
plain search visits overlapping matches in both directions (the count
|
||||||
|
could read "2/1"); the recovery prompt replays again if the journal
|
||||||
|
changed while it was open; a version 1 journal continued after
|
||||||
|
recovery gets a chunked checkpoint only by being rewritten as
|
||||||
|
version 2.
|
||||||
|
|
||||||
|
**Known limitations (not fixed)**
|
||||||
|
- Without PCRE2 (std::regex builds), catastrophic regex backtracking
|
||||||
|
can still take very long; std::regex has no limit.
|
||||||
|
- Without PCRE2 on libc++ (macOS), '^' also matches where a search
|
||||||
|
resumes inside a line (libc++ ignores match_prev_avail, and
|
||||||
|
match_not_bol with it, for '^'), and '\b' never matches at the end of
|
||||||
|
a line. Search_NextPrevVisitReferenceMatches fails in that
|
||||||
|
configuration; the default macOS build uses PCRE2.
|
||||||
|
- Typing in a multi-megabyte single line costs O(line length) per
|
||||||
|
keystroke (cursor column computed by scanning the line).
|
||||||
|
- Highlighter edge cases B13 are unchanged. (B7, B8 and B10 were fixed
|
||||||
|
in the debt pass below, once the GUIs could be built and run.)
|
||||||
|
- Journals are created at the first edit, so a second kte that opens
|
||||||
|
the file before the first one edits it is not warned at open; the
|
||||||
|
session that is locked out is told at its first edit instead.
|
||||||
|
- A regex that matches rarely or not at all scans the whole buffer
|
||||||
|
(0.1 s on 105 MB with PCRE2, 1.6 s with std::regex).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debt and performance pass (2026-09-24)
|
||||||
|
|
||||||
|
After the merge of the fixes above. Both GUI frontends (ImGui and Qt) were
|
||||||
|
built in this environment and checked by screenshot under Xvfb; the ImGui
|
||||||
|
renderer changes are pixel-identical to before on a sample with tabs,
|
||||||
|
UTF-8, syntax colouring and search highlights.
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- Typing near the top of a large file: each edit shifted every later line
|
||||||
|
start. The shift is now pending and applied on read; piece lookup
|
||||||
|
binary-searches prefix sums instead of walking the piece list. Typing on
|
||||||
|
line 10 of a 1M-line file, with a frame's worth of line reads per
|
||||||
|
keystroke: 0.52 -> 0.017 ms.
|
||||||
|
- A loaded file is adopted as the piece table's original storage: the
|
||||||
|
first keystroke no longer copies the whole file (140 ms at 200 MB), and
|
||||||
|
the read buffer is not copied again. Save writes the pieces instead of
|
||||||
|
materializing a second full copy that then stayed resident. An unedited
|
||||||
|
(single-piece) buffer is viewed in place; `GetLineView` no longer copies
|
||||||
|
the whole buffer after an edit when the line lies within one piece.
|
||||||
|
- Line reads decide "ends with a newline" from the line index instead of
|
||||||
|
fetching the last byte; the index is rebuilt with `memchr`.
|
||||||
|
- ImGui measured every span and the cursor from column 0 of the line
|
||||||
|
(quadratic on long lines); the terminal renderer rescanned spans per
|
||||||
|
byte; Qt compiled a `std::regex` per visible line per frame. All now
|
||||||
|
linear, with search-match ranges computed by one shared
|
||||||
|
`SearchHighlight`.
|
||||||
|
- The highlighter engine returns cached spans by reference and no longer
|
||||||
|
`dynamic_cast`s per call.
|
||||||
|
|
||||||
|
**Bugs found on the way**
|
||||||
|
- Qt: operators were drawn in the dark background colour (invisible);
|
||||||
|
tabs were 4 columns against the command layer's 8; text after a
|
||||||
|
multi-byte character was placed one column per byte; the editor's
|
||||||
|
column count was computed three different ways from painting and the
|
||||||
|
cell width was rounded, so on long lines the cursor drifted off the
|
||||||
|
text. Unhighlighted text between spans is now drawn.
|
||||||
|
- B7, B8 (above).
|
||||||
|
|
||||||
|
**Debt removed**
|
||||||
|
- `Buffer::Rows()` and the `Line` wrapper (tests use `GetLineString` or a
|
||||||
|
test-only `LinesForTests()`); `OptimizedSearch`, `UndoNodePool.h`, the
|
||||||
|
unused swap SPLIT/JOIN writers and several unused Editor/Buffer/PieceTable
|
||||||
|
members; mutexes that guarded only reads in single-threaded classes
|
||||||
|
(the claim that PieceTable reads were thread-safe was false).
|
||||||
|
- Consolidated: filetype detection and highlighter installation
|
||||||
|
(`Buffer::ApplyDetectedFiletype`, `InstallFiletypeHighlighter`), whole
|
||||||
|
buffer copies (`Buffer::Bytes`), character measurement
|
||||||
|
(`kte::MeasureChar`, `DisplayColumns`), span sanitizing
|
||||||
|
(`kte::SanitizeSpans`), word motion scans, the search reset, and the
|
||||||
|
regex engine's stack policy (`kte::Regex::RunGuarded`).
|
||||||
|
|
||||||
|
**Left as is**
|
||||||
|
- The C-k / ESC-meta / universal-argument key state machine is still
|
||||||
|
written once per frontend; unifying it is a behavioural change best done
|
||||||
|
with its own tests.
|
||||||
|
- The highlighter keeps one line state per row (about 40 bytes each) and
|
||||||
|
highlighters copy each line; both are modest next to the file itself.
|
||||||
|
- A long run of backspaces grows its undo record by prepending (quadratic
|
||||||
|
only in the length of one typed run).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-up: macOS build and Qt removal (2026-09-24)
|
||||||
|
|
||||||
|
The gate had only run on Linux (as root). On macOS it failed three
|
||||||
|
tests and the app bundle could not start.
|
||||||
|
|
||||||
|
- kge stopped at launch with "Failed loading SDL3 library": Homebrew's
|
||||||
|
sdl2 is sdl2-compat, which dlopen()s SDL3, and the bundle fixup copies
|
||||||
|
only linked libraries. SDL3 is now bundled when sdl2-compat is.
|
||||||
|
- Saving lost setuid/setgid when not running as root: writing the data
|
||||||
|
clears them, and the mode was applied before the write. Ownership and
|
||||||
|
mode now go on after the data; in-place writes restore the mode.
|
||||||
|
- Two tests used libc++'s std::regex as the reference, which misses
|
||||||
|
'\b' at the end of the subject and ignores match_prev_avail for '^'.
|
||||||
|
- After C-k, pressing Shift (for C-k C, C-k ^ or a shifted symbol) or
|
||||||
|
pressing Ctrl again (C-k C-d) ended the prefix as an unknown command
|
||||||
|
in kge; modifier keys alone are now ignored there (checked with
|
||||||
|
synthetic SDL key events against kge's objects).
|
||||||
|
- The Qt frontend was removed: unused, out of make-app-release since
|
||||||
|
March, not built by the gate, and its bundle could not start (no
|
||||||
|
platform plugin). Every renderer and input change had to be made in
|
||||||
|
it separately.
|
||||||
|
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
# Error Propagation Standardization Report
|
||||||
|
|
||||||
|
**Project:** kte (Kyle's Text Editor)
|
||||||
|
**Date:** 2026-02-17
|
||||||
|
**Auditor:** Error Propagation Standardization Review
|
||||||
|
**Language:** C++20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This report documents the standardization of error propagation patterns
|
||||||
|
across the kte codebase. Following the implementation of centralized
|
||||||
|
error handling (ErrorHandler), this audit identifies inconsistencies in
|
||||||
|
error propagation and provides concrete remediation recommendations.
|
||||||
|
|
||||||
|
**Key Findings:**
|
||||||
|
|
||||||
|
- **Dominant Pattern**: `bool + std::string &err` is used consistently
|
||||||
|
in Buffer and SwapManager for I/O operations
|
||||||
|
- **Inconsistencies**: PieceTable has no error reporting mechanism; some
|
||||||
|
internal helpers lack error propagation
|
||||||
|
- **Standard Chosen**: `bool + std::string &err` pattern (C++20 project,
|
||||||
|
std::expected not available)
|
||||||
|
- **Documentation**: Comprehensive error handling conventions added to
|
||||||
|
DEVELOPER_GUIDE.md
|
||||||
|
|
||||||
|
**Overall Assessment**: The codebase has a **solid foundation** with the
|
||||||
|
`bool + err` pattern used consistently in critical I/O paths. Primary
|
||||||
|
gaps are in PieceTable memory allocation error handling and some
|
||||||
|
internal helper functions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CURRENT STATE ANALYSIS
|
||||||
|
|
||||||
|
### 1.1 Error Propagation Patterns Found
|
||||||
|
|
||||||
|
#### Pattern 1: `bool + std::string &err` (Dominant)
|
||||||
|
|
||||||
|
**Usage**: File I/O, swap operations, resource allocation
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- `Buffer::OpenFromFile(const std::string &path, std::string &err)` (
|
||||||
|
Buffer.h:72)
|
||||||
|
- `Buffer::Save(std::string &err)` (Buffer.h:74)
|
||||||
|
- `Buffer::SaveAs(const std::string &path, std::string &err)` (Buffer.h:
|
||||||
|
75)
|
||||||
|
- `Editor::OpenFile(const std::string &path, std::string &err)` (
|
||||||
|
Editor.h:536)
|
||||||
|
-
|
||||||
|
`SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)` (
|
||||||
|
Swap.h:104)
|
||||||
|
-
|
||||||
|
`SwapManager::open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` (
|
||||||
|
Swap.h:208)
|
||||||
|
-
|
||||||
|
`SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err)` (
|
||||||
|
Swap.h:212-213)
|
||||||
|
|
||||||
|
**Assessment**: ✅ **Excellent** - Consistent, well-implemented,
|
||||||
|
integrated with ErrorHandler
|
||||||
|
|
||||||
|
#### Pattern 2: `void` (State Changes)
|
||||||
|
|
||||||
|
**Usage**: Setters, cursor movement, flag toggles, internal state
|
||||||
|
modifications
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- `Buffer::SetCursor(std::size_t x, std::size_t y)` (Buffer.h:348)
|
||||||
|
- `Buffer::SetDirty(bool d)` (Buffer.h:368)
|
||||||
|
- `Buffer::SetMark(std::size_t x, std::size_t y)` (Buffer.h:387)
|
||||||
|
- `Buffer::insert_text(int row, int col, std::string_view text)` (
|
||||||
|
Buffer.h:545)
|
||||||
|
- `Buffer::delete_text(int row, int col, std::size_t len)` (Buffer.h:
|
||||||
|
547)
|
||||||
|
- `Editor::SetStatus(const std::string &msg)` (Editor.h:various)
|
||||||
|
|
||||||
|
**Assessment**: ✅ **Appropriate** - These operations are infallible
|
||||||
|
state changes
|
||||||
|
|
||||||
|
#### Pattern 3: `bool` without error parameter (Control Flow)
|
||||||
|
|
||||||
|
**Usage**: Validation checks, control flow decisions
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- `Editor::ProcessPendingOpens()` (Editor.h:544)
|
||||||
|
- `Editor::ResolveRecoveryPrompt(bool yes)` (Editor.h:558)
|
||||||
|
- `Editor::SwitchTo(std::size_t index)` (Editor.h:563)
|
||||||
|
- `Editor::CloseBuffer(std::size_t index)` (Editor.h:565)
|
||||||
|
|
||||||
|
**Assessment**: ✅ **Appropriate** - Success/failure is sufficient for
|
||||||
|
control flow
|
||||||
|
|
||||||
|
#### Pattern 4: No Error Reporting (PieceTable)
|
||||||
|
|
||||||
|
**Usage**: Memory allocation, text manipulation
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- `void PieceTable::Reserve(std::size_t newCapacity)` (PieceTable.h:71)
|
||||||
|
- `void PieceTable::Append(const char *s, std::size_t len)` (
|
||||||
|
PieceTable.h:75)
|
||||||
|
-
|
||||||
|
`void PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)` (
|
||||||
|
PieceTable.h:118)
|
||||||
|
- `char *PieceTable::Data()` (PieceTable.h:89-93) - returns nullptr on
|
||||||
|
allocation failure
|
||||||
|
|
||||||
|
**Assessment**: ⚠️ **Gap** - Memory allocation failures are not reported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. STANDARDIZATION DECISION
|
||||||
|
|
||||||
|
### 2.1 Chosen Pattern: `bool + std::string &err`
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
|
||||||
|
1. **C++20 Project**: `std::expected` (C++23) is not available
|
||||||
|
2. **Existing Adoption**: Already used consistently in Buffer,
|
||||||
|
SwapManager, Editor for I/O operations
|
||||||
|
3. **Clear Semantics**: `bool` return indicates success/failure, `err`
|
||||||
|
provides details
|
||||||
|
4. **ErrorHandler Integration**: Works seamlessly with centralized error
|
||||||
|
logging
|
||||||
|
5. **Zero Overhead**: No exceptions, no dynamic allocation for error
|
||||||
|
paths
|
||||||
|
6. **Testability**: Easy to verify error messages in unit tests
|
||||||
|
|
||||||
|
**Alternative Considered**: `std::expected<T, std::string>` (C++23)
|
||||||
|
|
||||||
|
- **Rejected**: Requires C++23, would require major refactoring, not
|
||||||
|
available in current toolchain
|
||||||
|
|
||||||
|
### 2.2 Pattern Selection Guidelines
|
||||||
|
|
||||||
|
| Operation Type | Pattern | Example |
|
||||||
|
|---------------------|---------------------------|-----------------------------------------------------------------------------------|
|
||||||
|
| File I/O | `bool + std::string &err` | `Buffer::Save(std::string &err)` |
|
||||||
|
| Syscalls | `bool + std::string &err` | `open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` |
|
||||||
|
| Resource Allocation | `bool + std::string &err` | Future: `PieceTable::Reserve(std::size_t cap, std::string &err)` |
|
||||||
|
| Parsing/Validation | `bool + std::string &err` | `SwapManager::ReplayFile(Buffer &buf, const std::string &path, std::string &err)` |
|
||||||
|
| State Changes | `void` | `Buffer::SetCursor(std::size_t x, std::size_t y)` |
|
||||||
|
| Control Flow | `bool` (no err) | `Editor::SwitchTo(std::size_t index)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. INCONSISTENCIES AND GAPS
|
||||||
|
|
||||||
|
### 3.1 PieceTable Memory Allocation (Severity: 6/10)
|
||||||
|
|
||||||
|
**Finding**: PieceTable methods that allocate memory (`Reserve`,
|
||||||
|
`Append`, `Insert`, `Data`) do not report allocation failures.
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
|
||||||
|
- Memory allocation failures are silent
|
||||||
|
- `Data()` returns `nullptr` on failure, but callers may not check
|
||||||
|
- Large file operations could fail without user notification
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// PieceTable.h:71
|
||||||
|
void Reserve(std::size_t newCapacity); // No error reporting
|
||||||
|
|
||||||
|
// PieceTable.h:89-93
|
||||||
|
char *Data(); // Returns nullptr on allocation failure
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remediation Priority**: **Medium** - Memory allocation failures are
|
||||||
|
rare on modern systems, but should be handled for robustness
|
||||||
|
|
||||||
|
**Recommended Fix**:
|
||||||
|
|
||||||
|
**Option 1: Add error parameter to fallible operations** (Preferred)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// PieceTable.h
|
||||||
|
bool Reserve(std::size_t newCapacity, std::string &err);
|
||||||
|
bool Append(const char *s, std::size_t len, std::string &err);
|
||||||
|
bool Insert(std::size_t byte_offset, const char *text, std::size_t len, std::string &err);
|
||||||
|
|
||||||
|
// Returns nullptr on failure; check with HasMaterializationError()
|
||||||
|
char *Data();
|
||||||
|
bool HasMaterializationError() const;
|
||||||
|
std::string GetMaterializationError() const;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Use exceptions for allocation failures** (Not recommended)
|
||||||
|
|
||||||
|
PieceTable could throw `std::bad_alloc` on allocation failures, but this
|
||||||
|
conflicts with the project's error handling philosophy and would require
|
||||||
|
exception handling throughout the codebase.
|
||||||
|
|
||||||
|
**Option 3: Status quo with improved documentation** (Minimal change)
|
||||||
|
|
||||||
|
Document that `Data()` can return `nullptr` and callers must check. Add
|
||||||
|
assertions in debug builds.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// PieceTable.h
|
||||||
|
// Returns pointer to materialized buffer, or nullptr if materialization fails.
|
||||||
|
// Callers MUST check for nullptr before dereferencing.
|
||||||
|
char *Data();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**: **Option 3** for now (document + assertions), *
|
||||||
|
*Option 1** if memory allocation errors become a concern in production.
|
||||||
|
|
||||||
|
### 3.2 Internal Helper Functions (Severity: 4/10)
|
||||||
|
|
||||||
|
**Finding**: Some internal helper functions in Swap.cc and Buffer.cc use
|
||||||
|
`bool` returns without error parameters.
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Swap.cc:562
|
||||||
|
static bool ensure_parent_dir(const std::string &path); // No error details
|
||||||
|
|
||||||
|
// Swap.cc:579
|
||||||
|
static bool write_header(int fd); // No error details
|
||||||
|
|
||||||
|
// Buffer.cc:101
|
||||||
|
static bool write_all_fd(int fd, const char *data, std::size_t len, std::string &err); // ✅ Good
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: Limited - These are internal helpers called by functions
|
||||||
|
that do report errors
|
||||||
|
|
||||||
|
**Remediation Priority**: **Low** - Callers already provide error
|
||||||
|
context
|
||||||
|
|
||||||
|
**Recommended Fix**: Add error parameters to internal helpers for
|
||||||
|
consistency
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Swap.cc
|
||||||
|
static bool ensure_parent_dir(const std::string &path, std::string &err);
|
||||||
|
static bool write_header(int fd, std::string &err);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: **Deferred** - Low priority, callers already provide
|
||||||
|
adequate error context
|
||||||
|
|
||||||
|
### 3.3 Editor Control Flow Methods (Severity: 2/10)
|
||||||
|
|
||||||
|
**Finding**: Editor methods like `SwitchTo()`, `CloseBuffer()` return
|
||||||
|
`bool` without error details.
|
||||||
|
|
||||||
|
**Assessment**: ✅ **Appropriate** - These are control flow decisions
|
||||||
|
where success/failure is sufficient
|
||||||
|
|
||||||
|
**Remediation**: **None needed** - Current pattern is correct for this
|
||||||
|
use case
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ERRORHANDLER INTEGRATION STATUS
|
||||||
|
|
||||||
|
### 4.1 Components with ErrorHandler Integration
|
||||||
|
|
||||||
|
✅ **Buffer** (Buffer.cc)
|
||||||
|
|
||||||
|
- `OpenFromFile()` - Reports file open, seek, read errors
|
||||||
|
- `Save()` - Reports write errors
|
||||||
|
- `SaveAs()` - Reports write errors
|
||||||
|
|
||||||
|
✅ **SwapManager** (Swap.cc)
|
||||||
|
|
||||||
|
- `report_error()` - All swap file errors reported
|
||||||
|
- Background thread errors captured and logged
|
||||||
|
- Errno captured for all syscalls
|
||||||
|
|
||||||
|
✅ **main** (main.cc)
|
||||||
|
|
||||||
|
- Top-level exception handler reports Critical errors
|
||||||
|
- Both `std::exception` and unknown exceptions captured
|
||||||
|
|
||||||
|
### 4.2 Components Without ErrorHandler Integration
|
||||||
|
|
||||||
|
⚠️ **PieceTable** (PieceTable.cc)
|
||||||
|
|
||||||
|
- No error reporting mechanism
|
||||||
|
- Memory allocation failures are silent
|
||||||
|
|
||||||
|
⚠️ **Editor** (Editor.cc)
|
||||||
|
|
||||||
|
- File operations delegate to Buffer (✅ covered)
|
||||||
|
- Control flow methods don't need error reporting (✅ appropriate)
|
||||||
|
|
||||||
|
⚠️ **Command** (Command.cc)
|
||||||
|
|
||||||
|
- Commands use `Editor::SetStatus()` for user-facing messages
|
||||||
|
- No ErrorHandler integration for command failures
|
||||||
|
- **Assessment**: Commands are user-initiated actions; status messages
|
||||||
|
are appropriate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DOCUMENTATION STATUS
|
||||||
|
|
||||||
|
### 5.1 Error Handling Conventions (DEVELOPER_GUIDE.md)
|
||||||
|
|
||||||
|
✅ **Added comprehensive section** covering:
|
||||||
|
|
||||||
|
- Three standard error propagation patterns
|
||||||
|
- Pattern selection guidelines with decision tree
|
||||||
|
- ErrorHandler integration requirements
|
||||||
|
- Code examples for file I/O, syscalls, background threads, top-level
|
||||||
|
handlers
|
||||||
|
- Anti-patterns and best practices
|
||||||
|
- Error log location and format
|
||||||
|
- Migration guide for updating existing code
|
||||||
|
|
||||||
|
**Location**: `docs/DEVELOPER_GUIDE.md` section 7
|
||||||
|
|
||||||
|
### 5.2 API Documentation
|
||||||
|
|
||||||
|
⚠️ **Gap**: Individual function documentation in headers could be
|
||||||
|
improved
|
||||||
|
|
||||||
|
**Recommendation**: Add brief comments to public APIs documenting error
|
||||||
|
behavior
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Buffer.h
|
||||||
|
// Opens a file and loads its content into the buffer.
|
||||||
|
// Returns false on failure; err contains detailed error message.
|
||||||
|
// Errors are logged to ErrorHandler.
|
||||||
|
bool OpenFromFile(const std::string &path, std::string &err);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. REMEDIATION RECOMMENDATIONS
|
||||||
|
|
||||||
|
### 6.1 High Priority (Severity 7-10)
|
||||||
|
|
||||||
|
**None identified** - Critical error handling gaps were addressed in
|
||||||
|
previous sessions:
|
||||||
|
|
||||||
|
- ✅ Top-level exception handler added (Severity 9/10)
|
||||||
|
- ✅ Background thread error reporting added (Severity 9/10)
|
||||||
|
- ✅ File I/O error checking added (Severity 8/10)
|
||||||
|
- ✅ Errno capture added to swap operations (Severity 7/10)
|
||||||
|
- ✅ Centralized error handling implemented (Severity 7/10)
|
||||||
|
|
||||||
|
### 6.2 Medium Priority (Severity 4-6)
|
||||||
|
|
||||||
|
#### 6.2.1 PieceTable Memory Allocation Error Handling (Severity: 6/10)
|
||||||
|
|
||||||
|
**Action**: Document that `Data()` can return `nullptr` and add debug
|
||||||
|
assertions
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// PieceTable.h
|
||||||
|
// Returns pointer to materialized buffer, or nullptr if materialization fails
|
||||||
|
// due to memory allocation error. Callers MUST check for nullptr.
|
||||||
|
char *Data();
|
||||||
|
|
||||||
|
// PieceTable.cc
|
||||||
|
char *PieceTable::Data() {
|
||||||
|
materialize();
|
||||||
|
assert(materialized_ != nullptr && "PieceTable materialization failed");
|
||||||
|
return materialized_;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: Low (documentation + assertions)
|
||||||
|
**Risk**: Low (no API changes)
|
||||||
|
**Timeline**: Next maintenance cycle
|
||||||
|
|
||||||
|
#### 6.2.2 Add Error Parameters to Internal Helpers (Severity: 4/10)
|
||||||
|
|
||||||
|
**Action**: Add `std::string &err` parameters to `ensure_parent_dir()`
|
||||||
|
and `write_header()`
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Swap.cc
|
||||||
|
static bool ensure_parent_dir(const std::string &path, std::string &err) {
|
||||||
|
try {
|
||||||
|
fs::path p(path);
|
||||||
|
fs::path dir = p.parent_path();
|
||||||
|
if (dir.empty())
|
||||||
|
return true;
|
||||||
|
if (!fs::exists(dir))
|
||||||
|
fs::create_directories(dir);
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
err = std::string("Failed to create directory: ") + e.what();
|
||||||
|
return false;
|
||||||
|
} catch (...) {
|
||||||
|
err = "Failed to create directory: unknown error";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: Low (update 2 functions + call sites)
|
||||||
|
**Risk**: Low (internal helpers only)
|
||||||
|
**Timeline**: Next maintenance cycle
|
||||||
|
|
||||||
|
### 6.3 Low Priority (Severity 1-3)
|
||||||
|
|
||||||
|
#### 6.3.1 Add Function-Level Error Documentation (Severity: 3/10)
|
||||||
|
|
||||||
|
**Action**: Add brief comments to public APIs documenting error behavior
|
||||||
|
|
||||||
|
**Effort**: Medium (many functions to document)
|
||||||
|
**Risk**: None (documentation only)
|
||||||
|
**Timeline**: Ongoing as code is touched
|
||||||
|
|
||||||
|
#### 6.3.2 Add ErrorHandler Integration to Commands (Severity: 2/10)
|
||||||
|
|
||||||
|
**Action**: Consider logging command failures to ErrorHandler for
|
||||||
|
diagnostics
|
||||||
|
|
||||||
|
**Assessment**: **Not recommended** - Commands are user-initiated
|
||||||
|
actions; status messages are more appropriate than error logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. TESTING RECOMMENDATIONS
|
||||||
|
|
||||||
|
### 7.1 Error Handling Test Coverage
|
||||||
|
|
||||||
|
**Current State**:
|
||||||
|
|
||||||
|
- ✅ Swap file error handling tested (test_swap_edge_cases.cc)
|
||||||
|
- ✅ Buffer I/O error handling tested (test_buffer_io.cc)
|
||||||
|
- ⚠️ PieceTable allocation failure testing missing
|
||||||
|
|
||||||
|
**Recommendations**:
|
||||||
|
|
||||||
|
1. **Add PieceTable allocation failure tests** (if Option 1 from 3.1 is
|
||||||
|
implemented)
|
||||||
|
2. **Add ErrorHandler query tests** - Verify error logging and retrieval
|
||||||
|
3. **Add errno capture tests** - Verify errno is captured correctly in
|
||||||
|
syscall failures
|
||||||
|
|
||||||
|
### 7.2 Test Examples
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// test_error_handler.cc
|
||||||
|
TEST(ErrorHandler, LogsErrorsWithContext) {
|
||||||
|
ErrorHandler::Instance().Error("TestComponent", "Test error", "test.txt");
|
||||||
|
EXPECT_TRUE(ErrorHandler::Instance().HasErrors());
|
||||||
|
EXPECT_EQ(ErrorHandler::Instance().GetErrorCount(), 1);
|
||||||
|
std::string last = ErrorHandler::Instance().GetLastError();
|
||||||
|
EXPECT_TRUE(last.find("Test error") != std::string::npos);
|
||||||
|
EXPECT_TRUE(last.find("test.txt") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// test_piece_table.cc (if Option 1 implemented)
|
||||||
|
TEST(PieceTable, ReportsAllocationFailure) {
|
||||||
|
PieceTable pt;
|
||||||
|
std::string err;
|
||||||
|
// Attempt to allocate huge buffer
|
||||||
|
bool ok = pt.Reserve(SIZE_MAX, err);
|
||||||
|
EXPECT_FALSE(ok);
|
||||||
|
EXPECT_FALSE(err.empty());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. MIGRATION CHECKLIST
|
||||||
|
|
||||||
|
For developers updating existing code to follow error handling
|
||||||
|
conventions:
|
||||||
|
|
||||||
|
- [ ] Identify all error-prone operations (file I/O, syscalls,
|
||||||
|
allocations)
|
||||||
|
- [ ] Add `std::string &err` parameter if not present
|
||||||
|
- [ ] Clear `err` at function start: `err.clear();`
|
||||||
|
- [ ] Capture `errno` immediately after syscall failures:
|
||||||
|
`int saved_errno = errno;`
|
||||||
|
- [ ] Build detailed error messages with context (paths, operation
|
||||||
|
details)
|
||||||
|
- [ ] Call `ErrorHandler::Instance().Error()` at all error sites
|
||||||
|
- [ ] Return `false` on failure, `true` on success
|
||||||
|
- [ ] Update all call sites to handle the error parameter
|
||||||
|
- [ ] Write unit tests that verify error handling
|
||||||
|
- [ ] Update function documentation to describe error behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. SUMMARY AND NEXT STEPS
|
||||||
|
|
||||||
|
### 9.1 Achievements
|
||||||
|
|
||||||
|
✅ **Standardized on `bool + std::string &err` pattern** for error-prone
|
||||||
|
operations
|
||||||
|
✅ **Documented comprehensive error handling conventions** in
|
||||||
|
DEVELOPER_GUIDE.md
|
||||||
|
✅ **Identified and prioritized remaining gaps** (PieceTable, internal
|
||||||
|
helpers)
|
||||||
|
✅ **Integrated ErrorHandler** into Buffer, SwapManager, and main
|
||||||
|
✅ **Established clear pattern selection guidelines** for future
|
||||||
|
development
|
||||||
|
|
||||||
|
### 9.2 Remaining Work
|
||||||
|
|
||||||
|
**Medium Priority**:
|
||||||
|
|
||||||
|
1. Document PieceTable `Data()` nullptr behavior and add assertions
|
||||||
|
2. Add error parameters to internal helper functions
|
||||||
|
|
||||||
|
**Low Priority**:
|
||||||
|
|
||||||
|
3. Add function-level error documentation to public APIs
|
||||||
|
4. Add ErrorHandler query tests
|
||||||
|
|
||||||
|
### 9.3 Conclusion
|
||||||
|
|
||||||
|
The kte codebase has achieved **strong error handling consistency** with
|
||||||
|
the `bool + std::string &err` pattern used uniformly across critical I/O
|
||||||
|
paths. The centralized ErrorHandler provides comprehensive logging and
|
||||||
|
UI integration. Remaining gaps are minor and primarily affect edge
|
||||||
|
cases (memory allocation failures) that are rare in practice.
|
||||||
|
|
||||||
|
**Overall Grade**: **B+ (8.5/10)**
|
||||||
|
|
||||||
|
**Strengths**:
|
||||||
|
|
||||||
|
- Consistent error propagation in Buffer and SwapManager
|
||||||
|
- Comprehensive ErrorHandler integration
|
||||||
|
- Excellent documentation in DEVELOPER_GUIDE.md
|
||||||
|
- Errno capture for all syscalls
|
||||||
|
- Top-level exception handling
|
||||||
|
|
||||||
|
**Areas for Improvement**:
|
||||||
|
|
||||||
|
- PieceTable memory allocation error handling
|
||||||
|
- Internal helper function error propagation
|
||||||
|
- Function-level API documentation
|
||||||
|
|
||||||
|
The error handling infrastructure is **production-ready** and provides a
|
||||||
|
solid foundation for reliable operation and debugging.
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
Based on the project structure and the presence of files like
|
|
||||||
`imgui.ini`, `GUIFrontend.h`, and `TerminalFrontend.h`, here is an
|
|
||||||
analysis of the difficulty and challenges involved in adding a GTK or Qt
|
|
||||||
version of the GUI.
|
|
||||||
|
|
||||||
### **Executive Summary: Difficulty Level - Moderate**
|
|
||||||
|
|
||||||
The project is well-architected for this task. It already supports
|
|
||||||
multiple frontends (Terminal vs. GUI), meaning the "Core Logic" (
|
|
||||||
Buffers, Syntax, Commands) is successfully decoupled from the "View" (
|
|
||||||
Rendering/Input). However, the specific move from an **Immediate Mode**
|
|
||||||
GUI (likely Dear ImGui, implied by `imgui.ini` and standard naming
|
|
||||||
patterns) to a **Retained Mode** GUI (Qt/GTK) introduces specific
|
|
||||||
architectural frictions regarding the event loop and state management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **1. Architectural Analysis**
|
|
||||||
|
|
||||||
The existence of abstract interfaces—likely `Frontend`, `Renderer`, and
|
|
||||||
`InputHandler`—is the biggest asset here.
|
|
||||||
|
|
||||||
* **Current State:**
|
|
||||||
* **Abstract Layer:** `Frontend.h`, `Renderer.h`, `InputHandler.h`
|
|
||||||
likely define the contract.
|
|
||||||
* **Implementations:**
|
|
||||||
* `Terminal*` files implement the TUI (likely ncurses or VT100).
|
|
||||||
* `GUI*` files (currently ImGui) implement the graphical
|
|
||||||
version.
|
|
||||||
* **The Path Forward:**
|
|
||||||
* You would create `QtFrontend`, `QtRenderer`, `QtInputHandler` (or
|
|
||||||
GTK equivalents).
|
|
||||||
* Because the core logic (`Editor.cc`, `Buffer.cc`) calls these
|
|
||||||
interfaces, you theoretically don't need to touch the core text
|
|
||||||
manipulation code.
|
|
||||||
|
|
||||||
### **2. Key Challenges**
|
|
||||||
|
|
||||||
#### **A. The Event Loop Inversion (Main Challenge)**
|
|
||||||
|
|
||||||
* **Current (ImGui):** Typically, the application owns the loop:
|
|
||||||
`while (running) { HandleInput(); Update(); Render(); }`. The
|
|
||||||
application explicitly tells the GUI to draw every frame.
|
|
||||||
* **Target (Qt/GTK):** The framework owns the loop: `app.exec()` or
|
|
||||||
`gtk_main()`. The framework calls *you* when events happen.
|
|
||||||
* **Difficulty:** You will need to refactor `main.cc` or the entry point
|
|
||||||
to hand over control to the Qt/GTK application object. The Editor's "
|
|
||||||
tick" function might need to be connected to a timer or an idle event
|
|
||||||
in the new framework to ensure logic updates happen.
|
|
||||||
|
|
||||||
#### **B. Rendering Paradigm: Canvas vs. Widgets**
|
|
||||||
|
|
||||||
* **The "Easy" Way (Custom Canvas):**
|
|
||||||
* Implement the `QtRenderer` by subclassing `QWidget` and overriding
|
|
||||||
`paintEvent`.
|
|
||||||
* Use `QPainter` (or Cairo in GTK) to draw text, cursors, and
|
|
||||||
selections exactly where the `Renderer` interface says to.
|
|
||||||
* **Pros:** Keeps the code similar to the current ImGui/Terminal
|
|
||||||
renderers.
|
|
||||||
* **Cons:** You lose native accessibility and some native "feel" (
|
|
||||||
scrolling physics, native text context menus).
|
|
||||||
* **The "Hard" Way (Native Widgets):**
|
|
||||||
* Trying to map an internal `Buffer` directly to a `QTextEdit` or
|
|
||||||
`GtkTextView`.
|
|
||||||
* **Difficulty:** This is usually very hard because the Editor core
|
|
||||||
likely manages its own cursor, selection, and syntax highlighting.
|
|
||||||
Syncing that internal state with a complex native widget often
|
|
||||||
leads to conflicts.
|
|
||||||
* **Recommendation:** Stick to the "Custom Canvas" approach (drawing
|
|
||||||
text manually on a surface) to preserve the custom editor
|
|
||||||
behavior (vim-like modes, specific syntax highlighting).
|
|
||||||
|
|
||||||
#### **C. Input Handling**
|
|
||||||
|
|
||||||
* **Challenge:** Mapping Qt/GTK key events to the internal `Keymap`.
|
|
||||||
* **Detail:** ImGui and Terminal libraries often provide raw scancodes
|
|
||||||
or simple chars. Qt/GTK provide complex Event objects. You will need a
|
|
||||||
translation layer in `QtInputHandler::keyPressEvent` that converts
|
|
||||||
`Qt::Key_Escape` -> `KKey::Escape` (or your internal equivalent).
|
|
||||||
|
|
||||||
### **3. Portability of Assets**
|
|
||||||
|
|
||||||
#### **Themes (Colors)**
|
|
||||||
|
|
||||||
* **Feasibility:** High.
|
|
||||||
* **Approach:** `GUITheme.h` likely contains structs with RGB/Hex
|
|
||||||
values. Qt supports stylesheets (QSS) and GTK uses CSS. You can write
|
|
||||||
a converter that reads your current theme configuration and generates
|
|
||||||
a CSS string to apply to your window, or simply use the RGB values
|
|
||||||
directly in your custom `QPainter`/Cairo drawing logic.
|
|
||||||
|
|
||||||
#### **Fonts**
|
|
||||||
|
|
||||||
* **Feasibility:** Moderate.
|
|
||||||
* **Approach:**
|
|
||||||
* **ImGui:** Usually loads a TTF into a texture atlas.
|
|
||||||
* **Qt/GTK:** Uses the system font engine (Freetype/Pango).
|
|
||||||
* **Challenge:** You won't use the texture atlas anymore. You will
|
|
||||||
simply request a font family and size (e.g.,
|
|
||||||
`QFont("JetBrains Mono", 12)`). You may need to ensure your custom
|
|
||||||
renderer calculates character width/height metrics correctly using
|
|
||||||
`QFontMetrics` (Qt) or `PangoLayout` (GTK) to align the grid
|
|
||||||
correctly.
|
|
||||||
|
|
||||||
### **4. Summary Recommendation**
|
|
||||||
|
|
||||||
If you proceed, **Qt** is generally considered easier to integrate with
|
|
||||||
C++ projects than GTK (which is C-based, though `gtkmm` exists).
|
|
||||||
|
|
||||||
1. **Create a `QtFrontend`** class inheriting from `Frontend`.
|
|
||||||
2. **Create a `QtWindow`** class inheriting from `QWidget`.
|
|
||||||
3. **Implement `QtRenderer`** that holds a pointer to the `QtWindow`.
|
|
||||||
When the core calls `DrawText()`, `QtRenderer` should queue that
|
|
||||||
command or draw directly to the widget's paint buffer.
|
|
||||||
4. **Refactor `main.cc`** to instantiate `QApplication` instead of the
|
|
||||||
current manual loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Note (2025-12): The Qt frontend defers all key processing to the
|
|
||||||
existing command subsystem and keymaps, mirroring the ImGui path. There
|
|
||||||
are no Qt-only keybindings; `QtInputHandler` translates Qt key events
|
|
||||||
into the shared keymap flow (C-k prefix, Ctrl chords, ESC/Meta,
|
|
||||||
universal-argument digits, printable insertion).
|
|
||||||
+9
-4
@@ -23,29 +23,34 @@ Current themes (alphabetically):
|
|||||||
- **gruvbox** — Retro groove color scheme (light/dark variants)
|
- **gruvbox** — Retro groove color scheme (light/dark variants)
|
||||||
- **kanagawa-paper** — Inspired by traditional Japanese art
|
- **kanagawa-paper** — Inspired by traditional Japanese art
|
||||||
- **lcars** — Star Trek LCARS interface style
|
- **lcars** — Star Trek LCARS interface style
|
||||||
|
- **leuchtturm** — Modern, clean theme (light/dark variants)
|
||||||
- **nord** — Arctic, north-bluish color palette
|
- **nord** — Arctic, north-bluish color palette
|
||||||
- **old-book** — Sepia-toned vintage book aesthetic (light/dark
|
- **old-book** — Sepia-toned vintage book aesthetic (light/dark
|
||||||
variants)
|
variants)
|
||||||
- **orbital** — Space-themed dark palette
|
- **orbital** — Space-themed dark palette
|
||||||
- **plan9** — Minimalist Plan 9 from Bell Labs inspired
|
- **plan9** — Minimalist Plan 9 from Bell Labs inspired
|
||||||
- **solarized** — Ethan Schoonover's Solarized (light/dark variants)
|
- **solarized** — Ethan Schoonover's Solarized (light/dark variants)
|
||||||
|
- **tufte** — Edward Tufte-inspired minimalist theme (light/dark variants)
|
||||||
- **weyland-yutani** — Alien franchise corporate aesthetic
|
- **weyland-yutani** — Alien franchise corporate aesthetic
|
||||||
- **zenburn** — Low-contrast, easy-on-the-eyes theme
|
- **zenburn** — Low-contrast, easy-on-the-eyes theme
|
||||||
|
|
||||||
Configuration
|
Configuration
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
Themes are configured via `$HOME/.config/kte/kge.ini`:
|
Themes are configured via `$HOME/.config/kte/kge.toml`:
|
||||||
|
|
||||||
```ini
|
```toml
|
||||||
theme = nord
|
[appearance]
|
||||||
background = dark
|
theme = "nord"
|
||||||
|
background = "dark"
|
||||||
```
|
```
|
||||||
|
|
||||||
- `theme` — The theme name (e.g., "nord", "gruvbox", "solarized")
|
- `theme` — The theme name (e.g., "nord", "gruvbox", "solarized")
|
||||||
- `background` — Either "dark" or "light" (for themes supporting both
|
- `background` — Either "dark" or "light" (for themes supporting both
|
||||||
variants)
|
variants)
|
||||||
|
|
||||||
|
Legacy `kge.ini` format is also supported (see CONFIG.md).
|
||||||
|
|
||||||
Themes can also be switched at runtime using the `:theme <name>`
|
Themes can also be switched at runtime using the `:theme <name>`
|
||||||
command.
|
command.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@
|
|||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
outputs =
|
outputs = { self, nixpkgs, ... }:
|
||||||
inputs@{ self, nixpkgs, ... }:
|
|
||||||
let
|
let
|
||||||
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
|
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
|
||||||
pkgsFor = system: import nixpkgs { inherit system; };
|
pkgsFor = system: import nixpkgs { inherit system; };
|
||||||
@@ -13,9 +12,26 @@
|
|||||||
packages = eachSystem (system: rec {
|
packages = eachSystem (system: rec {
|
||||||
default = kte;
|
default = kte;
|
||||||
full = kge;
|
full = kge;
|
||||||
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; graphical-qt = false; };
|
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; };
|
||||||
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; };
|
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; };
|
||||||
qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
devShells = eachSystem (system:
|
||||||
|
let pkgs = pkgsFor system;
|
||||||
|
in {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
inputsFrom = [ self.packages.${system}.kge ];
|
||||||
|
packages = with pkgs; [ gdb valgrind ];
|
||||||
|
};
|
||||||
|
terminal = pkgs.mkShell {
|
||||||
|
inputsFrom = [ self.packages.${system}.kte ];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
overlays.default = final: prev: {
|
||||||
|
kte = self.packages.${final.system}.kte;
|
||||||
|
kge = self.packages.${final.system}.kge;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1768
File diff suppressed because it is too large
Load Diff
+1203
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@
|
|||||||
#include "BerkeleyMono.h"
|
#include "BerkeleyMono.h"
|
||||||
#include "BrassMono.h"
|
#include "BrassMono.h"
|
||||||
#include "BrassMonoCode.h"
|
#include "BrassMonoCode.h"
|
||||||
|
#include "CrimsonPro.h"
|
||||||
|
#include "ETBook.h"
|
||||||
#include "FiraCode.h"
|
#include "FiraCode.h"
|
||||||
#include "Go.h"
|
#include "Go.h"
|
||||||
#include "IBMPlexMono.h"
|
#include "IBMPlexMono.h"
|
||||||
@@ -13,6 +15,7 @@
|
|||||||
#include "IosevkaExtended.h"
|
#include "IosevkaExtended.h"
|
||||||
#include "ShareTech.h"
|
#include "ShareTech.h"
|
||||||
#include "SpaceMono.h"
|
#include "SpaceMono.h"
|
||||||
|
#include "Spectral.h"
|
||||||
#include "Syne.h"
|
#include "Syne.h"
|
||||||
#include "Triplicate.h"
|
#include "Triplicate.h"
|
||||||
#include "Unispace.h"
|
#include "Unispace.h"
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ InstallDefaultFonts()
|
|||||||
BrassMonoCode::DefaultFontBoldCompressedData,
|
BrassMonoCode::DefaultFontBoldCompressedData,
|
||||||
BrassMonoCode::DefaultFontBoldCompressedSize
|
BrassMonoCode::DefaultFontBoldCompressedSize
|
||||||
));
|
));
|
||||||
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
|
"crimsonpro",
|
||||||
|
CrimsonPro::DefaultFontRegularCompressedData,
|
||||||
|
CrimsonPro::DefaultFontRegularCompressedSize
|
||||||
|
));
|
||||||
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
|
"etbook",
|
||||||
|
ETBook::DefaultFontRegularCompressedData,
|
||||||
|
ETBook::DefaultFontRegularCompressedSize
|
||||||
|
));
|
||||||
FontRegistry::Instance().Register(std::make_unique<Font>(
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
"fira",
|
"fira",
|
||||||
FiraCode::DefaultFontRegularCompressedData,
|
FiraCode::DefaultFontRegularCompressedData,
|
||||||
@@ -95,6 +105,11 @@ InstallDefaultFonts()
|
|||||||
SpaceMono::DefaultFontRegularCompressedData,
|
SpaceMono::DefaultFontRegularCompressedData,
|
||||||
SpaceMono::DefaultFontRegularCompressedSize
|
SpaceMono::DefaultFontRegularCompressedSize
|
||||||
));
|
));
|
||||||
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
|
"spectral",
|
||||||
|
Spectral::DefaultFontRegularCompressedData,
|
||||||
|
Spectral::DefaultFontRegularCompressedSize
|
||||||
|
));
|
||||||
FontRegistry::Instance().Register(std::make_unique<Font>(
|
FontRegistry::Instance().Register(std::make_unique<Font>(
|
||||||
"syne",
|
"syne",
|
||||||
Syne::DefaultFontRegularCompressedData,
|
Syne::DefaultFontRegularCompressedData,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "Font.h"
|
#include "Font.h"
|
||||||
|
|
||||||
@@ -87,6 +89,19 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Return all registered font names (sorted)
|
||||||
|
std::vector<std::string> FontNames() const
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
std::vector<std::string> names;
|
||||||
|
names.reserve(fonts_.size());
|
||||||
|
for (const auto &[name, _] : fonts_)
|
||||||
|
names.push_back(name);
|
||||||
|
std::sort(names.begin(), names.end());
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Current font name/size as last successfully loaded via LoadFont()
|
// Current font name/size as last successfully loaded via LoadFont()
|
||||||
std::string CurrentFontName() const
|
std::string CurrentFontName() const
|
||||||
{
|
{
|
||||||
|
|||||||
+17142
-8577
File diff suppressed because it is too large
Load Diff
+3227
File diff suppressed because it is too large
Load Diff
+14694
-7353
File diff suppressed because it is too large
Load Diff
+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;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# kge configuration
|
||||||
|
# Place at ~/.config/kte/kge.toml
|
||||||
|
|
||||||
|
[window]
|
||||||
|
fullscreen = false
|
||||||
|
columns = 80
|
||||||
|
rows = 42
|
||||||
|
|
||||||
|
[font]
|
||||||
|
# Default font and size
|
||||||
|
name = "default"
|
||||||
|
size = 18.0
|
||||||
|
# Font used in code mode (monospace)
|
||||||
|
code = "default"
|
||||||
|
# Font used in writing mode (proportional) — for .txt, .md, .rst, .org, .tex, etc.
|
||||||
|
writing = "crimsonpro"
|
||||||
|
|
||||||
|
[appearance]
|
||||||
|
theme = "nord"
|
||||||
|
# "dark" or "light" for themes with variants
|
||||||
|
background = "dark"
|
||||||
|
|
||||||
|
[editor]
|
||||||
|
syntax = true
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <clocale>
|
#include <clocale>
|
||||||
|
#include <langinfo.h>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
#include <random>
|
#include <random>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
|
#include <filesystem>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
@@ -20,14 +22,11 @@
|
|||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "Frontend.h"
|
#include "Frontend.h"
|
||||||
#include "TerminalFrontend.h"
|
#include "TerminalFrontend.h"
|
||||||
|
#include "ErrorHandler.h"
|
||||||
|
|
||||||
#if defined(KTE_BUILD_GUI)
|
#if defined(KTE_BUILD_GUI)
|
||||||
#if defined(KTE_USE_QT)
|
|
||||||
#include "QtFrontend.h"
|
|
||||||
#else
|
|
||||||
#include "ImGuiFrontend.h"
|
#include "ImGuiFrontend.h"
|
||||||
#endif
|
#endif
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
#ifndef KTE_VERSION_STR
|
#ifndef KTE_VERSION_STR
|
||||||
@@ -111,10 +110,36 @@ RunStressHighlighter(unsigned seconds)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// kte edits UTF-8 text. With LANG unset (common in containers and ssh
|
||||||
|
// sessions) the C locale cannot decode or encode non-ASCII characters, and
|
||||||
|
// typed or pasted non-ASCII text was dropped (with the rest of the paste).
|
||||||
|
// Use a UTF-8 character-type locale when the environment does not give one.
|
||||||
|
static void
|
||||||
|
ensure_utf8_ctype()
|
||||||
|
{
|
||||||
|
auto is_utf8 = [] {
|
||||||
|
std::string cs = nl_langinfo(CODESET);
|
||||||
|
for (auto &c: cs)
|
||||||
|
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
return cs == "utf-8" || cs == "utf8";
|
||||||
|
};
|
||||||
|
if (is_utf8())
|
||||||
|
return;
|
||||||
|
for (const char *name: {"C.UTF-8", "C.utf8", "en_US.UTF-8", "en_US.utf8"}) {
|
||||||
|
if (std::setlocale(LC_CTYPE, name) && is_utf8())
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
int
|
int
|
||||||
main(int argc, char *argv[])
|
main(int argc, char *argv[])
|
||||||
{
|
{
|
||||||
std::setlocale(LC_ALL, "");
|
std::setlocale(LC_ALL, "");
|
||||||
|
ensure_utf8_ctype();
|
||||||
|
|
||||||
|
// Ensure the error handler (and its log file) is initialised early.
|
||||||
|
kte::ErrorHandler::Instance();
|
||||||
|
|
||||||
Editor editor;
|
Editor editor;
|
||||||
|
|
||||||
@@ -181,10 +206,13 @@ main(int argc, char *argv[])
|
|||||||
return RunStressHighlighter(stress_seconds);
|
return RunStressHighlighter(stress_seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top-level exception handler to prevent data loss and ensure cleanup
|
||||||
|
try {
|
||||||
// Determine frontend
|
// Determine frontend
|
||||||
#if !defined(KTE_BUILD_GUI)
|
#if !defined(KTE_BUILD_GUI)
|
||||||
if (req_gui) {
|
if (req_gui) {
|
||||||
std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed." <<
|
std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed."
|
||||||
|
<<
|
||||||
std::endl;
|
std::endl;
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
@@ -196,6 +224,8 @@ main(int argc, char *argv[])
|
|||||||
use_gui = false;
|
use_gui = false;
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Default depends on build target: kge defaults to GUI, kte to terminal
|
// Default depends on build target: kge defaults to GUI, kte to terminal
|
||||||
#if defined(KTE_DEFAULT_GUI)
|
#if defined(KTE_DEFAULT_GUI)
|
||||||
use_gui = true;
|
use_gui = true;
|
||||||
@@ -246,7 +276,18 @@ main(int argc, char *argv[])
|
|||||||
// Fall through: not a +number, treat as filename starting with '+'
|
// Fall through: not a +number, treat as filename starting with '+'
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string path = arg;
|
// Resolve to absolute path now, before any
|
||||||
|
// chdir (macOS GUI changes CWD to HOME before
|
||||||
|
// deferred opens are processed).
|
||||||
|
std::string path = arg;
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(path);
|
||||||
|
if (p.is_relative()) {
|
||||||
|
path = std::filesystem::absolute(p).string();
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
// Fall through with original path
|
||||||
|
}
|
||||||
editor.RequestOpenFile(path, pending_line);
|
editor.RequestOpenFile(path, pending_line);
|
||||||
pending_line = 0; // consumed (if set)
|
pending_line = 0; // consumed (if set)
|
||||||
}
|
}
|
||||||
@@ -271,6 +312,20 @@ main(int argc, char *argv[])
|
|||||||
fe = std::make_unique<TerminalFrontend>();
|
fe = std::make_unique<TerminalFrontend>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guarantee Shutdown() runs on every exit path from here on (normal
|
||||||
|
// return, early return, or exception unwinding e.g. from Init/Execute/
|
||||||
|
// Step) so a crash never leaves the terminal in raw/cbreak mode.
|
||||||
|
struct FrontendShutdownGuard {
|
||||||
|
Frontend *fe;
|
||||||
|
|
||||||
|
|
||||||
|
~FrontendShutdownGuard()
|
||||||
|
{
|
||||||
|
if (fe)
|
||||||
|
fe->Shutdown();
|
||||||
|
}
|
||||||
|
} shutdown_guard{fe.get()};
|
||||||
|
|
||||||
#if defined(KTE_BUILD_GUI) && defined(__APPLE__)
|
#if defined(KTE_BUILD_GUI) && defined(__APPLE__)
|
||||||
if (use_gui) {
|
if (use_gui) {
|
||||||
/* likely using the .app, so need to cd */
|
/* likely using the .app, so need to cd */
|
||||||
@@ -299,7 +354,19 @@ main(int argc, char *argv[])
|
|||||||
fe->Step(editor, running);
|
fe->Step(editor, running);
|
||||||
}
|
}
|
||||||
|
|
||||||
fe->Shutdown();
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
std::string msg = std::string("Unhandled exception: ") + e.what();
|
||||||
|
kte::ErrorHandler::Instance().Critical("main", msg);
|
||||||
|
std::cerr << "\n*** FATAL ERROR ***\n"
|
||||||
|
<< "kte encountered an unhandled exception: " << e.what() << "\n"
|
||||||
|
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
|
||||||
|
return 1;
|
||||||
|
} catch (...) {
|
||||||
|
kte::ErrorHandler::Instance().Critical("main", "Unknown exception");
|
||||||
|
std::cerr << "\n*** FATAL ERROR ***\n"
|
||||||
|
<< "kte encountered an unknown exception.\n"
|
||||||
|
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,21 +14,3 @@ zip -r kge.app.zip kge.app
|
|||||||
sha256sum kge.app.zip
|
sha256sum kge.app.zip
|
||||||
open .
|
open .
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
mkdir -p cmake-build-release-qt
|
|
||||||
cmake -S . -B cmake-build-release-qt -DBUILD_GUI=ON -DKTE_USE_QT=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
|
|
||||||
|
|
||||||
cd cmake-build-release-qt
|
|
||||||
make clean
|
|
||||||
rm -fr kge.app* kge-qt.app*
|
|
||||||
make
|
|
||||||
mv -f kge.app kge-qt.app
|
|
||||||
# Use the same Qt's macdeployqt as used for building; ensure it overwrites in-bundle paths
|
|
||||||
macdeployqt kge-qt.app -always-overwrite -verbose=3
|
|
||||||
|
|
||||||
# Run CMake BundleUtilities fixup to internalize non-Qt dylibs and rewrite install names
|
|
||||||
cmake -DAPP_BUNDLE="$(pwd)/kge-qt.app" -P "${PWD%/*}/cmake/fix_bundle.cmake"
|
|
||||||
zip -r kge-qt.app.zip kge-qt.app
|
|
||||||
sha256sum kge-qt.app.zip
|
|
||||||
open .
|
|
||||||
cd ..
|
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ fi
|
|||||||
|
|
||||||
git tag "${KTE_VERSION}"
|
git tag "${KTE_VERSION}"
|
||||||
git push && git push --tags
|
git push && git push --tags
|
||||||
|
git push github && git push github --tags
|
||||||
|
|
||||||
( ./make-app-release )
|
( ./make-app-release )
|
||||||
@@ -60,11 +60,10 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
|||||||
const LineState &prev,
|
const LineState &prev,
|
||||||
std::vector<HighlightSpan> &out) const
|
std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
StatefulHighlighter::LineState state = prev;
|
StatefulHighlighter::LineState state = prev;
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
return state;
|
return state;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
if (s.empty())
|
if (s.empty())
|
||||||
return state;
|
return state;
|
||||||
|
|
||||||
@@ -164,11 +163,14 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
|||||||
if (c == 'R' && i + 1 < n && s[i + 1] == '"') {
|
if (c == 'R' && i + 1 < n && s[i + 1] == '"') {
|
||||||
int k = i + 2;
|
int k = i + 2;
|
||||||
std::string delim;
|
std::string delim;
|
||||||
while (k < n && s[k] != '(') {
|
// A raw-string delimiter is at most 16 characters (and has no
|
||||||
|
// spaces, backslashes or parentheses); scanning the rest of the
|
||||||
|
// line for '(' at every R" made long lines quadratic.
|
||||||
|
while (k < n && s[k] != '(' && delim.size() <= 16) {
|
||||||
delim.push_back(s[k]);
|
delim.push_back(s[k]);
|
||||||
++k;
|
++k;
|
||||||
}
|
}
|
||||||
if (k < n && s[k] == '(') {
|
if (k < n && s[k] == '(' && delim.size() <= 16) {
|
||||||
int body_start = k + 1;
|
int body_start = k + 1;
|
||||||
std::string needle = ")" + delim + "\"";
|
std::string needle = ")" + delim + "\"";
|
||||||
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ ErlangHighlighter::ErlangHighlighter()
|
|||||||
void
|
void
|
||||||
ErlangHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
ErlangHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ ForthHighlighter::ForthHighlighter()
|
|||||||
void
|
void
|
||||||
ForthHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
ForthHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
|
|||||||
+37
-5
@@ -46,15 +46,45 @@ GoHighlighter::GoHighlighter()
|
|||||||
void
|
void
|
||||||
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
StatefulHighlighter::LineState prev;
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
(void) HighlightLineStateful(buf, row, prev, out);
|
||||||
return;
|
}
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
|
|
||||||
|
StatefulHighlighter::LineState
|
||||||
|
GoHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||||
|
int row,
|
||||||
|
const LineState &prev,
|
||||||
|
std::vector<HighlightSpan> &out) const
|
||||||
|
{
|
||||||
|
StatefulHighlighter::LineState state = prev;
|
||||||
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
|
return state;
|
||||||
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
int bol = 0;
|
int bol = 0;
|
||||||
while (bol < n && (s[bol] == ' ' || s[bol] == '\t'))
|
while (bol < n && (s[bol] == ' ' || s[bol] == '\t'))
|
||||||
++bol;
|
++bol;
|
||||||
|
|
||||||
|
// Continue a multi-line block comment from the previous line.
|
||||||
|
if (state.in_block_comment) {
|
||||||
|
int j = i;
|
||||||
|
while (i + 1 < n) {
|
||||||
|
if (s[i] == '*' && s[i + 1] == '/') {
|
||||||
|
i += 2;
|
||||||
|
push(out, j, i, TokenKind::Comment);
|
||||||
|
state.in_block_comment = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (state.in_block_comment) {
|
||||||
|
push(out, j, n, TokenKind::Comment);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// line comment
|
// line comment
|
||||||
while (i < n) {
|
while (i < n) {
|
||||||
char c = s[i];
|
char c = s[i];
|
||||||
@@ -83,7 +113,8 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
|||||||
}
|
}
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
push(out, i, n, TokenKind::Comment);
|
push(out, i, n, TokenKind::Comment);
|
||||||
break;
|
state.in_block_comment = true;
|
||||||
|
return state;
|
||||||
} else {
|
} else {
|
||||||
push(out, i, j, TokenKind::Comment);
|
push(out, i, j, TokenKind::Comment);
|
||||||
i = j;
|
i = j;
|
||||||
@@ -153,5 +184,6 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
|||||||
push(out, i, i + 1, TokenKind::Default);
|
push(out, i, i + 1, TokenKind::Default);
|
||||||
++i;
|
++i;
|
||||||
}
|
}
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
@@ -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.Rows().size()) {
|
|
||||||
if (r > best)
|
|
||||||
best = r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (best >= 0) {
|
|
||||||
start_row = best;
|
|
||||||
prev_state = state_cache_.at(best).state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We'll compute states and the target line's spans without holding the lock for most of the work.
|
|
||||||
// Create a local copy of prev_state and iterate rows; we will update caches under lock.
|
|
||||||
lock.unlock();
|
|
||||||
StatefulHighlighter::LineState cur_state = prev_state;
|
|
||||||
for (int r = start_row + 1; r <= row; ++r) {
|
|
||||||
std::vector<HighlightSpan> tmp;
|
|
||||||
std::vector<HighlightSpan> &out = (r == row) ? result.spans : tmp;
|
|
||||||
auto next_state = stateful->HighlightLineStateful(buf, r, cur_state, out);
|
|
||||||
// Update state cache for r
|
|
||||||
std::lock_guard<std::mutex> gl(mtx_);
|
|
||||||
StateEntry se;
|
|
||||||
se.version = buf_version;
|
|
||||||
se.state = next_state;
|
|
||||||
state_cache_[r] = se;
|
|
||||||
cur_state = next_state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store in cache and return by value
|
|
||||||
lock.lock();
|
|
||||||
cache_[row] = result;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
HighlighterEngine::InvalidateFrom(int row)
|
HighlighterEngine::InvalidateFrom(int row)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
invalidate_from_locked(row);
|
||||||
if (cache_.empty())
|
|
||||||
return;
|
|
||||||
// Simple implementation: erase all rows >= row
|
|
||||||
for (auto it = cache_.begin(); it != cache_.end();) {
|
|
||||||
if (it->first >= row)
|
|
||||||
it = cache_.erase(it);
|
|
||||||
else
|
|
||||||
++it;
|
|
||||||
}
|
|
||||||
if (!state_cache_.empty()) {
|
|
||||||
for (auto it = state_cache_.begin(); it != state_cache_.end();) {
|
|
||||||
if (it->first >= row)
|
|
||||||
it = state_cache_.erase(it);
|
|
||||||
else
|
|
||||||
++it;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
HighlighterEngine::ensure_worker_started() const
|
HighlighterEngine::OnEdit(int row, std::uint64_t buf_version)
|
||||||
{
|
{
|
||||||
if (worker_running_.load())
|
if (have_version_)
|
||||||
return;
|
invalidate_from_locked(row);
|
||||||
worker_running_.store(true);
|
version_ = buf_version;
|
||||||
worker_ = std::thread([this]() {
|
have_version_ = true;
|
||||||
this->worker_loop();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void
|
|
||||||
HighlighterEngine::worker_loop() const
|
|
||||||
{
|
|
||||||
std::unique_lock<std::mutex> lock(mtx_);
|
|
||||||
while (worker_running_.load()) {
|
|
||||||
cv_.wait(lock, [this]() {
|
|
||||||
return has_request_ || !worker_running_.load();
|
|
||||||
});
|
|
||||||
if (!worker_running_.load())
|
|
||||||
break;
|
|
||||||
WarmRequest req = pending_;
|
|
||||||
has_request_ = false;
|
|
||||||
// Copy locals then release lock while computing
|
|
||||||
lock.unlock();
|
|
||||||
if (req.buf) {
|
|
||||||
int start = std::max(0, req.start_row);
|
|
||||||
int end = std::max(start, req.end_row);
|
|
||||||
int skip_f = std::min(req.skip_first, req.skip_last);
|
|
||||||
int skip_l = std::max(req.skip_first, req.skip_last);
|
|
||||||
for (int r = start; r <= end; ++r) {
|
|
||||||
// Avoid touching rows that the foreground just computed/drew.
|
|
||||||
if (r >= skip_f && r <= skip_l)
|
|
||||||
continue;
|
|
||||||
// Compute line; GetLine is thread-safe and will refresh caches.
|
|
||||||
(void) this->GetLine(*req.buf, r, req.version);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lock.lock();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version,
|
HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_count, std::uint64_t buf_version,
|
||||||
int warm_margin) const
|
int /*warm_margin*/) const
|
||||||
{
|
{
|
||||||
if (row_count <= 0)
|
if (row_count <= 0)
|
||||||
return;
|
return;
|
||||||
@@ -204,21 +132,5 @@ HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_co
|
|||||||
for (int r = start; r <= end; ++r) {
|
for (int r = start; r <= end; ++r) {
|
||||||
(void) GetLine(buf, r, buf_version);
|
(void) GetLine(buf, r, buf_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue background warm-around
|
|
||||||
int warm_start = std::max(0, start - warm_margin);
|
|
||||||
int warm_end = std::min(max_rows - 1, end + warm_margin);
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
|
||||||
pending_.buf = &buf;
|
|
||||||
pending_.version = buf_version;
|
|
||||||
pending_.start_row = warm_start;
|
|
||||||
pending_.end_row = warm_end;
|
|
||||||
pending_.skip_first = start;
|
|
||||||
pending_.skip_last = end;
|
|
||||||
has_request_ = true;
|
|
||||||
}
|
|
||||||
ensure_worker_started();
|
|
||||||
cv_.notify_one();
|
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
+34
-46
@@ -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
|
||||||
@@ -13,10 +13,9 @@ is_digit(char c)
|
|||||||
void
|
void
|
||||||
JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
auto push = [&](int a, int b, TokenKind k) {
|
auto push = [&](int a, int b, TokenKind k) {
|
||||||
if (b > a)
|
if (b > a)
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ LispHighlighter::LispHighlighter()
|
|||||||
void
|
void
|
||||||
LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
int bol = 0;
|
int bol = 0;
|
||||||
|
|||||||
@@ -24,10 +24,9 @@ MarkdownHighlighter::HighlightLineStateful(const Buffer &buf, int row, const Lin
|
|||||||
std::vector<HighlightSpan> &out) const
|
std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
StatefulHighlighter::LineState state = prev;
|
StatefulHighlighter::LineState state = prev;
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return state;
|
return state;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
|
|
||||||
// Reuse in_block_comment flag as "in fenced code" state.
|
// Reuse in_block_comment flag as "in fenced code" state.
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ namespace kte {
|
|||||||
void
|
void
|
||||||
NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
if (n <= 0)
|
if (n <= 0)
|
||||||
return;
|
return;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user