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 |
+2
-58
@@ -1,58 +1,2 @@
|
|||||||
# Project Guidelines
|
Read AGENTS.md at the repository root before anything else. It is the only
|
||||||
|
project guide; this file exists because Junie cannot import it.
|
||||||
kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
|
|
||||||
It
|
|
||||||
replaces the earlier C implementation, ke (see the ke manual in
|
|
||||||
`docs/ke.md`). The
|
|
||||||
design draws inspiration from Antirez' kilo, with keybindings rooted in
|
|
||||||
the
|
|
||||||
WordStar/VDE family and emacs. The spiritual parent is `mg(1)`.
|
|
||||||
|
|
||||||
These guidelines summarize the goals, interfaces, key operations, and
|
|
||||||
current
|
|
||||||
development practices for kte.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Keep the core small, fast, and understandable.
|
|
||||||
- Provide an ncurses-based terminal-first editing experience, with an
|
|
||||||
additional ImGui GUI.
|
|
||||||
- Preserve familiar keybindings from ke while modernizing the internals.
|
|
||||||
- Favor simple data structures (e.g., piece table) and incremental
|
|
||||||
evolution.
|
|
||||||
|
|
||||||
Project entry point: `main.cpp`
|
|
||||||
|
|
||||||
## Core Components (current codebase)
|
|
||||||
|
|
||||||
- Buffer: editing model and file I/O (`Buffer.h/.cpp`).
|
|
||||||
- PieceTable: editable in-memory text representation (
|
|
||||||
`PieceTable.h/.cpp`).
|
|
||||||
- InputHandler: interface for handling text input (`InputHandler.h/`),
|
|
||||||
along
|
|
||||||
with `TerminalInputHandler` (ncurses-based) and `GUIInputHandler`.
|
|
||||||
- Renderer: interface for rendering text (`Renderer.h`), along with
|
|
||||||
`TerminalRenderer` (ncurses-based) and `GUIRenderer`.
|
|
||||||
- Editor: top-level editor state (`Editor.h/.cpp`).
|
|
||||||
- Command: command model (`Command.h/.cpp`).
|
|
||||||
- General purpose editor functionality (`Editing.h/.cpp`)
|
|
||||||
|
|
||||||
## Keybindings (inherited from ke)
|
|
||||||
|
|
||||||
The file `docs/ke.md` contains the canonical reference for keybindings.
|
|
||||||
|
|
||||||
## Contributing/Development Notes
|
|
||||||
|
|
||||||
- C++ standard: C++20.
|
|
||||||
- Keep dependencies minimal.
|
|
||||||
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
|
||||||
changing
|
|
||||||
behavior.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Previous editor manual: `ke.md` (canonical keybinding/spec reference
|
|
||||||
for now).
|
|
||||||
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# kte
|
||||||
|
|
||||||
|
kte (Kyle's Text Editor) is a C++20 text editor with a terminal-first design (ncurses) and an optional ImGui GUI frontend (SDL2/OpenGL/Freetype); it uses a WordStar/VDE-style command model. Standards: none. The Metacircular service standards do not apply here.
|
||||||
|
|
||||||
|
## Bootstrap (run once per checkout, idempotent)
|
||||||
|
nix develop (default shell; also provides a `terminal` devshell) or system SDL2/Freetype/ncurses if not using nix.
|
||||||
|
|
||||||
|
## Gate (run before saying "done"; must exit 0)
|
||||||
|
make gate # what it runs: configure (cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON -DBUILD_TESTS=ON) -> build (cmake --build build) -> test (./build/kte_tests)
|
||||||
|
Fast check while editing: cmake --build build --target kte_tests && ./build/kte_tests
|
||||||
|
Also run clang-tidy on changed files via build/compile_commands.json (not part of `make gate`; clang-tidy is not on this Mac's PATH).
|
||||||
|
|
||||||
|
## Run
|
||||||
|
Terminal editor: `./build/kte <file>` (always built).
|
||||||
|
GUI editor: `./build/kge <file>` (built when `-DBUILD_GUI=ON`; requires SDL2/OpenGL/Freetype).
|
||||||
|
Docker (cross-platform Linux testing): `docker build -t kte-linux . && docker run --rm -v "$(pwd):/kte" kte-linux`.
|
||||||
|
|
||||||
|
## Sensors the harness watches
|
||||||
|
- `make gate` exit status and last 40 lines; `kte_tests` runs via ctest too (`ctest --test-dir build`)
|
||||||
|
- clang-tidy warning count on the diff (when clang-tidy is available)
|
||||||
|
- for GUI bugs: launch `./build/kge` and report what was seen; a green test suite is not proof
|
||||||
|
|
||||||
|
## Rules (do not re-litigate)
|
||||||
|
- Release: bump CMakeLists version, checkpoint, `./make-release.sh`, report the SHA256 it prints; a GitHub push failure at the same commit is fine; then update `/opt/homebrew/Library/Taps/kisom/homebrew-tap`.
|
||||||
|
- All text mutations must go through the PieceTable API (`insert_text`, `delete_text`) so undo and swap recording work.
|
||||||
|
- Read lines with `Nrows()` and `GetLineString()` (a copy, cheap on any buffer) or `GetLineView()` (zero-copy when the line lies within one piece; a line straddling an edit materializes the whole buffer); `GetLineView()` and `ContentView()` are valid only until the next buffer modification.
|
||||||
|
- C++20, compiled with `-Wall -Wextra -Werror -pedantic`; Clang uses `-stdlib=libc++`.
|
||||||
|
- Naming: PascalCase for classes/methods, snake_case for variables, trailing underscore for private members (e.g. `pieces_`); indentation is tabs.
|
||||||
|
- Fallible ops use `bool func(args..., std::string &err)`: clear `err` at start, capture `errno` immediately after syscall failure, use EINTR-safe wrappers from `SyscallWrappers.h` instead of raw syscalls.
|
||||||
|
- After editing ops, call `ensure_cursor_visible()` to update the viewport.
|
||||||
|
|
||||||
|
## Docs that govern
|
||||||
|
- `docs/ke.md` — canonical keybinding/spec reference, inherited from the predecessor editor `ke`
|
||||||
|
- `REWRITE.md`, `ROADMAP.md`, `CONFIG.md` — design rationale, roadmap, and config reference
|
||||||
|
- `README.md` — project overview
|
||||||
|
|
||||||
|
## Remote target
|
||||||
|
None: kte builds and runs locally (or in the Docker/CI Linux image); it has no remote deployment target.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three-layer design with strict frontend independence:
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend Layer (Terminal / ImGui / Test)
|
||||||
|
InputHandler.h, Renderer.h, Frontend.h interfaces
|
||||||
|
↓
|
||||||
|
Command Layer
|
||||||
|
CommandId enum → CommandRegistry → handler functions in Command.cc
|
||||||
|
↓
|
||||||
|
Core Model Layer
|
||||||
|
Editor → Buffer → PieceTable
|
||||||
|
UndoSystem (tree-based, records at PieceTable level)
|
||||||
|
SwapManager (crash recovery journal per buffer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
- **PieceTable** (`PieceTable.h/.cc`) - Text storage. Lazy materialization; most ops work on the piece list directly. Line index and materialization caches must be invalidated on content changes.
|
||||||
|
- **Buffer** (`Buffer.h/.cc`) - Wraps PieceTable.
|
||||||
|
- **Editor** (`Editor.h/.cc`) - Top-level state container. Primarily getters/setters; editing logic lives in commands.
|
||||||
|
- **Command** (`Command.h/.cc`) - 120+ editing commands. This is the main place to add new editing operations. Register via `CommandRegistry::Register()` in `InstallDefaultCommands()`.
|
||||||
|
- **UndoSystem/UndoTree/UndoNode** - Tree-based undo with branching. Group related ops with `buf.Undo()->BeginGroup()` / `EndGroup()`.
|
||||||
|
- **Swap** (`Swap.h/.cc`) - Append-only crash recovery journal. Uses circuit breaker pattern for resilience. Files in `~/.local/state/kte/`.
|
||||||
|
- **Syntax highlighting** (`syntax/`) - Pluggable per-language highlighters registered in `HighlighterRegistry`. Per-line caching with buffer version tracking.
|
||||||
|
|
||||||
|
### Frontend Implementations
|
||||||
|
|
||||||
|
Each frontend implements three interfaces (`Frontend.h`, `InputHandler.h`, `Renderer.h`):
|
||||||
|
- **Terminal**: ncurses-based (always built)
|
||||||
|
- **ImGui**: SDL2+OpenGL+Freetype (built with `-DBUILD_GUI=ON`)
|
||||||
|
- **Test**: Programmatic frontend for testing (always built, no UI deps)
|
||||||
|
|
||||||
|
### Key CMake Options
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `BUILD_GUI` | ON | Build `kge` (ImGui GUI) |
|
||||||
|
| `BUILD_TESTS` | ON | Build test suite |
|
||||||
|
| `ENABLE_ASAN` | OFF | AddressSanitizer |
|
||||||
|
| `KTE_STATIC_LINK` | OFF | Static linking (Linux only) |
|
||||||
|
| `KTE_ENABLE_TREESITTER` | OFF | Tree-sitter syntax highlighting |
|
||||||
|
| `KTE_USE_PCRE2` | ON | Regex search/replace via PCRE2 when `libpcre2-8` is found (std::regex otherwise; see `RegexEngine.h`) |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live in `tests/test_*.cc`, run by the single `kte_tests` binary (also registered with ctest as `add_test(NAME kte_tests COMMAND kte_tests)`); `./build/kte_tests Swap_ Undo_` runs only the tests whose names contain one of the arguments. Tests use a minimal custom framework in `tests/Test.h` with `TEST()`, `ASSERT_EQ()`, `ASSERT_TRUE()`, `EXPECT_TRUE()` macros. Use `TestFrontend`/`TestInputHandler`/`TestRenderer` for integration tests that exercise the full Editor+Buffer+Command stack without UI dependencies.
|
||||||
|
|
||||||
|
Key test files by area:
|
||||||
|
- PieceTable: `test_piece_table.cc`
|
||||||
|
- Buffer I/O: `test_buffer_io.cc`
|
||||||
|
- Commands: `test_command_semantics.cc`
|
||||||
|
- Search/replace: `test_search_replace_flow.cc`
|
||||||
|
- Undo: `test_undo.cc`
|
||||||
|
- Swap (crash recovery): `test_swap_*.cc` (7 files)
|
||||||
|
- Reflow: `test_reflow_paragraph.cc`, `test_reflow_indented_bullets.cc`
|
||||||
|
- Integration: `test_daily_workflows.cc`
|
||||||
|
|
||||||
|
## Important Caveats
|
||||||
|
|
||||||
|
- `GetLineView()` and `ContentView()` return a `string_view` valid only until the next buffer modification; views of different lines need not be contiguous.
|
||||||
|
- Buffers, PieceTable and HighlighterEngine are used from the main thread only (the swap writer thread receives copies of the bytes).
|
||||||
|
- All source files are in the project root (no `src/` directory); tests are in `tests/`; syntax highlighters in `syntax/`; themes in `themes/`; embedded fonts in `fonts/`.
|
||||||
|
- External deps: `ext/imgui/` (Dear ImGui), `ext/tomlplusplus/` (TOML parser).
|
||||||
|
- GUI config: `~/.config/kte/kge.toml` (TOML preferred over legacy INI).
|
||||||
|
- ErrorHandler: centralized logging to `~/.local/state/kte/error.log` with severity levels (Info/Warning/Error/Critical).
|
||||||
@@ -99,19 +99,26 @@ Buffer::RefreshOnDiskIdentity()
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The bytes to save, as views of the piece table's storage in order (saving
|
||||||
|
// never makes a contiguous copy of the buffer).
|
||||||
|
using ByteChunks = std::vector<std::string_view>;
|
||||||
|
|
||||||
|
|
||||||
static bool
|
static bool
|
||||||
write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
|
write_all_fd(int fd, const ByteChunks &chunks, std::string &err)
|
||||||
{
|
{
|
||||||
std::size_t off = 0;
|
for (const std::string_view chunk: chunks) {
|
||||||
while (off < len) {
|
std::size_t off = 0;
|
||||||
ssize_t n = ::write(fd, data + off, len - off);
|
while (off < chunk.size()) {
|
||||||
if (n < 0) {
|
ssize_t n = ::write(fd, chunk.data() + off, chunk.size() - off);
|
||||||
if (errno == EINTR)
|
if (n < 0) {
|
||||||
continue;
|
if (errno == EINTR)
|
||||||
err = std::string("Write failed: ") + std::strerror(errno);
|
continue;
|
||||||
return false;
|
err = std::string("Write failed: ") + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
off += static_cast<std::size_t>(n);
|
||||||
}
|
}
|
||||||
off += static_cast<std::size_t>(n);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -136,9 +143,133 @@ best_effort_fsync_dir(const std::string &path)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The process umask, read once during static initialisation (before any
|
||||||
|
// thread exists): reading it later needs umask(0) + umask(old), which briefly
|
||||||
|
// changes it for the swap writer thread creating files concurrently.
|
||||||
|
static const mode_t g_process_umask = [] {
|
||||||
|
const mode_t m = ::umask(0);
|
||||||
|
(void) ::umask(m);
|
||||||
|
return m;
|
||||||
|
}();
|
||||||
|
|
||||||
|
|
||||||
|
// Rewrite `path` in place (truncate, write, fsync). Used for files with
|
||||||
|
// several hard links, which a temp-file-and-rename save would split.
|
||||||
static bool
|
static bool
|
||||||
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err)
|
write_in_place(const std::string &path, const ByteChunks &data, std::string &err)
|
||||||
{
|
{
|
||||||
|
int flags = O_WRONLY | O_TRUNC;
|
||||||
|
#ifdef O_CLOEXEC
|
||||||
|
flags |= O_CLOEXEC;
|
||||||
|
#endif
|
||||||
|
const int fd = kte::syscall::Open(path.c_str(), flags);
|
||||||
|
if (fd < 0) {
|
||||||
|
err = std::string("Failed to open file for writing: ") + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
struct stat before{};
|
||||||
|
const bool have_mode = ::fstat(fd, &before) == 0;
|
||||||
|
bool ok = write_all_fd(fd, data, err);
|
||||||
|
// Without privilege, writing clears setuid/setgid; put them back.
|
||||||
|
if (ok && have_mode)
|
||||||
|
(void) kte::syscall::Fchmod(fd, before.st_mode & 07777);
|
||||||
|
if (ok && kte::syscall::Fsync(fd) != 0) {
|
||||||
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Create a new file named from `tmpl` (a mkstemp template, which receives
|
||||||
|
// the chosen name) holding the content, fsynced.
|
||||||
|
static bool
|
||||||
|
write_new_file(std::string &tmpl, const ByteChunks &data, std::string &err, int &create_errno)
|
||||||
|
{
|
||||||
|
create_errno = 0;
|
||||||
|
std::vector<char> name(tmpl.begin(), tmpl.end());
|
||||||
|
name.push_back('\0');
|
||||||
|
const int fd = kte::syscall::Mkstemp(name.data());
|
||||||
|
if (fd < 0) {
|
||||||
|
create_errno = errno;
|
||||||
|
err = "Failed to create " + tmpl + ": " + std::strerror(create_errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tmpl.assign(name.data());
|
||||||
|
const std::string &path = tmpl;
|
||||||
|
bool ok = write_all_fd(fd, data, err);
|
||||||
|
if (ok && kte::syscall::Fsync(fd) != 0) {
|
||||||
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
|
if (!ok)
|
||||||
|
(void) ::unlink(path.c_str());
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whether a failure to create a file beside the target means only that the
|
||||||
|
// directory will not take a new name (not writable, name too long), so the
|
||||||
|
// target itself may still be rewritten in place. Anything else (ENOSPC,
|
||||||
|
// EDQUOT, EIO, ...) would most likely hit the in-place write too, after it
|
||||||
|
// had already truncated the file.
|
||||||
|
static bool
|
||||||
|
may_write_in_place_after(int create_errno)
|
||||||
|
{
|
||||||
|
return create_errno == EACCES || create_errno == EPERM || create_errno == ENAMETOOLONG;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
atomic_write_file(const std::string &path_in, const ByteChunks &data, std::string &err)
|
||||||
|
{
|
||||||
|
// Write through a symlink to its target: renaming over the link itself
|
||||||
|
// would replace the link with a regular file.
|
||||||
|
std::string path = path_in;
|
||||||
|
try {
|
||||||
|
if (std::filesystem::is_symlink(path_in))
|
||||||
|
path = std::filesystem::weakly_canonical(path_in).string();
|
||||||
|
} catch (...) {
|
||||||
|
// Fall back to the given path.
|
||||||
|
}
|
||||||
|
|
||||||
|
struct stat dst_st{};
|
||||||
|
const bool dst_exists = ::stat(path.c_str(), &dst_st) == 0;
|
||||||
|
// Never replace a directory, device node, FIFO or socket with a file.
|
||||||
|
if (dst_exists && !S_ISREG(dst_st.st_mode)) {
|
||||||
|
err = "Refusing to overwrite a non-regular file: " + path;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file with other hard links must be rewritten in place, or those
|
||||||
|
// names would keep the old content.
|
||||||
|
if (dst_exists && S_ISREG(dst_st.st_mode) && dst_st.st_nlink > 1) {
|
||||||
|
// Truncating in place risks the only copy if the write then fails
|
||||||
|
// (ENOSPC, EIO, crash), so first write and sync a copy beside it. If
|
||||||
|
// the directory will not take a new file (not writable, name too
|
||||||
|
// long), write in place anyway rather than refuse to save; any
|
||||||
|
// other failure (out of space, I/O error) refuses the save.
|
||||||
|
std::string copy = path + ".kte-save.XXXXXX";
|
||||||
|
std::string copy_err;
|
||||||
|
int copy_errno = 0;
|
||||||
|
const bool have_copy = write_new_file(copy, data, copy_err, copy_errno);
|
||||||
|
if (!have_copy && !may_write_in_place_after(copy_errno)) {
|
||||||
|
err = "Save failed, file left unchanged: " + copy_err;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!write_in_place(path, data, err)) {
|
||||||
|
if (have_copy)
|
||||||
|
err += " (the new content is in " + copy + ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (have_copy)
|
||||||
|
(void) ::unlink(copy.c_str());
|
||||||
|
best_effort_fsync_dir(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Create a temp file in the same directory so rename() is atomic.
|
// Create a temp file in the same directory so rename() is atomic.
|
||||||
std::filesystem::path p(path);
|
std::filesystem::path p(path);
|
||||||
std::filesystem::path dir = p.parent_path();
|
std::filesystem::path dir = p.parent_path();
|
||||||
@@ -161,31 +292,40 @@ atomic_write_file(const std::string &path, const char *data, std::size_t len, st
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) {
|
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) {
|
||||||
|
const int saved_errno = errno;
|
||||||
|
// No temp file in the directory because the directory is not
|
||||||
|
// writable (the file is): an existing regular file can still be
|
||||||
|
// written in place, as editors traditionally do. Not on ENOSPC and
|
||||||
|
// the like: the in-place write would truncate the file and then fail.
|
||||||
|
if (fd < 0 && dst_exists && S_ISREG(dst_st.st_mode) && may_write_in_place_after(saved_errno)) {
|
||||||
|
err.clear();
|
||||||
|
return write_in_place(path, data, err);
|
||||||
|
}
|
||||||
if (fd < 0) {
|
if (fd < 0) {
|
||||||
err = std::string("Failed to create temp file for save: ") + std::strerror(errno) + err;
|
err = std::string("Failed to create temp file for save: ") + std::strerror(saved_errno) + err;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::string tmp_path(buf.data());
|
std::string tmp_path(buf.data());
|
||||||
|
|
||||||
// If the destination exists, carry over its permissions.
|
bool ok = write_all_fd(fd, data, err);
|
||||||
struct stat dst_st{};
|
// Ownership and mode go on after the data: without privilege, both
|
||||||
if (::stat(path.c_str(), &dst_st) == 0) {
|
// chown and write clear setuid/setgid, so the mode is applied last.
|
||||||
(void) kte::syscall::Fchmod(fd, dst_st.st_mode);
|
if (ok && dst_exists) {
|
||||||
}
|
if (::fchown(fd, dst_st.st_uid, dst_st.st_gid) != 0) {
|
||||||
|
// Expected without privilege when the owner differs; keep ours.
|
||||||
bool ok = write_all_fd(fd, data, len, err);
|
|
||||||
if (ok) {
|
|
||||||
// Retry fsync on transient errors
|
|
||||||
auto fsync_fn = [&]() -> bool {
|
|
||||||
return kte::syscall::Fsync(fd) == 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::string fsync_err;
|
|
||||||
if (!kte::RetryOnTransientError(fsync_fn, kte::RetryPolicy::Aggressive(), fsync_err)) {
|
|
||||||
err = std::string("fsync failed: ") + std::strerror(errno) + fsync_err;
|
|
||||||
ok = false;
|
|
||||||
}
|
}
|
||||||
|
(void) kte::syscall::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);
|
(void) kte::syscall::Close(fd);
|
||||||
|
|
||||||
@@ -217,46 +357,31 @@ Buffer::Buffer(const Buffer &other)
|
|||||||
{
|
{
|
||||||
curx_ = other.curx_;
|
curx_ = other.curx_;
|
||||||
cury_ = other.cury_;
|
cury_ = other.cury_;
|
||||||
rx_ = other.rx_;
|
|
||||||
nrows_ = other.nrows_;
|
|
||||||
rowoffs_ = other.rowoffs_;
|
rowoffs_ = other.rowoffs_;
|
||||||
coloffs_ = other.coloffs_;
|
coloffs_ = other.coloffs_;
|
||||||
rows_ = other.rows_;
|
|
||||||
content_ = other.content_;
|
content_ = other.content_;
|
||||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
|
||||||
filename_ = other.filename_;
|
filename_ = other.filename_;
|
||||||
is_file_backed_ = other.is_file_backed_;
|
is_file_backed_ = other.is_file_backed_;
|
||||||
|
is_virtual_ = other.is_virtual_;
|
||||||
dirty_ = other.dirty_;
|
dirty_ = other.dirty_;
|
||||||
read_only_ = other.read_only_;
|
read_only_ = other.read_only_;
|
||||||
mark_set_ = other.mark_set_;
|
mark_set_ = other.mark_set_;
|
||||||
mark_curx_ = other.mark_curx_;
|
mark_curx_ = other.mark_curx_;
|
||||||
mark_cury_ = other.mark_cury_;
|
mark_cury_ = other.mark_cury_;
|
||||||
// Copy syntax/highlighting flags
|
// Copy edit mode + syntax/highlighting flags
|
||||||
version_ = other.version_;
|
edit_mode_ = other.edit_mode_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
filetype_ = other.filetype_;
|
version_ = other.version_;
|
||||||
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
|
syntax_user_override_ = other.syntax_user_override_;
|
||||||
|
filetype_ = other.filetype_;
|
||||||
// Fresh undo system for the copy
|
// Fresh undo system for the copy
|
||||||
undo_tree_ = std::make_unique<UndoTree>();
|
undo_tree_ = std::make_unique<UndoTree>();
|
||||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||||
|
|
||||||
// Recreate a highlighter engine for this copy based on filetype/syntax state
|
// Recreate a highlighter engine for this copy based on filetype/syntax state
|
||||||
if (syntax_enabled_) {
|
if (syntax_enabled_)
|
||||||
// Allocate engine and install an appropriate highlighter
|
InstallFiletypeHighlighter();
|
||||||
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
|
||||||
if (!filetype_.empty()) {
|
|
||||||
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
|
|
||||||
if (hl) {
|
|
||||||
highlighter_->SetHighlighter(std::move(hl));
|
|
||||||
} else {
|
|
||||||
// Unsupported filetype -> NullHighlighter keeps syntax pipeline active
|
|
||||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No filetype -> keep syntax enabled but use NullHighlighter
|
|
||||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
// Fresh engine has empty caches; nothing to invalidate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -267,42 +392,32 @@ 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_;
|
||||||
version_ = other.version_;
|
edit_mode_ = other.edit_mode_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
filetype_ = other.filetype_;
|
version_ = other.version_;
|
||||||
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
|
syntax_user_override_ = other.syntax_user_override_;
|
||||||
|
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>();
|
||||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||||
|
|
||||||
// Recreate highlighter engine consistent with syntax settings
|
// Recreate highlighter engine consistent with syntax settings
|
||||||
highlighter_.reset();
|
highlighter_.reset();
|
||||||
if (syntax_enabled_) {
|
if (syntax_enabled_)
|
||||||
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
InstallFiletypeHighlighter();
|
||||||
if (!filetype_.empty()) {
|
|
||||||
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
|
|
||||||
if (hl) {
|
|
||||||
highlighter_->SetHighlighter(std::move(hl));
|
|
||||||
} else {
|
|
||||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,28 +426,38 @@ Buffer::operator=(const Buffer &other)
|
|||||||
Buffer::Buffer(Buffer &&other) noexcept
|
Buffer::Buffer(Buffer &&other) noexcept
|
||||||
: curx_(other.curx_),
|
: curx_(other.curx_),
|
||||||
cury_(other.cury_),
|
cury_(other.cury_),
|
||||||
rx_(other.rx_),
|
|
||||||
nrows_(other.nrows_),
|
|
||||||
rowoffs_(other.rowoffs_),
|
rowoffs_(other.rowoffs_),
|
||||||
coloffs_(other.coloffs_),
|
coloffs_(other.coloffs_),
|
||||||
rows_(std::move(other.rows_)),
|
|
||||||
filename_(std::move(other.filename_)),
|
filename_(std::move(other.filename_)),
|
||||||
is_file_backed_(other.is_file_backed_),
|
is_file_backed_(other.is_file_backed_),
|
||||||
|
is_virtual_(other.is_virtual_),
|
||||||
|
id_(other.id_),
|
||||||
dirty_(other.dirty_),
|
dirty_(other.dirty_),
|
||||||
read_only_(other.read_only_),
|
read_only_(other.read_only_),
|
||||||
mark_set_(other.mark_set_),
|
mark_set_(other.mark_set_),
|
||||||
mark_curx_(other.mark_curx_),
|
mark_curx_(other.mark_curx_),
|
||||||
mark_cury_(other.mark_cury_),
|
mark_cury_(other.mark_cury_),
|
||||||
|
visual_line_active_(other.visual_line_active_),
|
||||||
|
visual_line_anchor_y_(other.visual_line_anchor_y_),
|
||||||
|
visual_line_active_y_(other.visual_line_active_y_),
|
||||||
undo_tree_(std::move(other.undo_tree_)),
|
undo_tree_(std::move(other.undo_tree_)),
|
||||||
undo_sys_(std::move(other.undo_sys_))
|
undo_sys_(std::move(other.undo_sys_))
|
||||||
{
|
{
|
||||||
// Move syntax/highlighting state
|
// Move edit mode + syntax/highlighting state
|
||||||
version_ = other.version_;
|
edit_mode_ = other.edit_mode_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
filetype_ = std::move(other.filetype_);
|
version_ = other.version_;
|
||||||
highlighter_ = std::move(other.highlighter_);
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
content_ = std::move(other.content_);
|
syntax_user_override_ = other.syntax_user_override_;
|
||||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
filetype_ = std::move(other.filetype_);
|
||||||
|
highlighter_ = std::move(other.highlighter_);
|
||||||
|
content_ = std::move(other.content_);
|
||||||
|
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);
|
||||||
@@ -347,30 +472,40 @@ Buffer::operator=(Buffer &&other) noexcept
|
|||||||
if (this == &other)
|
if (this == &other)
|
||||||
return *this;
|
return *this;
|
||||||
|
|
||||||
curx_ = other.curx_;
|
curx_ = other.curx_;
|
||||||
cury_ = other.cury_;
|
cury_ = other.cury_;
|
||||||
rx_ = other.rx_;
|
rowoffs_ = other.rowoffs_;
|
||||||
nrows_ = other.nrows_;
|
coloffs_ = other.coloffs_;
|
||||||
rowoffs_ = other.rowoffs_;
|
filename_ = std::move(other.filename_);
|
||||||
coloffs_ = other.coloffs_;
|
is_file_backed_ = other.is_file_backed_;
|
||||||
rows_ = std::move(other.rows_);
|
is_virtual_ = other.is_virtual_;
|
||||||
filename_ = std::move(other.filename_);
|
id_ = other.id_;
|
||||||
is_file_backed_ = other.is_file_backed_;
|
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_;
|
||||||
undo_tree_ = std::move(other.undo_tree_);
|
visual_line_anchor_y_ = other.visual_line_anchor_y_;
|
||||||
undo_sys_ = std::move(other.undo_sys_);
|
visual_line_active_y_ = other.visual_line_active_y_;
|
||||||
|
undo_tree_ = std::move(other.undo_tree_);
|
||||||
|
undo_sys_ = std::move(other.undo_sys_);
|
||||||
|
|
||||||
// Move syntax/highlighting state
|
// Move edit mode + syntax/highlighting state
|
||||||
version_ = other.version_;
|
edit_mode_ = other.edit_mode_;
|
||||||
syntax_enabled_ = other.syntax_enabled_;
|
edit_mode_detected_ = other.edit_mode_detected_;
|
||||||
filetype_ = std::move(other.filetype_);
|
version_ = other.version_;
|
||||||
highlighter_ = std::move(other.highlighter_);
|
syntax_enabled_ = other.syntax_enabled_;
|
||||||
content_ = std::move(other.content_);
|
syntax_user_override_ = other.syntax_user_override_;
|
||||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
filetype_ = std::move(other.filetype_);
|
||||||
|
highlighter_ = std::move(other.highlighter_);
|
||||||
|
content_ = std::move(other.content_);
|
||||||
|
on_disk_identity_ = other.on_disk_identity_;
|
||||||
|
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
||||||
|
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
||||||
|
// so the journal's Buffer* key follows this object to its new address.
|
||||||
|
swap_rec_ = other.swap_rec_;
|
||||||
|
other.swap_rec_ = nullptr;
|
||||||
// Update UndoSystem's buffer reference to point to this object
|
// Update UndoSystem's buffer reference to point to this object
|
||||||
if (undo_sys_) {
|
if (undo_sys_) {
|
||||||
undo_sys_->UpdateBufferReference(*this);
|
undo_sys_->UpdateBufferReference(*this);
|
||||||
@@ -409,77 +544,95 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|||||||
const std::string norm = normalize_path(path);
|
const std::string norm = normalize_path(path);
|
||||||
// If the file doesn't exist, initialize an empty, non-file-backed buffer
|
// If the file doesn't exist, initialize an empty, non-file-backed buffer
|
||||||
// with the provided filename. Do not touch the filesystem until Save/SaveAs.
|
// with the provided filename. Do not touch the filesystem until Save/SaveAs.
|
||||||
if (!std::filesystem::exists(norm)) {
|
std::error_code exists_ec;
|
||||||
rows_.clear();
|
const bool exists = std::filesystem::exists(norm, exists_ec);
|
||||||
nrows_ = 0;
|
if (exists_ec) {
|
||||||
|
// e.g. name too long, or a parent directory we may not search
|
||||||
|
err = "Cannot open " + norm + ": " + exists_ec.message();
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!exists) {
|
||||||
filename_ = norm;
|
filename_ = norm;
|
||||||
is_file_backed_ = false;
|
is_file_backed_ = false;
|
||||||
|
is_virtual_ = false;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
|
||||||
// Reset cursor/viewport state
|
// Reset cursor/viewport state
|
||||||
curx_ = cury_ = rx_ = 0;
|
curx_ = cury_ = 0;
|
||||||
rowoffs_ = coloffs_ = 0;
|
rowoffs_ = coloffs_ = 0;
|
||||||
mark_set_ = false;
|
mark_set_ = false;
|
||||||
mark_curx_ = mark_cury_ = 0;
|
mark_curx_ = mark_cury_ = 0;
|
||||||
|
|
||||||
// Empty PieceTable
|
// Empty PieceTable
|
||||||
content_.Clear();
|
content_.Clear();
|
||||||
rows_cache_dirty_ = true;
|
// History recorded against the previous content no longer applies.
|
||||||
|
if (undo_sys_)
|
||||||
|
undo_sys_->clear();
|
||||||
|
MarkContentChanged();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::ifstream in(norm, std::ios::in | std::ios::binary);
|
// Open non-blocking so a named pipe cannot hang the editor, then accept
|
||||||
if (!in) {
|
// regular files only: a directory reads as garbage (or throws), a FIFO
|
||||||
err = "Failed to open file: " + norm;
|
// blocks, and saving over a device node would replace it with a file.
|
||||||
|
int oflags = O_RDONLY | O_NONBLOCK;
|
||||||
|
#ifdef O_CLOEXEC
|
||||||
|
oflags |= O_CLOEXEC;
|
||||||
|
#endif
|
||||||
|
const int fd = kte::syscall::Open(norm.c_str(), oflags);
|
||||||
|
if (fd < 0) {
|
||||||
|
err = "Failed to open file: " + norm + ": " + std::strerror(errno);
|
||||||
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
struct stat st{};
|
||||||
|
if (kte::syscall::Fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
|
||||||
|
err = S_ISDIR(st.st_mode) ? ("Is a directory: " + norm) : ("Not a regular file: " + norm);
|
||||||
|
(void) kte::syscall::Close(fd);
|
||||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read entire file into PieceTable as-is
|
// Read to EOF in chunks (the file may change size while we read).
|
||||||
std::string data;
|
std::string data;
|
||||||
in.seekg(0, std::ios::end);
|
try {
|
||||||
if (!in) {
|
data.reserve(static_cast<std::size_t>(st.st_size));
|
||||||
err = "Failed to seek to end of file: " + norm;
|
char chunk[1 << 16];
|
||||||
|
for (;;) {
|
||||||
|
const ssize_t n = ::read(fd, chunk, sizeof(chunk));
|
||||||
|
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);
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
auto sz = in.tellg();
|
(void) kte::syscall::Close(fd);
|
||||||
if (sz < 0) {
|
// The bytes become the piece table's original storage, uncopied. If that
|
||||||
err = "Failed to get file size: " + norm;
|
// 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);
|
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (sz > 0) {
|
|
||||||
data.resize(static_cast<std::size_t>(sz));
|
|
||||||
in.seekg(0, std::ios::beg);
|
|
||||||
if (!in) {
|
|
||||||
err = "Failed to seek to beginning of file: " + norm;
|
|
||||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
in.read(data.data(), static_cast<std::streamsize>(data.size()));
|
|
||||||
if (!in && !in.eof()) {
|
|
||||||
err = "Failed to read file: " + norm;
|
|
||||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Validate we read the expected number of bytes
|
|
||||||
const std::streamsize bytes_read = in.gcount();
|
|
||||||
if (bytes_read != static_cast<std::streamsize>(data.size())) {
|
|
||||||
err = "Partial read of file (expected " + std::to_string(data.size()) +
|
|
||||||
" bytes, got " + std::to_string(bytes_read) + "): " + norm;
|
|
||||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content_.Clear();
|
|
||||||
if (!data.empty())
|
|
||||||
content_.Append(data.data(), data.size());
|
|
||||||
rows_cache_dirty_ = true;
|
|
||||||
nrows_ = 0; // not used under PieceTable
|
|
||||||
filename_ = norm;
|
filename_ = norm;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
|
is_virtual_ = false;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
RefreshOnDiskIdentity();
|
RefreshOnDiskIdentity();
|
||||||
|
|
||||||
@@ -490,9 +643,10 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|||||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||||
// Clear any existing history for a fresh load
|
// Clear any existing history for a fresh load
|
||||||
undo_sys_->clear();
|
undo_sys_->clear();
|
||||||
|
MarkContentChanged();
|
||||||
|
|
||||||
// Reset cursor/viewport state
|
// Reset cursor/viewport state
|
||||||
curx_ = cury_ = rx_ = 0;
|
curx_ = cury_ = 0;
|
||||||
rowoffs_ = coloffs_ = 0;
|
rowoffs_ = coloffs_ = 0;
|
||||||
mark_set_ = false;
|
mark_set_ = false;
|
||||||
mark_curx_ = mark_cury_ = 0;
|
mark_curx_ = mark_cury_ = 0;
|
||||||
@@ -508,13 +662,11 @@ Buffer::Save(std::string &err) const
|
|||||||
err = "Buffer is not file-backed; use SaveAs()";
|
err = "Buffer is not file-backed; use SaveAs()";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const std::size_t sz = content_.Size();
|
ByteChunks chunks;
|
||||||
const char *data = sz ? content_.Data() : nullptr;
|
content_.ForEachChunk([&](const char *d, std::size_t n) {
|
||||||
if (sz && !data) {
|
chunks.emplace_back(d, n);
|
||||||
err = "Internal error: buffer materialization failed";
|
});
|
||||||
return false;
|
if (!atomic_write_file(filename_, chunks, err)) {
|
||||||
}
|
|
||||||
if (!atomic_write_file(filename_, data ? data : "", sz, err)) {
|
|
||||||
kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
|
kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -548,19 +700,18 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
|||||||
out_path = path;
|
out_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::size_t sz = content_.Size();
|
ByteChunks chunks;
|
||||||
const char *data = sz ? content_.Data() : nullptr;
|
content_.ForEachChunk([&](const char *d, std::size_t n) {
|
||||||
if (sz && !data) {
|
chunks.emplace_back(d, n);
|
||||||
err = "Internal error: buffer materialization failed";
|
});
|
||||||
return false;
|
if (!atomic_write_file(out_path, chunks, err)) {
|
||||||
}
|
|
||||||
if (!atomic_write_file(out_path, data ? data : "", sz, err)) {
|
|
||||||
kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
|
kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
filename_ = out_path;
|
filename_ = out_path;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
|
is_virtual_ = false;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
RefreshOnDiskIdentity();
|
RefreshOnDiskIdentity();
|
||||||
return true;
|
return true;
|
||||||
@@ -592,43 +743,54 @@ Buffer::insert_text(int row, int col, std::string_view text)
|
|||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(col));
|
||||||
if (!text.empty()) {
|
if (!text.empty()) {
|
||||||
content_.Insert(off, text.data(), text.size());
|
content_.Insert(off, text.data(), text.size());
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_)
|
if (swap_rec_)
|
||||||
swap_rec_->OnInsert(row, col, text);
|
swap_rec_->OnInsert(row, col, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== Adapter helpers for PieceTable-backed Buffer =====
|
void
|
||||||
std::string_view
|
Buffer::ApplyDetectedFiletype()
|
||||||
Buffer::GetLineView(std::size_t row) const
|
|
||||||
{
|
{
|
||||||
// Get byte range for the logical line and return a view into materialized data
|
EnsureHighlighter();
|
||||||
auto range = content_.GetLineRange(row); // [start,end) in bytes
|
const std::string first = Nrows() > 0 ? GetLineString(0) : std::string();
|
||||||
const char *base = content_.Data(); // materializes if needed
|
const std::string ft = kte::HighlighterRegistry::DetectForPath(filename_, first);
|
||||||
if (!base)
|
SetFiletype(ft);
|
||||||
return std::string_view();
|
SetSyntaxEnabled(true);
|
||||||
const std::size_t start = range.first;
|
InstallFiletypeHighlighter();
|
||||||
const std::size_t len = (range.second > range.first) ? (range.second - range.first) : 0;
|
|
||||||
return std::string_view(base + start, len);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
Buffer::ensure_rows_cache() const
|
Buffer::InstallFiletypeHighlighter()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(buffer_mutex_);
|
EnsureHighlighter();
|
||||||
if (!rows_cache_dirty_)
|
auto hl = filetype_.empty() ? nullptr : kte::HighlighterRegistry::CreateFor(filetype_);
|
||||||
return;
|
if (!hl)
|
||||||
rows_.clear();
|
hl = std::make_unique<kte::NullHighlighter>();
|
||||||
const std::size_t lc = content_.LineCount();
|
highlighter_->SetHighlighter(std::move(hl));
|
||||||
rows_.reserve(lc);
|
}
|
||||||
for (std::size_t i = 0; i < lc; ++i) {
|
|
||||||
rows_.emplace_back(content_.GetLine(i));
|
|
||||||
}
|
// ===== Adapter helpers for PieceTable-backed Buffer =====
|
||||||
// Keep nrows_ in sync for any legacy code that still reads it
|
std::string
|
||||||
const_cast<Buffer *>(this)->nrows_ = rows_.size();
|
Buffer::Bytes() const
|
||||||
rows_cache_dirty_ = false;
|
{
|
||||||
|
std::string out;
|
||||||
|
out.reserve(content_.Size());
|
||||||
|
content_.ForEachChunk([&](const char *d, std::size_t n) {
|
||||||
|
out.append(d, n);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string_view
|
||||||
|
Buffer::GetLineView(std::size_t row) const
|
||||||
|
{
|
||||||
|
const auto [start, end] = content_.GetLineRange(row); // [start,end) in bytes
|
||||||
|
return end > start ? content_.View(start, end - start) : std::string_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -643,13 +805,17 @@ Buffer::content_LineCount_() const
|
|||||||
std::string
|
std::string
|
||||||
Buffer::BytesForTests() const
|
Buffer::BytesForTests() const
|
||||||
{
|
{
|
||||||
const std::size_t sz = content_.Size();
|
return Bytes();
|
||||||
if (sz == 0)
|
}
|
||||||
return std::string();
|
|
||||||
const char *data = content_.Data();
|
|
||||||
if (!data)
|
std::vector<std::string>
|
||||||
return std::string();
|
Buffer::LinesForTests() const
|
||||||
return std::string(data, data + sz);
|
{
|
||||||
|
std::vector<std::string> out;
|
||||||
|
for (std::size_t i = 0; i < Nrows(); ++i)
|
||||||
|
out.push_back(GetLineString(i));
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -664,51 +830,18 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
if (col < 0)
|
if (col < 0)
|
||||||
col = 0;
|
col = 0;
|
||||||
|
|
||||||
|
// `len` counts each newline as one character, so the deletion is simply
|
||||||
|
// the next `len` bytes from the (clamped) start, capped at end of buffer.
|
||||||
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(col));
|
||||||
std::size_t r = static_cast<std::size_t>(row);
|
const std::size_t total = content_.Size();
|
||||||
std::size_t c = static_cast<std::size_t>(col);
|
const std::size_t actual = (total > start) ? std::min(len, total - start) : 0;
|
||||||
std::size_t remaining = len;
|
if (actual == 0)
|
||||||
const std::size_t lc = content_.LineCount();
|
return;
|
||||||
|
content_.Delete(start, actual);
|
||||||
while (remaining > 0 && r < lc) {
|
edited_at_(row);
|
||||||
const std::string line = content_.GetLine(r); // logical line (without trailing '\n')
|
if (swap_rec_)
|
||||||
const std::size_t L = line.size();
|
swap_rec_->OnDelete(row, col, actual);
|
||||||
if (c < L) {
|
|
||||||
const std::size_t take = std::min(remaining, L - c);
|
|
||||||
c += take;
|
|
||||||
remaining -= take;
|
|
||||||
}
|
|
||||||
if (remaining == 0)
|
|
||||||
break;
|
|
||||||
// Consume newline between lines as one char, if there is a next line
|
|
||||||
if (r + 1 < lc) {
|
|
||||||
remaining -= 1; // the newline
|
|
||||||
r += 1;
|
|
||||||
c = 0;
|
|
||||||
} else {
|
|
||||||
// At last line and still remaining: delete to EOF
|
|
||||||
const std::size_t total = content_.Size();
|
|
||||||
const std::size_t actual = (total > start) ? (total - start) : 0;
|
|
||||||
if (actual == 0)
|
|
||||||
return;
|
|
||||||
content_.Delete(start, actual);
|
|
||||||
rows_cache_dirty_ = true;
|
|
||||||
if (swap_rec_)
|
|
||||||
swap_rec_->OnDelete(row, col, actual);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute end offset at (r,c)
|
|
||||||
std::size_t end = content_.LineColToByteOffset(r, c);
|
|
||||||
if (end > start) {
|
|
||||||
const std::size_t actual = end - start;
|
|
||||||
content_.Delete(start, actual);
|
|
||||||
rows_cache_dirty_ = true;
|
|
||||||
if (swap_rec_)
|
|
||||||
swap_rec_->OnDelete(row, col, actual);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -724,7 +857,7 @@ Buffer::split_line(int row, const int col)
|
|||||||
static_cast<std::size_t>(c));
|
static_cast<std::size_t>(c));
|
||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off, &nl, 1);
|
content_.Insert(off, &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_)
|
if (swap_rec_)
|
||||||
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
|
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
|
||||||
}
|
}
|
||||||
@@ -743,7 +876,7 @@ Buffer::join_lines(int row)
|
|||||||
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
||||||
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
||||||
content_.Delete(end_of_line, 1);
|
content_.Delete(end_of_line, 1);
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_)
|
if (swap_rec_)
|
||||||
swap_rec_->OnDelete(row, col, 1);
|
swap_rec_->OnDelete(row, col, 1);
|
||||||
}
|
}
|
||||||
@@ -759,12 +892,16 @@ Buffer::insert_row(int row, const std::string_view text)
|
|||||||
content_.Insert(off, text.data(), text.size());
|
content_.Insert(off, text.data(), text.size());
|
||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off + text.size(), &nl, 1);
|
content_.Insert(off + text.size(), &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_) {
|
if (swap_rec_) {
|
||||||
// Avoid allocation: emit the row text insertion (if any) and the newline insertion.
|
// One record: the first of two could trigger a journal checkpoint,
|
||||||
if (!text.empty())
|
// whose snapshot already holds the newline the second then added
|
||||||
swap_rec_->OnInsert(row, 0, text);
|
// again on replay.
|
||||||
swap_rec_->OnInsert(row, static_cast<int>(text.size()), std::string_view("\n", 1));
|
std::string line;
|
||||||
|
line.reserve(text.size() + 1);
|
||||||
|
line.append(text);
|
||||||
|
line.push_back('\n');
|
||||||
|
swap_rec_->OnInsert(row, 0, line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,7 +923,7 @@ Buffer::delete_row(int row)
|
|||||||
if (actual == 0)
|
if (actual == 0)
|
||||||
return;
|
return;
|
||||||
content_.Delete(start, actual);
|
content_.Delete(start, actual);
|
||||||
rows_cache_dirty_ = true;
|
edited_at_(row);
|
||||||
if (swap_rec_)
|
if (swap_rec_)
|
||||||
swap_rec_->OnDelete(row, 0, actual);
|
swap_rec_->OnDelete(row, 0, actual);
|
||||||
}
|
}
|
||||||
@@ -798,7 +935,50 @@ Buffer::replace_all_bytes(const std::string_view bytes)
|
|||||||
content_.Clear();
|
content_.Clear();
|
||||||
if (!bytes.empty())
|
if (!bytes.empty())
|
||||||
content_.Append(bytes.data(), bytes.size());
|
content_.Append(bytes.data(), bytes.size());
|
||||||
rows_cache_dirty_ = true;
|
if (undo_sys_)
|
||||||
|
undo_sys_->clear();
|
||||||
|
MarkContentChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Buffer::insert_spans(int row, int col, const std::vector<TextSpan> &spans)
|
||||||
|
{
|
||||||
|
if (row < 0)
|
||||||
|
row = 0;
|
||||||
|
if (col < 0)
|
||||||
|
col = 0;
|
||||||
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
|
static_cast<std::size_t>(col));
|
||||||
|
content_.InsertSpans(off, spans);
|
||||||
|
edited_at_(row);
|
||||||
|
if (swap_rec_) {
|
||||||
|
// The journal needs the bytes as one record (see insert_row).
|
||||||
|
std::string text;
|
||||||
|
content_.VisitSpans(spans, [&](const char *d, std::size_t n) {
|
||||||
|
text.append(d, n);
|
||||||
|
});
|
||||||
|
if (!text.empty())
|
||||||
|
swap_rec_->OnInsert(row, col, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<TextSpan>
|
||||||
|
Buffer::SpansAt(int row, int col, std::size_t len) const
|
||||||
|
{
|
||||||
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(std::max(row, 0)),
|
||||||
|
static_cast<std::size_t>(std::max(col, 0)));
|
||||||
|
return content_.SpansInRange(off, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<TextSpan>
|
||||||
|
Buffer::TakeDeletedSpans(int row, int col, std::size_t len)
|
||||||
|
{
|
||||||
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(std::max(row, 0)),
|
||||||
|
static_cast<std::size_t>(std::max(col, 0)));
|
||||||
|
return content_.TakeDeletedSpans(off, len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* or scratch document and manages:
|
* or scratch document and manages:
|
||||||
*
|
*
|
||||||
* - Content storage: Uses PieceTable for efficient text operations
|
* - Content storage: Uses PieceTable for efficient text operations
|
||||||
* - Cursor state: Current position (curx_, cury_), rendered column (rx_)
|
* - Cursor state: Current position (curx_, cury_)
|
||||||
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
|
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
|
||||||
* - File backing: Optional association with a file on disk
|
* - File backing: Optional association with a file on disk
|
||||||
* - Undo/Redo: Integrated UndoSystem for operation history
|
* - Undo/Redo: Integrated UndoSystem for operation history
|
||||||
@@ -17,27 +17,25 @@
|
|||||||
*
|
*
|
||||||
* 1. Cursor coordinates:
|
* 1. Cursor coordinates:
|
||||||
* - (curx_, cury_): Logical character position in the document
|
* - (curx_, cury_): Logical character position in the document
|
||||||
* - rx_: Rendered column accounting for tab expansion
|
|
||||||
*
|
*
|
||||||
* 2. File backing:
|
* 2. File backing:
|
||||||
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
|
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
|
||||||
* - File identity tracking detects external modifications
|
* - File identity tracking detects external modifications
|
||||||
*
|
*
|
||||||
* 3. Legacy Line wrapper:
|
* 3. Content access:
|
||||||
* - Buffer::Line provides a string-like interface for legacy command code
|
* - Nrows(), GetLineString(): per-line access
|
||||||
* - New code should prefer direct PieceTable operations
|
* - GetLineView(): line access via string_view (zero-copy within a piece)
|
||||||
* - See DEVELOPER_GUIDE.md for migration guidance
|
|
||||||
*
|
|
||||||
* 4. Content access:
|
|
||||||
* - Rows(): Materialized line cache (legacy, being phased out)
|
|
||||||
* - GetLineView(): Zero-copy line access via string_view (preferred)
|
|
||||||
* - Direct PieceTable access for new editing operations
|
* - Direct PieceTable access for new editing operations
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <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>
|
||||||
|
|
||||||
@@ -46,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 {
|
||||||
@@ -95,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_();
|
||||||
@@ -119,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);
|
||||||
@@ -294,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
|
||||||
{
|
{
|
||||||
@@ -311,6 +175,7 @@ public:
|
|||||||
{
|
{
|
||||||
filename_ = name;
|
filename_ = name;
|
||||||
is_file_backed_ = false;
|
is_file_backed_ = false;
|
||||||
|
is_virtual_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -320,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_;
|
||||||
@@ -352,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;
|
||||||
@@ -367,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -446,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -477,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
|
||||||
{
|
{
|
||||||
@@ -484,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;
|
||||||
@@ -496,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;
|
||||||
@@ -527,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)
|
||||||
{
|
{
|
||||||
@@ -554,11 +507,55 @@ public:
|
|||||||
|
|
||||||
void delete_row(int row);
|
void delete_row(int row);
|
||||||
|
|
||||||
|
// Insert the bytes of storage spans (see TextSpan.h) at (row, col)
|
||||||
|
// without copying them into storage again. Used by undo for large texts.
|
||||||
|
void insert_spans(int row, int col, const std::vector<TextSpan> &spans);
|
||||||
|
|
||||||
// Replace the entire buffer content with raw bytes.
|
// Replace the entire buffer content with raw bytes.
|
||||||
// Intended for crash recovery (swap replay) and test harnesses.
|
// Intended for crash recovery (swap replay) and test harnesses.
|
||||||
// This does not trigger swap or undo recording.
|
// This does not trigger swap or undo recording, and clears undo history
|
||||||
|
// (recorded against the previous content, which it also references).
|
||||||
void replace_all_bytes(std::string_view bytes);
|
void replace_all_bytes(std::string_view bytes);
|
||||||
|
|
||||||
|
// ===== Span access for undo (see TextSpan.h) =====
|
||||||
|
[[nodiscard]] std::vector<TextSpan> SpansAt(int row, int col, std::size_t len) const;
|
||||||
|
|
||||||
|
// Spans removed by the most recent deletion, if it was exactly `len`
|
||||||
|
// bytes at (row, col) (see PieceTable::TakeDeletedSpans).
|
||||||
|
[[nodiscard]] std::vector<TextSpan> TakeDeletedSpans(int row, int col, std::size_t len);
|
||||||
|
|
||||||
|
[[nodiscard]] bool SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const
|
||||||
|
{
|
||||||
|
return content_.SpansEqual(spans, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
template<typename Fn>
|
||||||
|
void VisitSpans(const std::vector<TextSpan> &spans, Fn &&fn) const
|
||||||
|
{
|
||||||
|
content_.VisitSpans(spans, std::forward<Fn>(fn));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Total size of the content in bytes.
|
||||||
|
[[nodiscard]] std::size_t ContentBytes() const
|
||||||
|
{
|
||||||
|
return content_.Size();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Byte offset of (row, col) in the content (clamped), and back.
|
||||||
|
[[nodiscard]] std::size_t RowColToOffset(std::size_t row, std::size_t col) const
|
||||||
|
{
|
||||||
|
return content_.LineColToByteOffset(row, col);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::pair<std::size_t, std::size_t> OffsetToRowCol(std::size_t off) const
|
||||||
|
{
|
||||||
|
return content_.ByteOffsetToLineCol(off);
|
||||||
|
}
|
||||||
|
|
||||||
// Undo system accessors (created per-buffer)
|
// Undo system accessors (created per-buffer)
|
||||||
[[nodiscard]] UndoSystem *Undo();
|
[[nodiscard]] UndoSystem *Undo();
|
||||||
|
|
||||||
@@ -566,6 +563,10 @@ public:
|
|||||||
|
|
||||||
#if defined(KTE_TESTS)
|
#if defined(KTE_TESTS)
|
||||||
// Test-only: return the raw buffer bytes (including newlines) as a string.
|
// Test-only: return the raw buffer bytes (including newlines) as a string.
|
||||||
|
|
||||||
|
// Every line without its newline (tests only).
|
||||||
|
[[nodiscard]] std::vector<std::string> LinesForTests() const;
|
||||||
|
|
||||||
[[nodiscard]] std::string BytesForTests() const;
|
[[nodiscard]] std::string BytesForTests() const;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -586,22 +587,23 @@ private:
|
|||||||
|
|
||||||
// State mirroring original C struct (without undo_tree)
|
// State mirroring original C struct (without undo_tree)
|
||||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
|
||||||
std::size_t nrows_ = 0; // number of rows
|
|
||||||
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
|
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
|
||||||
mutable std::vector<Line> rows_; // materialized cache of rows (without trailing newlines)
|
|
||||||
// PieceTable is the source of truth.
|
// PieceTable is the source of truth.
|
||||||
PieceTable content_{};
|
PieceTable content_{};
|
||||||
mutable bool rows_cache_dirty_ = true; // invalidate on edits / I/O
|
|
||||||
|
|
||||||
// Helper to rebuild rows_ from content_
|
|
||||||
void ensure_rows_cache() const;
|
|
||||||
|
|
||||||
// Helper to query content_.LineCount() while keeping header minimal
|
// Helper to query content_.LineCount() while keeping header minimal
|
||||||
std::size_t content_LineCount_() const;
|
std::size_t content_LineCount_() const;
|
||||||
|
|
||||||
std::string filename_;
|
std::string filename_;
|
||||||
bool is_file_backed_ = false;
|
bool is_file_backed_ = false;
|
||||||
|
bool is_virtual_ = false;
|
||||||
|
std::uint64_t id_ = NextBufferId();
|
||||||
|
|
||||||
|
static std::uint64_t NextBufferId()
|
||||||
|
{
|
||||||
|
static std::atomic<std::uint64_t> next{1};
|
||||||
|
return next.fetch_add(1);
|
||||||
|
}
|
||||||
bool dirty_ = false;
|
bool dirty_ = false;
|
||||||
bool read_only_ = false;
|
bool read_only_ = false;
|
||||||
bool mark_set_ = false;
|
bool mark_set_ = false;
|
||||||
@@ -614,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_;
|
|
||||||
};
|
};
|
||||||
+114
-55
@@ -4,16 +4,17 @@ project(kte)
|
|||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(KTE_VERSION "1.7.1")
|
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 ()
|
||||||
@@ -50,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 ()
|
||||||
@@ -62,6 +66,26 @@ endif ()
|
|||||||
|
|
||||||
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
||||||
|
|
||||||
|
# Regex engine (see RegexEngine.h). Linked by name (-lpcre2-8) so that a
|
||||||
|
# static build picks up the static library.
|
||||||
|
if (KTE_USE_PCRE2)
|
||||||
|
find_package(PkgConfig QUIET)
|
||||||
|
if (PKG_CONFIG_FOUND)
|
||||||
|
pkg_check_modules(PCRE2 QUIET libpcre2-8)
|
||||||
|
endif ()
|
||||||
|
if (PCRE2_FOUND)
|
||||||
|
message(STATUS "Regex engine: PCRE2 ${PCRE2_VERSION}")
|
||||||
|
add_compile_definitions(KTE_USE_PCRE2)
|
||||||
|
include_directories(${PCRE2_INCLUDE_DIRS})
|
||||||
|
link_directories(${PCRE2_LIBRARY_DIRS})
|
||||||
|
link_libraries(${PCRE2_LIBRARIES})
|
||||||
|
else ()
|
||||||
|
message(STATUS "Regex engine: std::regex (libpcre2-8 not found)")
|
||||||
|
endif ()
|
||||||
|
else ()
|
||||||
|
message(STATUS "Regex engine: std::regex")
|
||||||
|
endif ()
|
||||||
|
|
||||||
if (BUILD_GUI)
|
if (BUILD_GUI)
|
||||||
include(cmake/imgui.cmake)
|
include(cmake/imgui.cmake)
|
||||||
endif ()
|
endif ()
|
||||||
@@ -69,8 +93,34 @@ endif ()
|
|||||||
# NCurses for terminal mode
|
# NCurses for terminal mode
|
||||||
set(CURSES_NEED_NCURSES TRUE)
|
set(CURSES_NEED_NCURSES TRUE)
|
||||||
set(CURSES_NEED_WIDE TRUE)
|
set(CURSES_NEED_WIDE TRUE)
|
||||||
|
|
||||||
|
# macOS ships only a narrow-char ncurses in the SDK, and Homebrew's wide-char
|
||||||
|
# build is keg-only, so FindCurses can't see it without a hint.
|
||||||
|
if (APPLE)
|
||||||
|
find_program(BREW_EXECUTABLE brew)
|
||||||
|
if (BREW_EXECUTABLE)
|
||||||
|
execute_process(COMMAND "${BREW_EXECUTABLE}" --prefix ncurses
|
||||||
|
OUTPUT_VARIABLE HOMEBREW_NCURSES_PREFIX
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||||
|
ERROR_QUIET)
|
||||||
|
if (HOMEBREW_NCURSES_PREFIX AND IS_DIRECTORY "${HOMEBREW_NCURSES_PREFIX}")
|
||||||
|
message(STATUS "Using Homebrew ncursesw: ${HOMEBREW_NCURSES_PREFIX}")
|
||||||
|
list(PREPEND CMAKE_PREFIX_PATH "${HOMEBREW_NCURSES_PREFIX}")
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
find_package(Curses REQUIRED)
|
find_package(Curses REQUIRED)
|
||||||
include_directories(${CURSES_INCLUDE_DIR})
|
|
||||||
|
# FindCurses exports CURSES_INCLUDE_DIRS; the singular CURSES_INCLUDE_DIR was
|
||||||
|
# never set, so this silently expanded to nothing and the compiler fell back
|
||||||
|
# to whatever curses header happened to be on the default include path.
|
||||||
|
include_directories(${CURSES_INCLUDE_DIRS})
|
||||||
|
|
||||||
|
# The wide-char entry points we rely on (get_wch, etc.) are declared only when
|
||||||
|
# NCURSES_WIDECHAR is set; the header defaults it to 0 unless a feature-test
|
||||||
|
# macro says otherwise. We always link the wide library, so ask for them.
|
||||||
|
add_compile_definitions(NCURSES_WIDECHAR=1)
|
||||||
|
|
||||||
# On Alpine Linux, CMake's FindCurses looks in wrong paths
|
# On Alpine Linux, CMake's FindCurses looks in wrong paths
|
||||||
# Manually find the correct ncurses library
|
# Manually find the correct ncurses library
|
||||||
@@ -112,25 +162,13 @@ if (BUILD_GUI)
|
|||||||
set(GUI_SOURCES
|
set(GUI_SOURCES
|
||||||
GUIConfig.cc
|
GUIConfig.cc
|
||||||
)
|
)
|
||||||
if (KTE_USE_QT)
|
set(GUI_SOURCES
|
||||||
find_package(Qt6 COMPONENTS Widgets REQUIRED)
|
${GUI_SOURCES}
|
||||||
set(GUI_SOURCES
|
${FONT_SOURCES}
|
||||||
${GUI_SOURCES}
|
ImGuiFrontend.cc
|
||||||
QtFrontend.cc
|
ImGuiInputHandler.cc
|
||||||
QtInputHandler.cc
|
ImGuiRenderer.cc
|
||||||
QtRenderer.cc
|
)
|
||||||
)
|
|
||||||
# Expose preprocessor switch so sources can exclude ImGui-specific code
|
|
||||||
add_compile_definitions(KTE_USE_QT)
|
|
||||||
else ()
|
|
||||||
set(GUI_SOURCES
|
|
||||||
${GUI_SOURCES}
|
|
||||||
${FONT_SOURCES}
|
|
||||||
ImGuiFrontend.cc
|
|
||||||
ImGuiInputHandler.cc
|
|
||||||
ImGuiRenderer.cc
|
|
||||||
)
|
|
||||||
endif ()
|
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
set(COMMON_SOURCES
|
set(COMMON_SOURCES
|
||||||
@@ -144,6 +182,9 @@ set(COMMON_SOURCES
|
|||||||
ErrorHandler.cc
|
ErrorHandler.cc
|
||||||
SyscallWrappers.cc
|
SyscallWrappers.cc
|
||||||
ErrorRecovery.cc
|
ErrorRecovery.cc
|
||||||
|
RegexGuard.cc
|
||||||
|
RegexEngine.cc
|
||||||
|
SearchHighlight.cc
|
||||||
TerminalInputHandler.cc
|
TerminalInputHandler.cc
|
||||||
TerminalRenderer.cc
|
TerminalRenderer.cc
|
||||||
TerminalFrontend.cc
|
TerminalFrontend.cc
|
||||||
@@ -204,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
|
||||||
@@ -215,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
|
||||||
@@ -238,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
|
||||||
@@ -251,24 +298,15 @@ if (BUILD_GUI)
|
|||||||
GUIConfig.h
|
GUIConfig.h
|
||||||
)
|
)
|
||||||
|
|
||||||
if (KTE_USE_QT)
|
set(GUI_HEADERS
|
||||||
set(GUI_HEADERS
|
${GUI_HEADERS}
|
||||||
${GUI_HEADERS}
|
${THEME_HEADERS}
|
||||||
QtFrontend.h
|
${FONT_HEADERS}
|
||||||
QtInputHandler.h
|
ImGuiFrontend.h
|
||||||
QtRenderer.h
|
ImGuiInputHandler.h
|
||||||
)
|
ImGuiRenderer.h
|
||||||
else ()
|
fonts/BerkeleyMono.h
|
||||||
set(GUI_HEADERS
|
)
|
||||||
${GUI_HEADERS}
|
|
||||||
${THEME_HEADERS}
|
|
||||||
${FONT_HEADERS}
|
|
||||||
ImGuiFrontend.h
|
|
||||||
ImGuiInputHandler.h
|
|
||||||
ImGuiRenderer.h
|
|
||||||
fonts/BerkeleyMono.h
|
|
||||||
)
|
|
||||||
endif ()
|
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
# kte (terminal-first) executable
|
# kte (terminal-first) executable
|
||||||
@@ -285,7 +323,7 @@ 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)
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
if (NOT APPLE)
|
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||||
target_link_options(kte PRIVATE -static)
|
target_link_options(kte PRIVATE -static)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
@@ -326,9 +364,9 @@ if (BUILD_TESTS)
|
|||||||
tests/test_swap_edge_cases.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_swap_git_editor.cc
|
||||||
tests/test_piece_table.cc
|
tests/test_piece_table.cc
|
||||||
tests/test_search.cc
|
|
||||||
tests/test_search_replace_flow.cc
|
tests/test_search_replace_flow.cc
|
||||||
tests/test_reflow_paragraph.cc
|
tests/test_reflow_paragraph.cc
|
||||||
tests/test_reflow_indented_bullets.cc
|
tests/test_reflow_indented_bullets.cc
|
||||||
@@ -338,6 +376,9 @@ if (BUILD_TESTS)
|
|||||||
tests/test_migration_coverage.cc
|
tests/test_migration_coverage.cc
|
||||||
tests/test_smart_newline.cc
|
tests/test_smart_newline.cc
|
||||||
tests/test_reflow_undo.cc
|
tests/test_reflow_undo.cc
|
||||||
|
tests/test_syntax_highlighting.cc
|
||||||
|
tests/test_paste_split.cc
|
||||||
|
tests/test_audit_regressions.cc
|
||||||
|
|
||||||
# minimal engine sources required by Buffer
|
# minimal engine sources required by Buffer
|
||||||
PieceTable.cc
|
PieceTable.cc
|
||||||
@@ -349,9 +390,11 @@ if (BUILD_TESTS)
|
|||||||
ErrorHandler.cc
|
ErrorHandler.cc
|
||||||
SyscallWrappers.cc
|
SyscallWrappers.cc
|
||||||
ErrorRecovery.cc
|
ErrorRecovery.cc
|
||||||
|
RegexGuard.cc
|
||||||
|
RegexEngine.cc
|
||||||
|
SearchHighlight.cc
|
||||||
KKeymap.cc
|
KKeymap.cc
|
||||||
SwapRecorder.h
|
SwapRecorder.h
|
||||||
OptimizedSearch.cc
|
|
||||||
UndoNode.cc
|
UndoNode.cc
|
||||||
UndoTree.cc
|
UndoTree.cc
|
||||||
UndoSystem.cc
|
UndoSystem.cc
|
||||||
@@ -375,9 +418,12 @@ if (BUILD_TESTS)
|
|||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
if (NOT APPLE)
|
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||||
target_link_options(kte_tests PRIVATE -static)
|
target_link_options(kte_tests PRIVATE -static)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
|
enable_testing()
|
||||||
|
add_test(NAME kte_tests COMMAND kte_tests)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
if (BUILD_GUI)
|
if (BUILD_GUI)
|
||||||
@@ -405,20 +451,13 @@ 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} imgui)
|
||||||
target_link_libraries(kge ${CURSES_LIBRARIES} Qt6::Widgets)
|
|
||||||
else ()
|
|
||||||
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
|
||||||
endif ()
|
|
||||||
|
|
||||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
if (NOT APPLE)
|
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||||
target_link_options(kge PRIVATE -static)
|
target_link_options(kge PRIVATE -static)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
@@ -475,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)
|
||||||
@@ -486,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).
|
||||||
+1722
-1087
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)
|
||||||
@@ -111,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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ RUN apk add --no-cache \
|
|||||||
cmake \
|
cmake \
|
||||||
make \
|
make \
|
||||||
ncurses-dev \
|
ncurses-dev \
|
||||||
|
pcre2-dev \
|
||||||
|
pkgconf \
|
||||||
sdl2-dev \
|
sdl2-dev \
|
||||||
mesa-dev \
|
mesa-dev \
|
||||||
freetype-dev \
|
freetype-dev \
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "ErrorHandler.h"
|
||||||
#include "syntax/CppHighlighter.h"
|
#include "syntax/CppHighlighter.h"
|
||||||
#include "syntax/NullHighlighter.h"
|
|
||||||
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
static std::string
|
// Universal-argument repeat counts are capped here (well below INT_MAX).
|
||||||
buffer_bytes_via_views(const Buffer &b)
|
constexpr int kMaxUArgCount = 1000000;
|
||||||
{
|
} // namespace
|
||||||
const std::size_t nrows = b.Nrows();
|
|
||||||
std::string out;
|
|
||||||
for (std::size_t i = 0; i < nrows; i++) {
|
|
||||||
auto v = b.GetLineView(i);
|
|
||||||
out.append(v.data(), v.size());
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
static void
|
static void
|
||||||
apply_pending_line(Editor &ed, const std::size_t line1)
|
apply_pending_line(Editor &ed, const std::size_t line1)
|
||||||
{
|
{
|
||||||
@@ -44,6 +37,46 @@ apply_pending_line(Editor &ed, const std::size_t line1)
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Same normalization as Buffer::OpenFromFile (expand "~", canonical path if
|
||||||
|
// the file exists, else absolute), so a path can be compared with the
|
||||||
|
// Filename() of open buffers.
|
||||||
|
std::string
|
||||||
|
normalize_open_path(const std::string &in)
|
||||||
|
{
|
||||||
|
std::string expanded = in;
|
||||||
|
if (!expanded.empty() && expanded[0] == '~') {
|
||||||
|
const char *home = std::getenv("HOME");
|
||||||
|
if (home && expanded.size() >= 2 && (expanded[1] == '/' || expanded[1] == '\\'))
|
||||||
|
expanded = std::string(home) + expanded.substr(1);
|
||||||
|
else if (home && expanded.size() == 1)
|
||||||
|
expanded = std::string(home);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(expanded);
|
||||||
|
if (std::filesystem::exists(p))
|
||||||
|
return std::filesystem::canonical(p).string();
|
||||||
|
return std::filesystem::absolute(p).string();
|
||||||
|
} catch (...) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
Editor::FindOpenBuffer(const std::string &path) const
|
||||||
|
{
|
||||||
|
const std::string norm = normalize_open_path(path);
|
||||||
|
const auto &bufs = Buffers();
|
||||||
|
for (std::size_t i = 0; i < bufs.size(); ++i) {
|
||||||
|
if (!bufs[i].Filename().empty() && bufs[i].Filename() == norm)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return static_cast<std::size_t>(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Editor::Editor()
|
Editor::Editor()
|
||||||
{
|
{
|
||||||
swap_ = std::make_unique<kte::SwapManager>();
|
swap_ = std::make_unique<kte::SwapManager>();
|
||||||
@@ -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,41 +197,77 @@ 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));
|
||||||
|
}
|
||||||
|
Swap()->Attach(&bufs.back());
|
||||||
|
bufs.back().SetSwapRecorder(Swap()->RecorderFor(&bufs.back()));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
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));
|
||||||
|
}
|
||||||
|
Swap()->Attach(&bufs.back());
|
||||||
|
bufs.back().SetSwapRecorder(Swap()->RecorderFor(&bufs.back()));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
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 std::size_t nrows = cur.Nrows();
|
const std::size_t nrows = cur.Nrows();
|
||||||
@@ -206,32 +278,13 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
if (!ok)
|
if (!ok)
|
||||||
return false;
|
return false;
|
||||||
// Ensure swap recorder is attached for this buffer
|
// Ensure swap recorder is attached for this buffer
|
||||||
if (swap_) {
|
if (Swap()) {
|
||||||
swap_->Attach(&cur);
|
Swap()->Attach(&cur);
|
||||||
cur.SetSwapRecorder(swap_->RecorderFor(&cur));
|
cur.SetSwapRecorder(Swap()->RecorderFor(&cur));
|
||||||
swap_->NotifyFilenameChanged(cur);
|
Swap()->NotifyFilenameChanged(cur);
|
||||||
}
|
|
||||||
// Setup highlighting using registry (extension + shebang)
|
|
||||||
cur.EnsureHighlighter();
|
|
||||||
std::string first = "";
|
|
||||||
if (cur.Nrows() > 0)
|
|
||||||
first = cur.GetLineString(0);
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
|
||||||
if (!ft.empty()) {
|
|
||||||
cur.SetFiletype(ft);
|
|
||||||
cur.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = cur.Highlighter()) {
|
|
||||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cur.SetFiletype("");
|
|
||||||
cur.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = cur.Highlighter()) {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Highlighting by extension or shebang
|
||||||
|
cur.ApplyDetectedFiletype();
|
||||||
// Defensive: ensure any active prompt is closed after a successful open
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
CancelPrompt();
|
CancelPrompt();
|
||||||
return true;
|
return true;
|
||||||
@@ -244,31 +297,12 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
}
|
}
|
||||||
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
||||||
// final location (vector) because swap manager keys off Buffer*.
|
// final location (vector) because swap manager keys off Buffer*.
|
||||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
// Highlighting by extension or shebang
|
||||||
b.EnsureHighlighter();
|
b.ApplyDetectedFiletype();
|
||||||
std::string first = "";
|
|
||||||
if (b.Nrows() > 0)
|
|
||||||
first = b.GetLineString(0);
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
|
||||||
if (!ft.empty()) {
|
|
||||||
b.SetFiletype(ft);
|
|
||||||
b.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = b.Highlighter()) {
|
|
||||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.SetFiletype("");
|
|
||||||
b.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = b.Highlighter()) {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add as a new buffer and switch to it
|
// Add as a new buffer and switch to it
|
||||||
std::size_t idx = AddBuffer(std::move(b));
|
std::size_t idx = AddBuffer(std::move(b));
|
||||||
if (swap_) {
|
if (Swap()) {
|
||||||
swap_->NotifyFilenameChanged(buffers_[idx]);
|
Swap()->NotifyFilenameChanged(Buffers()[idx]);
|
||||||
}
|
}
|
||||||
SwitchTo(idx);
|
SwitchTo(idx);
|
||||||
// Defensive: ensure any active prompt is closed after a successful open
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
@@ -308,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -320,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;
|
||||||
@@ -334,11 +376,35 @@ Editor::ResolveRecoveryPrompt(const bool yes)
|
|||||||
SetStatus("Recovery failed: no buffer");
|
SetStatus("Recovery failed: no buffer");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::string rerr;
|
std::uint64_t swap_size_now = 0;
|
||||||
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
|
try {
|
||||||
SetStatus("Swap recovery failed: " + rerr);
|
swap_size_now = std::filesystem::file_size(swp);
|
||||||
return false;
|
} 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;
|
||||||
|
if (!kte::SwapManager::ReplayFile(*b, swp, rerr, &valid_bytes)) {
|
||||||
|
SetStatus("Swap recovery failed: " + rerr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This session keeps appending to the same journal. Drop a torn
|
||||||
|
// final record first, or new records would land behind it and the
|
||||||
|
// journal could not be replayed after another crash.
|
||||||
|
try {
|
||||||
|
if (std::filesystem::file_size(swp) > valid_bytes)
|
||||||
|
std::filesystem::resize_file(swp, valid_bytes);
|
||||||
|
} catch (...) {
|
||||||
|
// Best effort; the replay itself succeeded.
|
||||||
|
}
|
||||||
|
// Nothing in the undo history matches the file on disk now: keep
|
||||||
|
// the buffer dirty even after undoing back to the loaded state.
|
||||||
|
if (auto *u = b->Undo())
|
||||||
|
u->mark_base_unsaved();
|
||||||
b->SetDirty(true);
|
b->SetDirty(true);
|
||||||
apply_pending_line(*this, req.line1);
|
apply_pending_line(*this, req.line1);
|
||||||
SetStatus("Recovered " + req.path);
|
SetStatus("Recovered " + req.path);
|
||||||
@@ -355,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);
|
||||||
@@ -364,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);
|
||||||
@@ -377,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;
|
||||||
@@ -389,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;
|
||||||
@@ -397,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 {
|
||||||
@@ -446,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,21 +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()) {
|
||||||
// Always remove swap file when closing a buffer on normal exit.
|
// Always remove swap file when closing a buffer on normal exit.
|
||||||
// Swap files are for crash recovery; on clean close, we don't need them.
|
// Swap files are for crash recovery; on clean close, we don't need them.
|
||||||
// This prevents stale swap files from accumulating (e.g., when used as git editor).
|
// This prevents stale swap files from accumulating (e.g., when used as git editor).
|
||||||
swap_->Detach(&buffers_[index], true);
|
Swap()->Detach(&bufs[index], true);
|
||||||
buffers_[index].SetSwapRecorder(nullptr);
|
bufs[index].SetSwapRecorder(nullptr);
|
||||||
|
// Drain in-flight records before the erase-shift below moves other
|
||||||
|
// buffers to new addresses (vector::erase never reallocates, but it does
|
||||||
|
// move-assign each trailing buffer into the previous slot).
|
||||||
|
Swap()->Flush();
|
||||||
}
|
}
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -502,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,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];
|
||||||
@@ -552,7 +709,7 @@ Editor::UArgDigit(int d)
|
|||||||
uarg_ = 1;
|
uarg_ = 1;
|
||||||
ucount_ = 0;
|
ucount_ = 0;
|
||||||
}
|
}
|
||||||
ucount_ = ucount_ * 10 + d;
|
ucount_ = (ucount_ > (kMaxUArgCount - d) / 10) ? kMaxUArgCount : ucount_ * 10 + d;
|
||||||
char buf[64];
|
char buf[64];
|
||||||
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
||||||
SetStatus(buf);
|
SetStatus(buf);
|
||||||
|
|||||||
@@ -79,19 +79,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Mode and flags (mirroring legacy fields)
|
|
||||||
void SetMode(int m)
|
|
||||||
{
|
|
||||||
mode_ = m;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int Mode() const
|
|
||||||
{
|
|
||||||
return mode_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetKillChain(bool on)
|
void SetKillChain(bool on)
|
||||||
{
|
{
|
||||||
kill_ = on ? 1 : 0;
|
kill_ = on ? 1 : 0;
|
||||||
@@ -104,18 +91,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetNoKill(bool on)
|
|
||||||
{
|
|
||||||
no_kill_ = on ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool NoKill() const
|
|
||||||
{
|
|
||||||
return no_kill_ != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Kill ring API
|
// Kill ring API
|
||||||
void KillRingClear()
|
void KillRingClear()
|
||||||
{
|
{
|
||||||
@@ -164,18 +139,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetDirtyEx(int d)
|
|
||||||
{
|
|
||||||
dirtyex_ = d;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int DirtyEx() const
|
|
||||||
{
|
|
||||||
return dirtyex_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetUniversalArg(int uarg, int ucount)
|
void SetUniversalArg(int uarg, int ucount)
|
||||||
{
|
{
|
||||||
uarg_ = uarg;
|
uarg_ = uarg;
|
||||||
@@ -189,12 +152,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int UCount() const
|
|
||||||
{
|
|
||||||
return ucount_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Universal argument control (C-u) ---
|
// --- Universal argument control (C-u) ---
|
||||||
// Begin or extend a universal argument (like ke's uarg_start)
|
// Begin or extend a universal argument (like ke's uarg_start)
|
||||||
void UArgStart();
|
void UArgStart();
|
||||||
@@ -209,19 +166,6 @@ public:
|
|||||||
// If no universal argument active, returns 1.
|
// If no universal argument active, returns 1.
|
||||||
int UArgGet();
|
int UArgGet();
|
||||||
|
|
||||||
// Repeatable command flag: input layer can mark the next command as repeatable
|
|
||||||
void SetRepeatable(bool on)
|
|
||||||
{
|
|
||||||
repeatable_ = on;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool Repeatable() const
|
|
||||||
{
|
|
||||||
return repeatable_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Status message storage. Rendering is renderer-dependent; the editor
|
// Status message storage. Rendering is renderer-dependent; the editor
|
||||||
// merely stores the current message and its timestamp.
|
// merely stores the current message and its timestamp.
|
||||||
void SetStatus(const std::string &message);
|
void SetStatus(const std::string &message);
|
||||||
@@ -246,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;
|
||||||
@@ -509,7 +465,7 @@ public:
|
|||||||
// Buffers
|
// Buffers
|
||||||
[[nodiscard]] std::size_t BufferCount() const
|
[[nodiscard]] std::size_t BufferCount() const
|
||||||
{
|
{
|
||||||
return buffers_.size();
|
return Buffers().size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -519,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;
|
||||||
@@ -543,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`
|
||||||
@@ -570,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -618,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::size_t curbuf_ = 0; // index into buffers_
|
std::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of 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_;
|
||||||
@@ -639,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
|
||||||
@@ -666,6 +657,12 @@ private:
|
|||||||
PendingOpen pending_recovery_open_{};
|
PendingOpen pending_recovery_open_{};
|
||||||
std::string pending_recovery_swap_path_;
|
std::string pending_recovery_swap_path_;
|
||||||
std::string pending_recovery_replay_err_;
|
std::string pending_recovery_replay_err_;
|
||||||
|
// The recovered content the prompt offers, and how much of the journal
|
||||||
|
// produced it: "y" installs it instead of replaying the journal again.
|
||||||
|
std::string pending_recovery_content_;
|
||||||
|
std::uint64_t pending_recovery_valid_bytes_ = 0;
|
||||||
|
std::uint64_t pending_recovery_swap_size_ = 0; // the journal's size then
|
||||||
|
bool pending_recovery_have_content_ = false;
|
||||||
|
|
||||||
// GUI-only state (safe no-op in terminal builds)
|
// GUI-only state (safe no-op in terminal builds)
|
||||||
bool file_picker_visible_ = false;
|
bool file_picker_visible_ = false;
|
||||||
|
|||||||
+5
-3
@@ -73,13 +73,15 @@ CircuitBreaker::RecordFailure()
|
|||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lg(mtx_);
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
|
||||||
const auto now = std::chrono::steady_clock::now();
|
// The window is measured from the previous failure, so check it before
|
||||||
last_failure_time_ = now;
|
// recording this one.
|
||||||
|
const bool window_expired = IsWindowExpired();
|
||||||
|
last_failure_time_ = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
switch (state_) {
|
switch (state_) {
|
||||||
case State::Closed:
|
case State::Closed:
|
||||||
// Check if we need to reset the failure count (window expired)
|
// Check if we need to reset the failure count (window expired)
|
||||||
if (IsWindowExpired()) {
|
if (window_expired) {
|
||||||
failure_count_ = 0;
|
failure_count_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,11 +52,6 @@ struct RetryPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Conservative policy for non-critical operations: fewer attempts, slower retries
|
|
||||||
static RetryPolicy Conservative()
|
|
||||||
{
|
|
||||||
return RetryPolicy{2, std::chrono::milliseconds(200), 2.5, std::chrono::milliseconds(10000)};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Retry a function with exponential backoff for transient errors
|
// Retry a function with exponential backoff for transient errors
|
||||||
|
|||||||
+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
|
|
||||||
|
|||||||
+31
-5
@@ -27,6 +27,7 @@ HelpText::Text()
|
|||||||
" C-k SPACE Toggle mark\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"
|
||||||
@@ -40,6 +41,7 @@ HelpText::Text()
|
|||||||
" C-k j Jump to mark\n"
|
" C-k j Jump to mark\n"
|
||||||
" C-k k Center viewport on cursor\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"
|
||||||
@@ -63,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"
|
||||||
@@ -74,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
|
||||||
|
|||||||
+464
-185
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply pending font change before starting a new frame
|
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) {
|
||||||
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
|
for (auto &ws : windows_) {
|
||||||
// Recreate backend font texture
|
if (!ws->alive || !ws->imgui_ctx)
|
||||||
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
continue;
|
||||||
ImGui_ImplOpenGL3_CreateFontsTexture();
|
ImGui::SetCurrentContext(ws->imgui_ctx);
|
||||||
|
SDL_GL_MakeCurrent(ws->window, ws->gl_ctx);
|
||||||
|
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
|
||||||
|
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
||||||
|
ImGui_ImplOpenGL3_CreateFontsTexture();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a new ImGui frame BEFORE processing commands so dimensions are correct
|
// --- Step each window ---
|
||||||
ImGui_ImplOpenGL3_NewFrame();
|
// We iterate by index because OpenNewWindow_ may append to windows_.
|
||||||
ImGui_ImplSDL2_NewFrame(window_);
|
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
|
||||||
ImGui::NewFrame();
|
WindowState &ws = *windows_[wi];
|
||||||
|
if (!ws.alive)
|
||||||
|
continue;
|
||||||
|
|
||||||
// Update editor logical rows/cols using current ImGui metrics and display size
|
Editor &wed = (wi == 0) ? ed : ws.editor;
|
||||||
{
|
|
||||||
ImGuiIO &io = ImGui::GetIO();
|
|
||||||
float row_h = ImGui::GetTextLineHeightWithSpacing();
|
|
||||||
float ch_w = ImGui::CalcTextSize("M").x;
|
|
||||||
if (row_h <= 0.0f)
|
|
||||||
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.
|
// Shared buffer list may have been modified by another window.
|
||||||
const float pad_x = 6.0f;
|
wed.ValidateBufferIndex();
|
||||||
const float pad_y = 6.0f;
|
|
||||||
|
|
||||||
// Use the same logic as ImGuiRenderer for available height and status bar reservation.
|
// Activate this window's GL and ImGui contexts
|
||||||
float wanted_bar_h = ImGui::GetFrameHeight();
|
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
|
||||||
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
|
ImGui::SetCurrentContext(ws.imgui_ctx);
|
||||||
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
|
|
||||||
|
|
||||||
// Visible content rows inside the scroll child
|
// Start a new ImGui frame
|
||||||
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
|
ImGui_ImplOpenGL3_NewFrame();
|
||||||
// Editor::Rows includes the status line; add 1 back for it.
|
ImGui_ImplSDL2_NewFrame(ws.window);
|
||||||
std::size_t rows = content_rows + 1;
|
ImGui::NewFrame();
|
||||||
|
|
||||||
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
|
// Update editor dimensions
|
||||||
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
{
|
||||||
|
ImGuiIO &io = ImGui::GetIO();
|
||||||
// Only update if changed to avoid churn
|
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(ws.width);
|
||||||
if (rows != ed.Rows() || cols != ed.Cols()) {
|
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(ws.height);
|
||||||
ed.SetDimensions(rows, cols);
|
update_editor_dimensions(wed, disp_w, disp_h);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
for (;;) {
|
apply_syntax_to_buffer(wed.CurrentBuffer(), config_);
|
||||||
MappedInput mi;
|
|
||||||
if (!input_.Poll(mi))
|
// Drain input queue
|
||||||
break;
|
for (;;) {
|
||||||
if (mi.hasCommand) {
|
MappedInput mi;
|
||||||
// Track kill ring before and after to sync GUI clipboard when it changes
|
if (!ws.input.Poll(mi))
|
||||||
const std::string before = ed.KillRingHead();
|
break;
|
||||||
Execute(ed, mi.id, mi.arg, mi.count);
|
if (mi.hasCommand) {
|
||||||
const std::string after = ed.KillRingHead();
|
if (mi.id == CommandId::NewWindow) {
|
||||||
if (after != before && !after.empty()) {
|
// Open a new window; handled after this loop
|
||||||
// Update the system clipboard to mirror the kill ring head in GUI
|
wed.SetNewWindowRequested(true);
|
||||||
SDL_SetClipboardText(after.c_str());
|
} 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()) {
|
||||||
|
SDL_SetClipboardText(after.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wi == 0 && wed.QuitRequested()) {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
ws.renderer.Draw(wed);
|
||||||
|
|
||||||
|
// Render
|
||||||
|
ImGui::Render();
|
||||||
|
int display_w, display_h;
|
||||||
|
SDL_GL_GetDrawableSize(ws.window, &display_w, &display_h);
|
||||||
|
glViewport(0, 0, display_w, display_h);
|
||||||
|
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
|
||||||
|
glClear(GL_COLOR_BUFFER_BIT);
|
||||||
|
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
||||||
|
SDL_GL_SwapWindow(ws.window);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ed.QuitRequested()) {
|
// Handle deferred new-window requests (must happen outside the render loop
|
||||||
running = false;
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No runtime font UI; always use embedded font.
|
// Remove dead secondary windows
|
||||||
|
for (auto it = windows_.begin() + 1; it != windows_.end();) {
|
||||||
|
if (!(*it)->alive) {
|
||||||
|
DestroyWindowResources_(**it);
|
||||||
|
it = windows_.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Draw editor UI
|
// Restore primary context
|
||||||
renderer_.Draw(ed);
|
if (!windows_.empty()) {
|
||||||
|
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
|
||||||
// Render
|
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
|
||||||
ImGui::Render();
|
}
|
||||||
int display_w, display_h;
|
|
||||||
SDL_GL_GetDrawableSize(window_, &display_w, &display_h);
|
|
||||||
glViewport(0, 0, display_w, display_h);
|
|
||||||
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
|
|
||||||
glClear(GL_COLOR_BUFFER_BIT);
|
|
||||||
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
|
||||||
SDL_GL_SwapWindow(window_);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+30
-7
@@ -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;
|
|
||||||
};
|
|
||||||
+95
-40
@@ -8,6 +8,20 @@
|
|||||||
#include "ImGuiInputHandler.h"
|
#include "ImGuiInputHandler.h"
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
|
#include "PasteSplit.h"
|
||||||
|
|
||||||
|
// Verbose k-prefix suffix logging for debugging macOS/SDL key translation
|
||||||
|
// issues. Default to off; enable by defining IMGUI_IH_DEBUG=1 at compile
|
||||||
|
// time.
|
||||||
|
#ifndef IMGUI_IH_DEBUG
|
||||||
|
#define IMGUI_IH_DEBUG 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if IMGUI_IH_DEBUG
|
||||||
|
#define IH_LOGF(...) do { std::fprintf(stderr, __VA_ARGS__); std::fflush(stderr); } while (0)
|
||||||
|
#else
|
||||||
|
#define IH_LOGF(...) ((void) 0)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
static bool
|
static bool
|
||||||
@@ -143,6 +157,24 @@ map_key(const SDL_Keycode key,
|
|||||||
|
|
||||||
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
|
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
|
||||||
if (k_prefix) {
|
if (k_prefix) {
|
||||||
|
// A modifier pressed on its own (Shift for C-k C or C-k ^, Ctrl
|
||||||
|
// pressed again for C-k C-d) is not the suffix: keep waiting.
|
||||||
|
switch (key) {
|
||||||
|
case SDLK_LSHIFT:
|
||||||
|
case SDLK_RSHIFT:
|
||||||
|
case SDLK_LCTRL:
|
||||||
|
case SDLK_RCTRL:
|
||||||
|
case SDLK_LALT:
|
||||||
|
case SDLK_RALT:
|
||||||
|
case SDLK_LGUI:
|
||||||
|
case SDLK_RGUI:
|
||||||
|
case SDLK_CAPSLOCK:
|
||||||
|
case SDLK_MODE:
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
esc_meta = false;
|
esc_meta = false;
|
||||||
// Normalize to ASCII; preserve case for letters using Shift
|
// Normalize to ASCII; preserve case for letters using Shift
|
||||||
int ascii_key = 0;
|
int ascii_key = 0;
|
||||||
@@ -182,18 +214,19 @@ map_key(const SDL_Keycode key,
|
|||||||
k_ctrl_pending = false;
|
k_ctrl_pending = false;
|
||||||
CommandId id;
|
CommandId id;
|
||||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||||
|
#if IMGUI_IH_DEBUG
|
||||||
// Diagnostics for u/U
|
// Diagnostics for u/U
|
||||||
if (lower == 'u') {
|
if (lower == 'u') {
|
||||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||||
? static_cast<char>(ascii_key)
|
? static_cast<char>(ascii_key)
|
||||||
: '?';
|
: '?';
|
||||||
std::fprintf(stderr,
|
IH_LOGF(
|
||||||
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
||||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||||
mapped ? static_cast<int>(id) : -1);
|
mapped ? static_cast<int>(id) : -1);
|
||||||
std::fflush(stderr);
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
out = {true, id, "", 0};
|
out = {true, id, "", 0};
|
||||||
if (ed)
|
if (ed)
|
||||||
@@ -331,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)) {
|
||||||
@@ -344,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
|
||||||
@@ -446,7 +499,7 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
if (ed_ &&ed_
|
if (ed_ &&ed_
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
->
|
->
|
||||||
UArg() != 0
|
UArg() != 0
|
||||||
)
|
)
|
||||||
@@ -492,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)
|
{
|
||||||
? static_cast<char>(ascii_key)
|
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||||
: '?';
|
? static_cast<char>(ascii_key)
|
||||||
std::fprintf(stderr,
|
: '?';
|
||||||
"[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
IH_LOGF("[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_)
|
||||||
|
|||||||
+248
-238
@@ -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,32 +213,57 @@ 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::size_t best_col = 0;
|
||||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
float best_dist = std::numeric_limits<float>::infinity();
|
||||||
const std::size_t tabw = 8;
|
for (std::size_t ci = 0; ci <= line_clicked.size(); ++ci) {
|
||||||
std::size_t rx = 0;
|
std::size_t exp_col = src_to_exp[ci];
|
||||||
std::size_t best_col = 0;
|
float px = 0.0f;
|
||||||
float best_dist = std::numeric_limits<float>::infinity();
|
if (exp_col > 0 && !click_expanded.empty()) {
|
||||||
float clicked_rx_f = static_cast<float>(clicked_rx);
|
std::size_t end = std::min(click_expanded.size(), exp_col);
|
||||||
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
|
px = ImGui::CalcTextSize(click_expanded.c_str(),
|
||||||
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
|
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};
|
||||||
@@ -211,7 +271,7 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
|
|
||||||
// Mouse-driven selection: set mark on double-click or drag, update cursor on any press/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);
|
||||||
@@ -225,7 +285,7 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 we are dragging (mouse moved while down), ensure mark is set to start selection
|
||||||
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
|
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
|
||||||
@@ -242,60 +302,66 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
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
|
else
|
||||||
if (ed.PromptActive() && (
|
expanded.push_back(line[src]);
|
||||||
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 {
|
rx_of[line.size()] = expanded.size();
|
||||||
std::size_t rx = 0;
|
auto src_to_rx = [&](std::size_t src) -> std::size_t {
|
||||||
std::size_t s = 0;
|
return rx_of[std::min(src, line.size())];
|
||||||
while (s < upto_src_exclusive && s < line.size()) {
|
|
||||||
if (line[s] == '\t')
|
|
||||||
rx += (tabw - (rx % tabw));
|
|
||||||
else
|
|
||||||
rx += 1;
|
|
||||||
++s;
|
|
||||||
}
|
|
||||||
return rx;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -306,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;
|
||||||
@@ -349,18 +410,12 @@ 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)
|
line_pos.y);
|
||||||
? (rx_start - coloffs_now)
|
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||||
: 0;
|
line_pos.y + line_h);
|
||||||
std::size_t vx1 = rx_end - coloffs_now;
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
line_pos.y);
|
|
||||||
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
|
||||||
line_pos.y + line_h);
|
|
||||||
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
|
||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
||||||
@@ -374,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)
|
line_pos.y);
|
||||||
? (rx_start - coloffs_now)
|
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||||
: 0;
|
line_pos.y + line_h);
|
||||||
std::size_t vx1 = rx_end - coloffs_now;
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
line_pos.y);
|
|
||||||
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
|
||||||
line_pos.y + line_h);
|
|
||||||
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
|
||||||
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);
|
||||||
@@ -463,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();
|
||||||
@@ -545,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 {
|
||||||
cursor_rx += 1;
|
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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
long cxr = static_cast<long>(cursor_rx);
|
if (cursor_px_abs < scroll_x_now || cursor_px_abs > scroll_x_now + child_w_actual) {
|
||||||
if (cxr < first_col || cxr > last_col) {
|
float target_x = cursor_px_abs - (child_w_actual / 2.0f);
|
||||||
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();
|
||||||
@@ -720,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;
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+290
-68
@@ -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;
|
||||||
@@ -199,8 +197,60 @@ PieceTable::Clear()
|
|||||||
line_index_.clear();
|
line_index_.clear();
|
||||||
line_index_dirty_ = true;
|
line_index_dirty_ = true;
|
||||||
version_++;
|
version_++;
|
||||||
range_cache_ = {};
|
range_cache_ = {};
|
||||||
find_cache_ = {};
|
find_cache_ = {};
|
||||||
|
last_deleted_ = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
PieceTable::AdoptOriginal(std::string &&bytes)
|
||||||
|
{
|
||||||
|
// Allocate before touching the current content: if this throws, the
|
||||||
|
// table is unchanged (everything below is non-throwing).
|
||||||
|
std::vector<Piece> pieces;
|
||||||
|
if (!bytes.empty())
|
||||||
|
pieces.push_back(Piece{Source::Original, 0, bytes.size()});
|
||||||
|
Clear();
|
||||||
|
std::string().swap(add_); // release a previous content's capacity
|
||||||
|
std::string().swap(materialized_);
|
||||||
|
original_ = std::move(bytes);
|
||||||
|
total_size_ = original_.size();
|
||||||
|
pieces_.swap(pieces);
|
||||||
|
version_++;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string_view
|
||||||
|
PieceTable::ContentView() const
|
||||||
|
{
|
||||||
|
if (total_size_ == 0)
|
||||||
|
return {};
|
||||||
|
if (pieces_.size() == 1) {
|
||||||
|
const Piece &p = pieces_.front();
|
||||||
|
return {(p.src == Source::Original ? original_ : add_).data() + p.start, p.len};
|
||||||
|
}
|
||||||
|
materialize();
|
||||||
|
return {materialized_.data(), materialized_.size()};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string_view
|
||||||
|
PieceTable::View(std::size_t byte_offset, std::size_t len) const
|
||||||
|
{
|
||||||
|
if (byte_offset >= total_size_ || len == 0)
|
||||||
|
return {};
|
||||||
|
len = std::min(len, total_size_ - byte_offset);
|
||||||
|
if (dirty_) {
|
||||||
|
// Materialized text is stale: serve the range from its piece when it
|
||||||
|
// fits in one, rather than copying the whole buffer.
|
||||||
|
auto [idx, inner] = locate(byte_offset);
|
||||||
|
if (idx < pieces_.size() && inner + len <= pieces_[idx].len) {
|
||||||
|
const Piece &p = pieces_[idx];
|
||||||
|
return {(p.src == Source::Original ? original_ : add_).data() + p.start + inner, len};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ContentView().substr(byte_offset, len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
off -= p.len;
|
piece_ends_[i] = end += pieces_[i].len;
|
||||||
|
piece_ends_version_ = version_;
|
||||||
}
|
}
|
||||||
// Should not reach here unless inconsistency; return end
|
const auto it = std::upper_bound(piece_ends_.begin(), piece_ends_.end(), byte_offset);
|
||||||
return {pieces_.size(), 0};
|
if (it == piece_ends_.end())
|
||||||
|
return {pieces_.size(), 0}; // inconsistent sizes; treat as the end
|
||||||
|
const std::size_t i = static_cast<std::size_t>(it - piece_ends_.begin());
|
||||||
|
return {i, byte_offset - (i > 0 ? piece_ends_[i - 1] : 0)};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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);
|
end -= 1;
|
||||||
if (!last.empty() && last[0] == '\n') {
|
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return GetRange(start, end - start);
|
return GetRange(start, end - start);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,9 +893,9 @@ PieceTable::ByteOffsetToLineCol(std::size_t byte_offset) const
|
|||||||
RebuildLineIndex();
|
RebuildLineIndex();
|
||||||
if (line_index_.empty())
|
if (line_index_.empty())
|
||||||
return {0, 0};
|
return {0, 0};
|
||||||
auto it = std::upper_bound(line_index_.begin(), line_index_.end(), byte_offset);
|
const std::size_t ub = lineUpperBound(byte_offset);
|
||||||
std::size_t row = (it == line_index_.begin()) ? 0 : static_cast<std::size_t>((it - line_index_.begin()) - 1);
|
std::size_t row = ub == 0 ? 0 : ub - 1;
|
||||||
std::size_t col = byte_offset - line_index_[row];
|
std::size_t col = byte_offset - lineStart(row);
|
||||||
return {row, col};
|
return {row, col};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,15 +908,12 @@ PieceTable::LineColToByteOffset(std::size_t row, std::size_t col) const
|
|||||||
return 0;
|
return 0;
|
||||||
if (row >= line_index_.size())
|
if (row >= line_index_.size())
|
||||||
return total_size_;
|
return total_size_;
|
||||||
std::size_t start = line_index_[row];
|
std::size_t start = lineStart(row);
|
||||||
std::size_t end = (row + 1 < line_index_.size()) ? line_index_[row + 1] : total_size_;
|
std::size_t end = (row + 1 < line_index_.size()) ? lineStart(row + 1) : total_size_;
|
||||||
// Clamp col to line length excluding trailing newline
|
// Clamp col to the line's length without its newline (every line but
|
||||||
if (end > start) {
|
// the last has one).
|
||||||
std::string last = GetRange(end - 1, 1);
|
if (end > start && row + 1 < line_index_.size())
|
||||||
if (!last.empty() && last[0] == '\n') {
|
end -= 1;
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::size_t target = start + std::min(col, end - start);
|
std::size_t target = start + std::min(col, end - start);
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
@@ -702,7 +929,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
|||||||
|
|
||||||
// Fast path: return cached value if version/offset/len match
|
// Fast path: return cached value if version/offset/len match
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
if (range_cache_.valid && range_cache_.version == version_ &&
|
if (range_cache_.valid && range_cache_.version == version_ &&
|
||||||
range_cache_.off == byte_offset && range_cache_.len == len) {
|
range_cache_.off == byte_offset && range_cache_.len == len) {
|
||||||
return range_cache_.data;
|
return range_cache_.data;
|
||||||
@@ -712,7 +938,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
|||||||
std::string out;
|
std::string out;
|
||||||
out.reserve(len);
|
out.reserve(len);
|
||||||
if (!dirty_) {
|
if (!dirty_) {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
// Already materialized; slice directly
|
// Already materialized; slice directly
|
||||||
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
|
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
|
||||||
} else {
|
} else {
|
||||||
@@ -737,7 +962,6 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
|||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
range_cache_.valid = true;
|
range_cache_.valid = true;
|
||||||
range_cache_.version = version_;
|
range_cache_.version = version_;
|
||||||
range_cache_.off = byte_offset;
|
range_cache_.off = byte_offset;
|
||||||
@@ -756,7 +980,6 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
|
|||||||
if (start > total_size_)
|
if (start > total_size_)
|
||||||
return std::numeric_limits<std::size_t>::max();
|
return std::numeric_limits<std::size_t>::max();
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
if (find_cache_.valid &&
|
if (find_cache_.valid &&
|
||||||
find_cache_.version == version_ &&
|
find_cache_.version == version_ &&
|
||||||
find_cache_.needle == needle &&
|
find_cache_.needle == needle &&
|
||||||
@@ -768,7 +991,6 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
|
|||||||
materialize();
|
materialize();
|
||||||
std::size_t pos;
|
std::size_t pos;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
pos = materialized_.find(needle, start);
|
pos = materialized_.find(needle, start);
|
||||||
if (pos == std::string::npos)
|
if (pos == std::string::npos)
|
||||||
pos = std::numeric_limits<std::size_t>::max();
|
pos = std::numeric_limits<std::size_t>::max();
|
||||||
|
|||||||
+105
-25
@@ -4,7 +4,7 @@
|
|||||||
* PieceTable is kte's core text storage data structure. It provides efficient
|
* PieceTable is kte's core text storage data structure. It provides efficient
|
||||||
* insert/delete operations without copying the entire buffer by maintaining a
|
* insert/delete operations without copying the entire buffer by maintaining a
|
||||||
* sequence of "pieces" that reference ranges in two underlying buffers:
|
* sequence of "pieces" that reference ranges in two underlying buffers:
|
||||||
* - original_: Initial file content (currently unused, reserved for future)
|
* - original_: A loaded file's content (AdoptOriginal), never modified
|
||||||
* - add_: All text added during editing
|
* - add_: All text added during editing
|
||||||
*
|
*
|
||||||
* Key advantages:
|
* Key advantages:
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
*
|
*
|
||||||
* Performance characteristics:
|
* Performance characteristics:
|
||||||
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
|
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
|
||||||
* - Materialization (Data() call) is O(total_size) but cached until next edit
|
* - Materialization (Data() call) is O(total_size) but cached until next edit;
|
||||||
|
* a single-piece table (an unedited file) is viewed in place, not copied
|
||||||
* - Line index is lazily rebuilt on first line-based query after edits
|
* - Line index is lazily rebuilt on first line-based query after edits
|
||||||
* - Range and Find operations use lightweight caches for repeated queries
|
* - Range and Find operations use lightweight caches for repeated queries
|
||||||
*
|
*
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
*
|
*
|
||||||
* Implementation notes:
|
* Implementation notes:
|
||||||
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
|
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
|
||||||
* - Thread-safe for concurrent reads (mutex protects caches and lazy rebuilds)
|
* - Not thread-safe: even reads update caches. Buffers are used from the
|
||||||
|
* main thread only (the swap writer thread gets copies of the bytes).
|
||||||
* - Version tracking invalidates caches on mutations
|
* - Version tracking invalidates caches on mutations
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
@@ -42,15 +44,15 @@
|
|||||||
#include <ostream>
|
#include <ostream>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <mutex>
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "TextSpan.h"
|
||||||
|
|
||||||
|
|
||||||
class PieceTable {
|
class PieceTable {
|
||||||
public:
|
public:
|
||||||
PieceTable();
|
PieceTable();
|
||||||
|
|
||||||
explicit PieceTable(std::size_t initialCapacity);
|
|
||||||
|
|
||||||
// Advanced constructor allowing configuration of consolidation heuristics
|
// Advanced constructor allowing configuration of consolidation heuristics
|
||||||
PieceTable(std::size_t initialCapacity,
|
PieceTable(std::size_t initialCapacity,
|
||||||
std::size_t piece_limit,
|
std::size_t piece_limit,
|
||||||
@@ -67,9 +69,6 @@ public:
|
|||||||
|
|
||||||
~PieceTable();
|
~PieceTable();
|
||||||
|
|
||||||
// Public API mirrors GapBuffer
|
|
||||||
void Reserve(std::size_t newCapacity);
|
|
||||||
|
|
||||||
void AppendChar(char c);
|
void AppendChar(char c);
|
||||||
|
|
||||||
void Append(const char *s, std::size_t len);
|
void Append(const char *s, std::size_t len);
|
||||||
@@ -85,18 +84,40 @@ public:
|
|||||||
// Content management
|
// Content management
|
||||||
void Clear();
|
void Clear();
|
||||||
|
|
||||||
|
// Replace the content with `bytes`, taken over without copying (a loaded
|
||||||
|
// file). Undo spans into the old storage are invalidated, as by Clear().
|
||||||
|
// If it throws (std::bad_alloc), the content is unchanged.
|
||||||
|
void AdoptOriginal(std::string &&bytes);
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
char *Data()
|
// The whole text, contiguous; nullptr when empty. Valid until the next
|
||||||
|
// modification.
|
||||||
|
[[nodiscard]] const char *Data() const
|
||||||
{
|
{
|
||||||
materialize();
|
const std::string_view v = ContentView();
|
||||||
return materialized_.empty() ? nullptr : materialized_.data();
|
return v.empty() ? nullptr : v.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] const char *Data() const
|
// The whole text as one view: in place for a single piece, else the
|
||||||
|
// materialized copy (built once per modification). Valid until the next
|
||||||
|
// modification.
|
||||||
|
[[nodiscard]] std::string_view ContentView() const;
|
||||||
|
|
||||||
|
// A view of [byte_offset, byte_offset + len), clamped to the content: in
|
||||||
|
// place when the range lies within one piece (no copy), else into the
|
||||||
|
// materialized text. Valid until the next modification.
|
||||||
|
[[nodiscard]] std::string_view View(std::size_t byte_offset, std::size_t len) const;
|
||||||
|
|
||||||
|
// Call fn(const char *data, std::size_t len) for each piece, in order:
|
||||||
|
// the content without materializing it.
|
||||||
|
template<typename Fn>
|
||||||
|
void ForEachChunk(Fn &&fn) const
|
||||||
{
|
{
|
||||||
const_cast<PieceTable *>(this)->materialize();
|
for (const Piece &p: pieces_) {
|
||||||
return materialized_.empty() ? nullptr : materialized_.data();
|
if (p.len > 0)
|
||||||
|
fn((p.src == Source::Original ? original_ : add_).data() + p.start, p.len);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -106,13 +127,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::size_t Capacity() const
|
|
||||||
{
|
|
||||||
// Capacity for piece table isn't directly meaningful; report materialized capacity
|
|
||||||
return materialized_.capacity();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ===== New buffer-wide API (Phase 1) =====
|
// ===== New buffer-wide API (Phase 1) =====
|
||||||
// Byte-based editing operations
|
// Byte-based editing operations
|
||||||
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
||||||
@@ -139,6 +153,36 @@ public:
|
|||||||
// Stream out content without materializing the entire buffer
|
// Stream out content without materializing the entire buffer
|
||||||
void WriteToStream(std::ostream &out) const;
|
void WriteToStream(std::ostream &out) const;
|
||||||
|
|
||||||
|
// ===== Span access (see TextSpan.h) =====
|
||||||
|
// The storage spans backing [byte_offset, byte_offset + len).
|
||||||
|
[[nodiscard]] std::vector<TextSpan> SpansInRange(std::size_t byte_offset, std::size_t len) const;
|
||||||
|
|
||||||
|
// Call fn(const char *data, std::size_t len) for each span's bytes, in order.
|
||||||
|
template<typename Fn>
|
||||||
|
void VisitSpans(const std::vector<TextSpan> &spans, Fn &&fn) const
|
||||||
|
{
|
||||||
|
for (const TextSpan &sp: spans) {
|
||||||
|
const std::string &src = sp.add ? add_ : original_;
|
||||||
|
if (sp.len > 0 && sp.start + sp.len <= src.size())
|
||||||
|
fn(src.data() + sp.start, sp.len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whether the spans hold exactly `text`.
|
||||||
|
[[nodiscard]] bool SpansEqual(const std::vector<TextSpan> &spans, std::string_view text) const;
|
||||||
|
|
||||||
|
// Insert the spans' bytes at byte_offset without copying them into
|
||||||
|
// storage again (the new pieces reference the existing bytes).
|
||||||
|
void InsertSpans(std::size_t byte_offset, const std::vector<TextSpan> &spans);
|
||||||
|
|
||||||
|
// Deletions of at least kCaptureDeletedMin bytes remember the spans they
|
||||||
|
// removed; this returns them if the most recent deletion was exactly
|
||||||
|
// [byte_offset, byte_offset + len), else nothing. Consumes the capture.
|
||||||
|
static constexpr std::size_t kCaptureDeletedMin = 4096;
|
||||||
|
|
||||||
|
[[nodiscard]] std::vector<TextSpan> TakeDeletedSpans(std::size_t byte_offset, std::size_t len);
|
||||||
|
|
||||||
// Heuristic configuration
|
// Heuristic configuration
|
||||||
void SetConsolidationParams(std::size_t piece_limit,
|
void SetConsolidationParams(std::size_t piece_limit,
|
||||||
std::size_t small_piece_threshold,
|
std::size_t small_piece_threshold,
|
||||||
@@ -175,10 +219,16 @@ private:
|
|||||||
// Line index support (rebuilt lazily on demand)
|
// Line index support (rebuilt lazily on demand)
|
||||||
void InvalidateLineIndex() const;
|
void InvalidateLineIndex() const;
|
||||||
|
|
||||||
|
// Keep a valid line index in step with an edit instead of rescanning the
|
||||||
|
// whole buffer on the next query. No-ops if the index is already dirty.
|
||||||
|
void lineIndexOnInsert(std::size_t offset, const char *text, std::size_t len) const;
|
||||||
|
|
||||||
|
void lineIndexOnDelete(std::size_t offset, std::size_t len) const;
|
||||||
|
|
||||||
void RebuildLineIndex() const;
|
void RebuildLineIndex() const;
|
||||||
|
|
||||||
// Underlying storages
|
// Underlying storages
|
||||||
std::string original_; // unused for builder use-case, but kept for API symmetry
|
std::string original_; // a loaded file's bytes (AdoptOriginal)
|
||||||
std::string add_;
|
std::string add_;
|
||||||
std::vector<Piece> pieces_;
|
std::vector<Piece> pieces_;
|
||||||
|
|
||||||
@@ -188,9 +238,34 @@ private:
|
|||||||
mutable std::uint64_t version_ = 0;
|
mutable std::uint64_t version_ = 0;
|
||||||
std::size_t total_size_ = 0;
|
std::size_t total_size_ = 0;
|
||||||
|
|
||||||
|
// End offset of each piece (prefix sums) for binary-search lookup,
|
||||||
|
// valid while piece_ends_version_ == version_ (every mutation bumps it).
|
||||||
|
mutable std::vector<std::size_t> piece_ends_;
|
||||||
|
mutable std::uint64_t piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
|
||||||
|
|
||||||
// Cached line index: starting byte offset of each line (always contains at least 1 entry: 0)
|
// Cached line index: starting byte offset of each line (always contains at least 1 entry: 0)
|
||||||
mutable std::vector<std::size_t> line_index_;
|
mutable std::vector<std::size_t> line_index_;
|
||||||
mutable bool line_index_dirty_ = true;
|
mutable bool line_index_dirty_ = true;
|
||||||
|
// A shift not yet applied to line_index_: entries from index
|
||||||
|
// line_shift_from_ on are really line_index_[i] + line_shift_by_ (mod
|
||||||
|
// 2^64, so it may be negative). An edit moves every later line start;
|
||||||
|
// applying that at once cost O(lines) per keystroke, so consecutive edits
|
||||||
|
// on one line only adjust the pending shift.
|
||||||
|
mutable std::size_t line_shift_from_ = std::numeric_limits<std::size_t>::max();
|
||||||
|
mutable std::size_t line_shift_by_ = 0;
|
||||||
|
|
||||||
|
// Start of line i (i < line_index_.size()), with the pending shift.
|
||||||
|
[[nodiscard]] std::size_t lineStart(std::size_t i) const
|
||||||
|
{
|
||||||
|
return i >= line_shift_from_ ? line_index_[i] + line_shift_by_ : line_index_[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Index of the first line starting after byte_offset.
|
||||||
|
[[nodiscard]] std::size_t lineUpperBound(std::size_t byte_offset) const;
|
||||||
|
|
||||||
|
// Fold the pending shift into line_index_.
|
||||||
|
void applyLineShift() const;
|
||||||
|
|
||||||
// Heuristic knobs
|
// Heuristic knobs
|
||||||
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
|
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
|
||||||
@@ -214,8 +289,13 @@ private:
|
|||||||
std::size_t result = std::numeric_limits<std::size_t>::max();
|
std::size_t result = std::numeric_limits<std::size_t>::max();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct DeletedCapture {
|
||||||
|
bool valid = false;
|
||||||
|
std::size_t offset = 0;
|
||||||
|
std::size_t len = 0;
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
} last_deleted_;
|
||||||
|
|
||||||
mutable RangeCache range_cache_;
|
mutable RangeCache range_cache_;
|
||||||
mutable FindCache find_cache_;
|
mutable FindCache find_cache_;
|
||||||
|
|
||||||
mutable std::mutex mutex_;
|
|
||||||
};
|
};
|
||||||
-983
@@ -1,983 +0,0 @@
|
|||||||
#include "QtFrontend.h"
|
|
||||||
|
|
||||||
#include <QApplication>
|
|
||||||
#include <QWidget>
|
|
||||||
#include <QKeyEvent>
|
|
||||||
#include <QTimer>
|
|
||||||
#include <QScreen>
|
|
||||||
#include <QFont>
|
|
||||||
#include <QFontMetrics>
|
|
||||||
#include <QFontDatabase>
|
|
||||||
#include <QFileDialog>
|
|
||||||
#include <QFontDialog>
|
|
||||||
#include <QPainter>
|
|
||||||
#include <QPaintEvent>
|
|
||||||
#include <QWheelEvent>
|
|
||||||
#include <regex>
|
|
||||||
|
|
||||||
#include "Editor.h"
|
|
||||||
#include "Command.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include "GUITheme.h"
|
|
||||||
#include "Highlight.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
class MainWindow : public QWidget {
|
|
||||||
public:
|
|
||||||
explicit MainWindow(class QtInputHandler &ih, QWidget *parent = nullptr)
|
|
||||||
: QWidget(parent), input_(ih)
|
|
||||||
{
|
|
||||||
// Match ImGui window title format
|
|
||||||
setWindowTitle(QStringLiteral("kge - kyle's graphical editor ")
|
|
||||||
+ QStringLiteral(KTE_VERSION_STR));
|
|
||||||
resize(1280, 800);
|
|
||||||
setFocusPolicy(Qt::StrongFocus);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
bool WasClosed() const
|
|
||||||
{
|
|
||||||
return closed_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetEditor(Editor *ed)
|
|
||||||
{
|
|
||||||
ed_ = ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetFontFamilyAndSize(QString family, int px)
|
|
||||||
{
|
|
||||||
if (family.isEmpty())
|
|
||||||
family = QStringLiteral("Brass Mono");
|
|
||||||
if (px <= 0)
|
|
||||||
px = 18;
|
|
||||||
font_family_ = std::move(family);
|
|
||||||
font_px_ = px;
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void keyPressEvent(QKeyEvent *event) override
|
|
||||||
{
|
|
||||||
// Route to editor keymap; if handled, accept and stop propagation so
|
|
||||||
// Qt doesn't trigger any default widget shortcuts.
|
|
||||||
if (input_.ProcessKeyEvent(*event)) {
|
|
||||||
event->accept();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QWidget::keyPressEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void paintEvent(QPaintEvent *event) override
|
|
||||||
{
|
|
||||||
Q_UNUSED(event);
|
|
||||||
QPainter p(this);
|
|
||||||
p.setRenderHint(QPainter::TextAntialiasing, true);
|
|
||||||
|
|
||||||
// Colors from GUITheme palette (Qt branch)
|
|
||||||
auto to_qcolor = [](const KteColor &c) -> QColor {
|
|
||||||
int r = int(std::round(c.x * 255.0f));
|
|
||||||
int g = int(std::round(c.y * 255.0f));
|
|
||||||
int b = int(std::round(c.z * 255.0f));
|
|
||||||
int a = int(std::round(c.w * 255.0f));
|
|
||||||
return QColor(r, g, b, a);
|
|
||||||
};
|
|
||||||
const auto pal = kte::GetPalette();
|
|
||||||
const QColor bg = to_qcolor(pal.bg);
|
|
||||||
const QColor fg = to_qcolor(pal.fg);
|
|
||||||
const QColor sel_bg = to_qcolor(pal.sel_bg);
|
|
||||||
const QColor cur_bg = to_qcolor(pal.cur_bg);
|
|
||||||
const QColor status_bg = to_qcolor(pal.status_bg);
|
|
||||||
const QColor status_fg = to_qcolor(pal.status_fg);
|
|
||||||
|
|
||||||
// Background
|
|
||||||
p.fillRect(rect(), bg);
|
|
||||||
|
|
||||||
// Font/metrics (configured or defaults)
|
|
||||||
QFont f(font_family_, font_px_);
|
|
||||||
p.setFont(f);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = fm.height();
|
|
||||||
const int ch_w = std::max(1, fm.horizontalAdvance(QStringLiteral(" ")));
|
|
||||||
|
|
||||||
// Layout metrics
|
|
||||||
const int pad_l = 8;
|
|
||||||
const int pad_t = 6;
|
|
||||||
const int pad_r = 8;
|
|
||||||
const int pad_b = 6;
|
|
||||||
const int status_h = line_h + 6; // status bar height
|
|
||||||
|
|
||||||
// Content area (text viewport)
|
|
||||||
const QRect content_rect(pad_l,
|
|
||||||
pad_t,
|
|
||||||
width() - pad_l - pad_r,
|
|
||||||
height() - pad_t - pad_b - status_h);
|
|
||||||
|
|
||||||
// Text viewport occupies all content area (no extra title row)
|
|
||||||
QRect viewport(content_rect.x(), content_rect.y(), content_rect.width(), content_rect.height());
|
|
||||||
|
|
||||||
// Draw buffer contents
|
|
||||||
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
|
|
||||||
const Buffer *buf = ed_->CurrentBuffer();
|
|
||||||
if (buf) {
|
|
||||||
const std::size_t nrows = buf->Nrows();
|
|
||||||
const std::size_t rowoffs = buf->Rowoffs();
|
|
||||||
const std::size_t coloffs = buf->Coloffs();
|
|
||||||
const std::size_t cy = buf->Cury();
|
|
||||||
const std::size_t cx = buf->Curx();
|
|
||||||
|
|
||||||
// Visible line count
|
|
||||||
const int max_lines = (line_h > 0) ? (viewport.height() / line_h) : 0;
|
|
||||||
const std::size_t last_row = std::min<std::size_t>(
|
|
||||||
nrows, rowoffs + std::max(0, max_lines));
|
|
||||||
|
|
||||||
// Tab width: follow ImGuiRenderer default of 4
|
|
||||||
const std::size_t tabw = 4;
|
|
||||||
|
|
||||||
// Prepare painter clip to viewport
|
|
||||||
p.save();
|
|
||||||
p.setClipRect(viewport);
|
|
||||||
|
|
||||||
// Iterate visible lines
|
|
||||||
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
|
|
||||||
// Get line as string for regex/iterator usage and general string ops.
|
|
||||||
const std::string line = buf->GetLineString(i);
|
|
||||||
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
|
|
||||||
const int baseline = y + fm.ascent();
|
|
||||||
|
|
||||||
// Helper: convert src col -> rx with tab expansion
|
|
||||||
auto src_to_rx_line = [&](std::size_t src_col) -> std::size_t {
|
|
||||||
std::size_t rx = 0;
|
|
||||||
for (std::size_t k = 0; k < src_col && k < line.size(); ++k) {
|
|
||||||
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
|
|
||||||
}
|
|
||||||
return rx;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Search-match background highlights first (under text)
|
|
||||||
if (ed_->SearchActive() && !ed_->SearchQuery().empty()) {
|
|
||||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
|
||||||
// Compute ranges per line (source indices)
|
|
||||||
if (ed_->PromptActive() &&
|
|
||||||
(ed_->CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
|
|
||||||
ed_->CurrentPromptKind() ==
|
|
||||||
Editor::PromptKind::RegexReplaceFind)) {
|
|
||||||
try {
|
|
||||||
std::regex rx(ed_->SearchQuery());
|
|
||||||
for (auto it = std::sregex_iterator(
|
|
||||||
line.begin(), line.end(), rx);
|
|
||||||
it != std::sregex_iterator(); ++it) {
|
|
||||||
const auto &m = *it;
|
|
||||||
std::size_t sx = static_cast<std::size_t>(m.
|
|
||||||
position());
|
|
||||||
std::size_t ex =
|
|
||||||
sx + static_cast<std::size_t>(m.
|
|
||||||
length());
|
|
||||||
hl_src_ranges.emplace_back(sx, ex);
|
|
||||||
}
|
|
||||||
} catch (const std::regex_error &) {
|
|
||||||
// Invalid regex: ignore, status line already shows errors
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const std::string &q = ed_->SearchQuery();
|
|
||||||
if (!q.empty()) {
|
|
||||||
std::size_t pos = 0;
|
|
||||||
while ((pos = line.find(q, pos)) != std::string::npos) {
|
|
||||||
hl_src_ranges.emplace_back(pos, pos + q.size());
|
|
||||||
pos += q.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hl_src_ranges.empty()) {
|
|
||||||
const bool has_current =
|
|
||||||
ed_->SearchMatchLen() > 0 && ed_->SearchMatchY() == i;
|
|
||||||
const std::size_t cur_x = has_current ? ed_->SearchMatchX() : 0;
|
|
||||||
const std::size_t cur_end = has_current
|
|
||||||
? (ed_->SearchMatchX() + ed_->SearchMatchLen())
|
|
||||||
: 0;
|
|
||||||
for (const auto &rg: hl_src_ranges) {
|
|
||||||
std::size_t sx = rg.first, ex = rg.second;
|
|
||||||
std::size_t rx_s = src_to_rx_line(sx);
|
|
||||||
std::size_t rx_e = src_to_rx_line(ex);
|
|
||||||
if (rx_e <= coloffs)
|
|
||||||
continue; // fully left of view
|
|
||||||
int vx0 = viewport.x() + static_cast<int>((
|
|
||||||
(rx_s > coloffs ? rx_s - coloffs : 0)
|
|
||||||
* ch_w));
|
|
||||||
int vx1 = viewport.x() + static_cast<int>((
|
|
||||||
(rx_e - coloffs) * ch_w));
|
|
||||||
QRect r(vx0, y, std::max(0, vx1 - vx0), line_h);
|
|
||||||
if (r.width() <= 0)
|
|
||||||
continue;
|
|
||||||
bool is_current =
|
|
||||||
has_current && sx == cur_x && ex == cur_end;
|
|
||||||
QColor col = is_current
|
|
||||||
? QColor(255, 220, 120, 140)
|
|
||||||
: QColor(200, 200, 0, 90);
|
|
||||||
p.fillRect(r, col);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection background (if active on this line)
|
|
||||||
if (buf->MarkSet() && (
|
|
||||||
i == buf->MarkCury() || i == cy || (
|
|
||||||
i > std::min(buf->MarkCury(), cy) && i < std::max(
|
|
||||||
buf->MarkCury(), cy)))) {
|
|
||||||
std::size_t sx = 0, ex = 0;
|
|
||||||
if (buf->MarkCury() == i && cy == i) {
|
|
||||||
sx = std::min(buf->MarkCurx(), cx);
|
|
||||||
ex = std::max(buf->MarkCurx(), cx);
|
|
||||||
} else if (i == buf->MarkCury()) {
|
|
||||||
sx = buf->MarkCurx();
|
|
||||||
ex = line.size();
|
|
||||||
} else if (i == cy) {
|
|
||||||
sx = 0;
|
|
||||||
ex = cx;
|
|
||||||
} else {
|
|
||||||
sx = 0;
|
|
||||||
ex = line.size();
|
|
||||||
}
|
|
||||||
std::size_t rx_s = src_to_rx_line(sx);
|
|
||||||
std::size_t rx_e = src_to_rx_line(ex);
|
|
||||||
if (rx_e > coloffs) {
|
|
||||||
int vx0 = viewport.x() + static_cast<int>((rx_s > coloffs
|
|
||||||
? rx_s - coloffs
|
|
||||||
: 0) * ch_w);
|
|
||||||
int vx1 = viewport.x() + static_cast<int>(
|
|
||||||
(rx_e - coloffs) * ch_w);
|
|
||||||
QRect sel_r(vx0, y, std::max(0, vx1 - vx0), line_h);
|
|
||||||
if (sel_r.width() > 0)
|
|
||||||
p.fillRect(sel_r, sel_bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build expanded line (tabs -> spaces) for drawing
|
|
||||||
std::string expanded;
|
|
||||||
expanded.reserve(line.size() + 8);
|
|
||||||
std::size_t rx_acc = 0;
|
|
||||||
for (char c: line) {
|
|
||||||
if (c == '\t') {
|
|
||||||
std::size_t adv = (tabw - (rx_acc % tabw));
|
|
||||||
expanded.append(adv, ' ');
|
|
||||||
rx_acc += adv;
|
|
||||||
} else {
|
|
||||||
expanded.push_back(c);
|
|
||||||
rx_acc += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Syntax highlighting spans or plain text
|
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
|
||||||
HasHighlighter()) {
|
|
||||||
kte::LineHighlight lh = buf->Highlighter()->GetLine(
|
|
||||||
*buf, static_cast<int>(i), buf->Version());
|
|
||||||
struct SSpan {
|
|
||||||
std::size_t s;
|
|
||||||
std::size_t e;
|
|
||||||
kte::TokenKind k;
|
|
||||||
};
|
|
||||||
std::vector<SSpan> spans;
|
|
||||||
spans.reserve(lh.spans.size());
|
|
||||||
const std::size_t line_len = line.size();
|
|
||||||
for (const auto &sp: lh.spans) {
|
|
||||||
int s_raw = sp.col_start;
|
|
||||||
int e_raw = sp.col_end;
|
|
||||||
if (e_raw < s_raw)
|
|
||||||
std::swap(e_raw, s_raw);
|
|
||||||
std::size_t s = static_cast<std::size_t>(std::max(
|
|
||||||
0, std::min(s_raw, (int) line_len)));
|
|
||||||
std::size_t e = static_cast<std::size_t>(std::max(
|
|
||||||
(int) s, std::min(e_raw, (int) line_len)));
|
|
||||||
if (s < e)
|
|
||||||
spans.push_back({s, e, sp.kind});
|
|
||||||
}
|
|
||||||
std::sort(spans.begin(), spans.end(),
|
|
||||||
[](const SSpan &a, const SSpan &b) {
|
|
||||||
return a.s < b.s;
|
|
||||||
});
|
|
||||||
|
|
||||||
auto colorFor = [](kte::TokenKind k) -> QColor {
|
|
||||||
// GUITheme provides colors via ImGui vector; avoid direct dependency types
|
|
||||||
const auto v = kte::SyntaxInk(k);
|
|
||||||
return QColor(int(v.x * 255.0f), int(v.y * 255.0f),
|
|
||||||
int(v.z * 255.0f), int(v.w * 255.0f));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to convert src col to expanded rx
|
|
||||||
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
|
|
||||||
std::size_t rx = 0;
|
|
||||||
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
|
|
||||||
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
|
|
||||||
}
|
|
||||||
return rx;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (spans.empty()) {
|
|
||||||
// No highlight spans: draw the whole (visible) expanded line in default fg
|
|
||||||
if (coloffs < expanded.size()) {
|
|
||||||
const char *start =
|
|
||||||
expanded.c_str() + static_cast<int>(coloffs);
|
|
||||||
p.setPen(fg);
|
|
||||||
p.drawText(viewport.x(), baseline,
|
|
||||||
QString::fromUtf8(start));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Draw colored spans
|
|
||||||
for (const auto &sp: spans) {
|
|
||||||
std::size_t rx_s = src_to_rx_full(sp.s);
|
|
||||||
std::size_t rx_e = src_to_rx_full(sp.e);
|
|
||||||
if (rx_e <= coloffs)
|
|
||||||
continue; // left of viewport
|
|
||||||
std::size_t draw_start = (rx_s > coloffs)
|
|
||||||
? rx_s
|
|
||||||
: coloffs;
|
|
||||||
std::size_t draw_end = std::min<std::size_t>(
|
|
||||||
rx_e, expanded.size());
|
|
||||||
if (draw_end <= draw_start)
|
|
||||||
continue;
|
|
||||||
std::size_t screen_x = draw_start - coloffs;
|
|
||||||
int px = viewport.x() + int(screen_x * ch_w);
|
|
||||||
int len = int(draw_end - draw_start);
|
|
||||||
p.setPen(colorFor(sp.k));
|
|
||||||
p.drawText(px, baseline,
|
|
||||||
QString::fromUtf8(
|
|
||||||
expanded.c_str() + draw_start, len));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Draw expanded text clipped by coloffs
|
|
||||||
if (static_cast<std::size_t>(coloffs) < expanded.size()) {
|
|
||||||
const char *start =
|
|
||||||
expanded.c_str() + static_cast<int>(coloffs);
|
|
||||||
p.setPen(fg);
|
|
||||||
p.drawText(viewport.x(), baseline, QString::fromUtf8(start));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor indicator on current line
|
|
||||||
if (i == cy) {
|
|
||||||
std::size_t rx_cur = src_to_rx_line(cx);
|
|
||||||
if (rx_cur >= coloffs) {
|
|
||||||
// Compute exact pixel x by measuring expanded substring [coloffs, rx_cur)
|
|
||||||
std::size_t start = std::min<std::size_t>(
|
|
||||||
coloffs, expanded.size());
|
|
||||||
std::size_t end = std::min<
|
|
||||||
std::size_t>(rx_cur, expanded.size());
|
|
||||||
int px_advance = 0;
|
|
||||||
if (end > start) {
|
|
||||||
const QString sub = QString::fromUtf8(
|
|
||||||
expanded.c_str() + start,
|
|
||||||
static_cast<int>(end - start));
|
|
||||||
px_advance = fm.horizontalAdvance(sub);
|
|
||||||
}
|
|
||||||
int x0 = viewport.x() + px_advance;
|
|
||||||
QRect r(x0, y, ch_w, line_h);
|
|
||||||
p.fillRect(r, cur_bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status bar
|
|
||||||
const int bar_y = height() - status_h;
|
|
||||||
QRect status_rect(0, bar_y, width(), status_h);
|
|
||||||
p.fillRect(status_rect, status_bg);
|
|
||||||
p.setPen(status_fg);
|
|
||||||
if (ed_) {
|
|
||||||
const int pad = 6;
|
|
||||||
const int left_x = status_rect.x() + pad;
|
|
||||||
const int right_x_max = status_rect.x() + status_rect.width() - pad;
|
|
||||||
const int baseline_y = bar_y + (status_h + fm.ascent() - fm.descent()) / 2;
|
|
||||||
|
|
||||||
// If a prompt is active, mirror ImGui/TUI: show only the prompt across the bar
|
|
||||||
if (ed_->PromptActive()) {
|
|
||||||
std::string label = ed_->PromptLabel();
|
|
||||||
std::string text = ed_->PromptText();
|
|
||||||
|
|
||||||
// Map $HOME to ~ for path prompts (Open/Save/Chdir)
|
|
||||||
auto kind = ed_->CurrentPromptKind();
|
|
||||||
if (kind == Editor::PromptKind::OpenFile ||
|
|
||||||
kind == Editor::PromptKind::SaveAs ||
|
|
||||||
kind == Editor::PromptKind::Chdir) {
|
|
||||||
const char *home_c = std::getenv("HOME");
|
|
||||||
if (home_c && *home_c) {
|
|
||||||
std::string home(home_c);
|
|
||||||
if (text.rfind(home, 0) == 0) {
|
|
||||||
std::string rest = text.substr(home.size());
|
|
||||||
if (rest.empty())
|
|
||||||
text = "~";
|
|
||||||
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
|
|
||||||
text = std::string("~") + rest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string prefix;
|
|
||||||
if (kind == Editor::PromptKind::Command)
|
|
||||||
prefix = ": ";
|
|
||||||
else if (!label.empty())
|
|
||||||
prefix = label + ": ";
|
|
||||||
|
|
||||||
// Compose text and elide per behavior:
|
|
||||||
const int max_w = status_rect.width() - 2 * pad;
|
|
||||||
QString qprefix = QString::fromStdString(prefix);
|
|
||||||
QString qtext = QString::fromStdString(text);
|
|
||||||
int avail_w = std::max(0, max_w - fm.horizontalAdvance(qprefix));
|
|
||||||
Qt::TextElideMode mode = Qt::ElideRight;
|
|
||||||
if (kind == Editor::PromptKind::OpenFile ||
|
|
||||||
kind == Editor::PromptKind::SaveAs ||
|
|
||||||
kind == Editor::PromptKind::Chdir) {
|
|
||||||
mode = Qt::ElideLeft;
|
|
||||||
}
|
|
||||||
QString shown = fm.elidedText(qtext, mode, avail_w);
|
|
||||||
p.drawText(left_x, baseline_y, qprefix + shown);
|
|
||||||
} else {
|
|
||||||
// Build left segment: app/version, buffer idx/total, filename [+dirty], line count
|
|
||||||
QString left;
|
|
||||||
left += QStringLiteral("kge ");
|
|
||||||
left += QStringLiteral(KTE_VERSION_STR);
|
|
||||||
|
|
||||||
const Buffer *buf = ed_->CurrentBuffer();
|
|
||||||
if (buf) {
|
|
||||||
// buffer index/total
|
|
||||||
std::size_t total = ed_->BufferCount();
|
|
||||||
if (total > 0) {
|
|
||||||
std::size_t idx1 = ed_->CurrentBufferIndex() + 1; // 1-based
|
|
||||||
left += QStringLiteral(" [");
|
|
||||||
left += QString::number(static_cast<qlonglong>(idx1));
|
|
||||||
left += QStringLiteral("/");
|
|
||||||
left += QString::number(static_cast<qlonglong>(total));
|
|
||||||
left += QStringLiteral("] ");
|
|
||||||
} else {
|
|
||||||
left += QStringLiteral(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// buffer display name
|
|
||||||
std::string disp;
|
|
||||||
try {
|
|
||||||
disp = ed_->DisplayNameFor(*buf);
|
|
||||||
} catch (...) {
|
|
||||||
disp = buf->Filename();
|
|
||||||
}
|
|
||||||
if (disp.empty())
|
|
||||||
disp = "[No Name]";
|
|
||||||
left += QString::fromStdString(disp);
|
|
||||||
if (buf->Dirty())
|
|
||||||
left += QStringLiteral(" *");
|
|
||||||
|
|
||||||
// total lines suffix " <n>L"
|
|
||||||
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
|
|
||||||
left += QStringLiteral(" ");
|
|
||||||
left += QString::number(static_cast<qlonglong>(lcount));
|
|
||||||
left += QStringLiteral("L");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build right segment: cursor and mark
|
|
||||||
QString right;
|
|
||||||
if (buf) {
|
|
||||||
int row1 = static_cast<int>(buf->Cury()) + 1;
|
|
||||||
int col1 = static_cast<int>(buf->Curx()) + 1;
|
|
||||||
bool have_mark = buf->MarkSet();
|
|
||||||
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
|
|
||||||
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
|
|
||||||
if (have_mark)
|
|
||||||
right = QString("%1,%2 | M: %3,%4").arg(row1).arg(col1).arg(mrow1).arg(
|
|
||||||
mcol1);
|
|
||||||
else
|
|
||||||
right = QString("%1,%2 | M: not set").arg(row1).arg(col1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Middle message: status text
|
|
||||||
QString mid = QString::fromStdString(ed_->Status());
|
|
||||||
|
|
||||||
// Measure and layout
|
|
||||||
int left_w = fm.horizontalAdvance(left);
|
|
||||||
int right_w = fm.horizontalAdvance(right);
|
|
||||||
int lx = left_x;
|
|
||||||
int rx = std::max(left_x, right_x_max - right_w);
|
|
||||||
|
|
||||||
// If overlap, elide left to make space for right
|
|
||||||
if (lx + left_w + pad > rx) {
|
|
||||||
int max_left_w = std::max(0, rx - lx - pad);
|
|
||||||
left = fm.elidedText(left, Qt::ElideRight, max_left_w);
|
|
||||||
left_w = fm.horizontalAdvance(left);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw left and right
|
|
||||||
p.drawText(lx, baseline_y, left);
|
|
||||||
if (!right.isEmpty())
|
|
||||||
p.drawText(rx, baseline_y, right);
|
|
||||||
|
|
||||||
// Middle message clipped between end of left and start of right
|
|
||||||
int mid_left = lx + left_w + pad;
|
|
||||||
int mid_right = std::max(mid_left, rx - pad);
|
|
||||||
int mid_w = std::max(0, mid_right - mid_left);
|
|
||||||
if (mid_w > 0 && !mid.isEmpty()) {
|
|
||||||
QString mid_show = fm.elidedText(mid, Qt::ElideRight, mid_w);
|
|
||||||
p.save();
|
|
||||||
p.setClipRect(QRect(mid_left, bar_y, mid_w, status_h));
|
|
||||||
p.drawText(mid_left, baseline_y, mid_show);
|
|
||||||
p.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void resizeEvent(QResizeEvent *event) override
|
|
||||||
{
|
|
||||||
QWidget::resizeEvent(event);
|
|
||||||
if (!ed_)
|
|
||||||
return;
|
|
||||||
// Update editor dimensions based on new size
|
|
||||||
QFont f(font_family_, font_px_);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = std::max(12, fm.height());
|
|
||||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
|
|
||||||
const int pad_l = 8, pad_r = 8, pad_t = 6, pad_b = 6;
|
|
||||||
const int status_h = line_h + 6;
|
|
||||||
const int avail_w = std::max(0, width() - pad_l - pad_r);
|
|
||||||
const int avail_h = std::max(0, height() - pad_t - pad_b - status_h);
|
|
||||||
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h));
|
|
||||||
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
|
|
||||||
ed_->SetDimensions(rows, cols);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void wheelEvent(QWheelEvent *event) override
|
|
||||||
{
|
|
||||||
if (!ed_) {
|
|
||||||
QWidget::wheelEvent(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Buffer *buf = ed_->CurrentBuffer();
|
|
||||||
if (!buf) {
|
|
||||||
QWidget::wheelEvent(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recompute metrics to map pixel deltas to rows/cols
|
|
||||||
QFont f(font_family_, font_px_);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = std::max(12, fm.height());
|
|
||||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
|
|
||||||
|
|
||||||
// Determine scroll intent: use pixelDelta when available (trackpads), otherwise angleDelta
|
|
||||||
QPoint pixel = event->pixelDelta();
|
|
||||||
QPoint angle = event->angleDelta();
|
|
||||||
|
|
||||||
double v_lines_delta = 0.0;
|
|
||||||
double h_cols_delta = 0.0;
|
|
||||||
|
|
||||||
// Horizontal scroll with Shift or explicit horizontal delta
|
|
||||||
bool horiz_mode = (event->modifiers() & Qt::ShiftModifier) || (!pixel.isNull() && pixel.x() != 0) || (
|
|
||||||
!angle.isNull() && angle.x() != 0);
|
|
||||||
|
|
||||||
if (!pixel.isNull()) {
|
|
||||||
// Trackpad smooth scrolling (pixels)
|
|
||||||
v_lines_delta = -static_cast<double>(pixel.y()) / std::max(1, line_h);
|
|
||||||
h_cols_delta = -static_cast<double>(pixel.x()) / std::max(1, ch_w);
|
|
||||||
} else if (!angle.isNull()) {
|
|
||||||
// Mouse wheel: 120 units per notch; map one notch to 3 lines similar to ImGui UX
|
|
||||||
v_lines_delta = -static_cast<double>(angle.y()) / 120.0 * 3.0;
|
|
||||||
// For horizontal wheels, each notch scrolls 8 columns
|
|
||||||
h_cols_delta = -static_cast<double>(angle.x()) / 120.0 * 8.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accumulate fractional deltas across events
|
|
||||||
v_scroll_accum_ += v_lines_delta;
|
|
||||||
h_scroll_accum_ += h_cols_delta;
|
|
||||||
|
|
||||||
int d_rows = 0;
|
|
||||||
int d_cols = 0;
|
|
||||||
if (std::fabs(v_scroll_accum_) >= 1.0 && (!horiz_mode || std::fabs(v_scroll_accum_) > std::fabs(
|
|
||||||
h_scroll_accum_))) {
|
|
||||||
d_rows = static_cast<int>(v_scroll_accum_);
|
|
||||||
v_scroll_accum_ -= d_rows;
|
|
||||||
}
|
|
||||||
if (std::fabs(h_scroll_accum_) >= 1.0 && (horiz_mode || std::fabs(h_scroll_accum_) >= std::fabs(
|
|
||||||
v_scroll_accum_))) {
|
|
||||||
d_cols = static_cast<int>(h_scroll_accum_);
|
|
||||||
h_scroll_accum_ -= d_cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (d_rows != 0 || d_cols != 0) {
|
|
||||||
std::size_t new_rowoffs = buf->Rowoffs();
|
|
||||||
std::size_t new_coloffs = buf->Coloffs();
|
|
||||||
// Clamp vertical between 0 and last row (leaving at least one visible line)
|
|
||||||
if (d_rows != 0) {
|
|
||||||
long nr = static_cast<long>(new_rowoffs) + d_rows;
|
|
||||||
if (nr < 0)
|
|
||||||
nr = 0;
|
|
||||||
const auto nrows = static_cast<long>(buf->Rows().size());
|
|
||||||
if (nr > std::max(0L, nrows - 1))
|
|
||||||
nr = std::max(0L, nrows - 1);
|
|
||||||
new_rowoffs = static_cast<std::size_t>(nr);
|
|
||||||
}
|
|
||||||
if (d_cols != 0) {
|
|
||||||
long nc = static_cast<long>(new_coloffs) + d_cols;
|
|
||||||
if (nc < 0)
|
|
||||||
nc = 0;
|
|
||||||
new_coloffs = static_cast<std::size_t>(nc);
|
|
||||||
}
|
|
||||||
buf->SetOffsets(new_rowoffs, new_coloffs);
|
|
||||||
update();
|
|
||||||
event->accept();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QWidget::wheelEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void closeEvent(QCloseEvent *event) override
|
|
||||||
{
|
|
||||||
closed_ = true;
|
|
||||||
QWidget::closeEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
QtInputHandler &input_;
|
|
||||||
bool closed_ = false;
|
|
||||||
Editor *ed_ = nullptr;
|
|
||||||
double v_scroll_accum_ = 0.0;
|
|
||||||
double h_scroll_accum_ = 0.0;
|
|
||||||
QString font_family_ = QStringLiteral("Brass Mono");
|
|
||||||
int font_px_ = 18;
|
|
||||||
};
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool
|
|
||||||
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
|
||||||
{
|
|
||||||
app_ = new QApplication(argc, argv);
|
|
||||||
|
|
||||||
window_ = new MainWindow(input_);
|
|
||||||
window_->show();
|
|
||||||
// Ensure the window becomes the active, focused window so it receives key events
|
|
||||||
window_->activateWindow();
|
|
||||||
window_->raise();
|
|
||||||
window_->setFocus(Qt::OtherFocusReason);
|
|
||||||
|
|
||||||
renderer_.Attach(window_);
|
|
||||||
input_.Attach(&ed);
|
|
||||||
if (auto *mw = dynamic_cast<MainWindow *>(window_))
|
|
||||||
mw->SetEditor(&ed);
|
|
||||||
|
|
||||||
// Load GUI configuration (kge.ini) and configure font for Qt
|
|
||||||
config_ = GUIConfig::Load();
|
|
||||||
|
|
||||||
// Apply background mode from config to match ImGui frontend behavior
|
|
||||||
if (config_.background == "light")
|
|
||||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
|
||||||
else
|
|
||||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
|
||||||
|
|
||||||
// Apply theme by name for Qt palette-based theming (maps to named palettes).
|
|
||||||
// If unknown, falls back to the generic light/dark palette.
|
|
||||||
(void) kte::ApplyQtThemeByName(config_.theme);
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
|
|
||||||
// Map GUIConfig font name to a system family (Qt uses installed fonts)
|
|
||||||
auto choose_family = [](const std::string &name) -> QString {
|
|
||||||
QString fam;
|
|
||||||
std::string n = name;
|
|
||||||
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
|
|
||||||
return (char) std::tolower(c);
|
|
||||||
});
|
|
||||||
if (n.empty() || n == "default" || n == "brassmono" || n == "brassmonocode") {
|
|
||||||
fam = QStringLiteral("Brass Mono");
|
|
||||||
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
|
|
||||||
fam = QStringLiteral("JetBrains Mono");
|
|
||||||
} else if (n == "iosevka") {
|
|
||||||
fam = QStringLiteral("Iosevka");
|
|
||||||
} else if (n == "inconsolata" || n == "inconsolataex") {
|
|
||||||
fam = QStringLiteral("Inconsolata");
|
|
||||||
} else if (n == "space" || n == "spacemono" || n == "space mono") {
|
|
||||||
fam = QStringLiteral("Space Mono");
|
|
||||||
} else if (n == "go") {
|
|
||||||
fam = QStringLiteral("Go Mono");
|
|
||||||
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
|
|
||||||
fam = QStringLiteral("IBM Plex Mono");
|
|
||||||
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
|
|
||||||
fam = QStringLiteral("Fira Code");
|
|
||||||
} else if (!name.empty()) {
|
|
||||||
fam = QString::fromStdString(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate availability; choose a fallback if needed
|
|
||||||
const auto families = QFontDatabase::families();
|
|
||||||
if (!fam.isEmpty() && families.contains(fam)) {
|
|
||||||
return fam;
|
|
||||||
}
|
|
||||||
// Preferred fallback chain on macOS; otherwise, try common monospace families
|
|
||||||
const QStringList fallbacks = {
|
|
||||||
QStringLiteral("Brass Mono"),
|
|
||||||
QStringLiteral("JetBrains Mono"),
|
|
||||||
QStringLiteral("SF Mono"),
|
|
||||||
QStringLiteral("Menlo"),
|
|
||||||
QStringLiteral("Monaco"),
|
|
||||||
QStringLiteral("Courier New"),
|
|
||||||
QStringLiteral("Courier"),
|
|
||||||
QStringLiteral("Monospace")
|
|
||||||
};
|
|
||||||
for (const auto &fb: fallbacks) {
|
|
||||||
if (families.contains(fb))
|
|
||||||
return fb;
|
|
||||||
}
|
|
||||||
// As a last resort, return the request (Qt will substitute)
|
|
||||||
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
|
|
||||||
};
|
|
||||||
|
|
||||||
QString family = choose_family(config_.font);
|
|
||||||
int px_size = (config_.font_size > 0.0f) ? (int) std::lround(config_.font_size) : 18;
|
|
||||||
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
|
|
||||||
mw->SetFontFamilyAndSize(family, px_size);
|
|
||||||
}
|
|
||||||
// Track current font in globals for command/status queries
|
|
||||||
kte::gCurrentFontFamily = family.toStdString();
|
|
||||||
kte::gCurrentFontSize = static_cast<float>(px_size);
|
|
||||||
|
|
||||||
// Set initial dimensions based on font metrics
|
|
||||||
QFont f(family, px_size);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = std::max(12, fm.height());
|
|
||||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
|
|
||||||
const int w = window_->width();
|
|
||||||
const int h = window_->height();
|
|
||||||
const int pad = 16;
|
|
||||||
const int status_h = line_h + 4;
|
|
||||||
const int avail_w = std::max(0, w - 2 * pad);
|
|
||||||
const int avail_h = std::max(0, h - 2 * pad - status_h);
|
|
||||||
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
|
|
||||||
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
|
|
||||||
ed.SetDimensions(rows, cols);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void
|
|
||||||
GUIFrontend::Step(Editor &ed, bool &running)
|
|
||||||
{
|
|
||||||
// Pump Qt events
|
|
||||||
if (app_)
|
|
||||||
app_->processEvents();
|
|
||||||
|
|
||||||
// Allow deferred opens (including swap recovery prompts) to run.
|
|
||||||
ed.ProcessPendingOpens();
|
|
||||||
|
|
||||||
// Drain input queue
|
|
||||||
for (;;) {
|
|
||||||
MappedInput mi;
|
|
||||||
if (!input_.Poll(mi))
|
|
||||||
break;
|
|
||||||
if (mi.hasCommand) {
|
|
||||||
Execute(ed, mi.id, mi.arg, mi.count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ed.QuitRequested()) {
|
|
||||||
running = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Visual File Picker (Qt): invoked via CommandId::VisualFilePickerToggle ---
|
|
||||||
if (ed.FilePickerVisible()) {
|
|
||||||
QString startDir;
|
|
||||||
if (!ed.FilePickerDir().empty()) {
|
|
||||||
startDir = QString::fromStdString(ed.FilePickerDir());
|
|
||||||
}
|
|
||||||
QFileDialog dlg(window_, QStringLiteral("Open File"), startDir);
|
|
||||||
dlg.setFileMode(QFileDialog::ExistingFile);
|
|
||||||
if (dlg.exec() == QDialog::Accepted) {
|
|
||||||
const QStringList files = dlg.selectedFiles();
|
|
||||||
if (!files.isEmpty()) {
|
|
||||||
const QString fp = files.front();
|
|
||||||
ed.RequestOpenFile(fp.toStdString());
|
|
||||||
(void) ed.ProcessPendingOpens();
|
|
||||||
// Update picker dir for next time
|
|
||||||
QFileInfo info(fp);
|
|
||||||
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Close picker overlay regardless of outcome
|
|
||||||
ed.SetFilePickerVisible(false);
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply any queued theme change requests (from command handler)
|
|
||||||
if (kte::gThemeChangePending) {
|
|
||||||
if (!kte::gThemeChangeRequest.empty()) {
|
|
||||||
// Apply Qt palette theme by name; if unknown, keep current palette
|
|
||||||
(void) kte::ApplyQtThemeByName(kte::gThemeChangeRequest);
|
|
||||||
}
|
|
||||||
kte::gThemeChangePending = false;
|
|
||||||
kte::gThemeChangeRequest.clear();
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visual font picker request (Qt only)
|
|
||||||
if (kte::gFontDialogRequested) {
|
|
||||||
// Seed initial font from current or default
|
|
||||||
QFont seed;
|
|
||||||
if (!kte::gCurrentFontFamily.empty()) {
|
|
||||||
seed = QFont(QString::fromStdString(kte::gCurrentFontFamily),
|
|
||||||
(int) std::lround(kte::gCurrentFontSize > 0 ? kte::gCurrentFontSize : 18));
|
|
||||||
} else {
|
|
||||||
seed = window_ ? window_->font() : QFont();
|
|
||||||
}
|
|
||||||
bool ok = false;
|
|
||||||
const QFont chosen = QFontDialog::getFont(&ok, seed, window_, QStringLiteral("Choose Editor Font"));
|
|
||||||
if (ok) {
|
|
||||||
// Queue font change via existing hooks
|
|
||||||
kte::gFontFamilyRequest = chosen.family().toStdString();
|
|
||||||
// Use pixel size if available, otherwise convert from point size approximately
|
|
||||||
int px = chosen.pixelSize();
|
|
||||||
if (px <= 0) {
|
|
||||||
// Approximate points to pixels (96 DPI assumption); Qt will rasterize appropriately
|
|
||||||
px = (int) std::lround(chosen.pointSizeF() * 96.0 / 72.0);
|
|
||||||
if (px <= 0)
|
|
||||||
px = 18;
|
|
||||||
}
|
|
||||||
kte::gFontSizeRequest = static_cast<float>(px);
|
|
||||||
kte::gFontChangePending = true;
|
|
||||||
}
|
|
||||||
kte::gFontDialogRequested = false;
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply any queued font change requests (Qt)
|
|
||||||
if (kte::gFontChangePending) {
|
|
||||||
// Derive target family
|
|
||||||
auto map_family = [](const std::string &name) -> QString {
|
|
||||||
std::string n = name;
|
|
||||||
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
|
|
||||||
return (char) std::tolower(c);
|
|
||||||
});
|
|
||||||
QString fam;
|
|
||||||
if (n == "brass" || n == "brassmono" || n == "brass mono") {
|
|
||||||
fam = QStringLiteral("Brass Mono");
|
|
||||||
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
|
|
||||||
fam = QStringLiteral("JetBrains Mono");
|
|
||||||
} else if (n == "iosevka") {
|
|
||||||
fam = QStringLiteral("Iosevka");
|
|
||||||
} else if (n == "inconsolata" || n == "inconsolataex") {
|
|
||||||
fam = QStringLiteral("Inconsolata");
|
|
||||||
} else if (n == "space" || n == "spacemono" || n == "space mono") {
|
|
||||||
fam = QStringLiteral("Space Mono");
|
|
||||||
} else if (n == "go") {
|
|
||||||
fam = QStringLiteral("Go Mono");
|
|
||||||
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
|
|
||||||
fam = QStringLiteral("IBM Plex Mono");
|
|
||||||
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
|
|
||||||
fam = QStringLiteral("Fira Code");
|
|
||||||
} else if (!name.empty()) {
|
|
||||||
fam = QString::fromStdString(name);
|
|
||||||
}
|
|
||||||
// Validate availability; choose fallback if needed
|
|
||||||
const auto families = QFontDatabase::families();
|
|
||||||
if (!fam.isEmpty() && families.contains(fam)) {
|
|
||||||
return fam;
|
|
||||||
}
|
|
||||||
// Fallback chain
|
|
||||||
const QStringList fallbacks = {
|
|
||||||
QStringLiteral("Brass Mono"),
|
|
||||||
QStringLiteral("JetBrains Mono"),
|
|
||||||
QStringLiteral("SF Mono"),
|
|
||||||
QStringLiteral("Menlo"),
|
|
||||||
QStringLiteral("Monaco"),
|
|
||||||
QStringLiteral("Courier New"),
|
|
||||||
QStringLiteral("Courier"),
|
|
||||||
QStringLiteral("Monospace")
|
|
||||||
};
|
|
||||||
for (const auto &fb: fallbacks) {
|
|
||||||
if (families.contains(fb))
|
|
||||||
return fb;
|
|
||||||
}
|
|
||||||
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
|
|
||||||
};
|
|
||||||
|
|
||||||
QString target_family;
|
|
||||||
if (!kte::gFontFamilyRequest.empty()) {
|
|
||||||
target_family = map_family(kte::gFontFamilyRequest);
|
|
||||||
} else if (!kte::gCurrentFontFamily.empty()) {
|
|
||||||
target_family = QString::fromStdString(kte::gCurrentFontFamily);
|
|
||||||
}
|
|
||||||
int target_px = 0;
|
|
||||||
if (kte::gFontSizeRequest > 0.0f) {
|
|
||||||
target_px = (int) std::lround(kte::gFontSizeRequest);
|
|
||||||
} else if (kte::gCurrentFontSize > 0.0f) {
|
|
||||||
target_px = (int) std::lround(kte::gCurrentFontSize);
|
|
||||||
}
|
|
||||||
if (target_px <= 0)
|
|
||||||
target_px = 18;
|
|
||||||
if (target_family.isEmpty())
|
|
||||||
target_family = QStringLiteral("Monospace");
|
|
||||||
|
|
||||||
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
|
|
||||||
mw->SetFontFamilyAndSize(target_family, target_px);
|
|
||||||
}
|
|
||||||
// Update globals
|
|
||||||
kte::gCurrentFontFamily = target_family.toStdString();
|
|
||||||
kte::gCurrentFontSize = static_cast<float>(target_px);
|
|
||||||
// Reset requests
|
|
||||||
kte::gFontChangePending = false;
|
|
||||||
kte::gFontFamilyRequest.clear();
|
|
||||||
kte::gFontSizeRequest = 0.0f;
|
|
||||||
|
|
||||||
// Recompute editor dimensions to match new metrics
|
|
||||||
QFont f(target_family, target_px);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = std::max(12, fm.height());
|
|
||||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
|
|
||||||
const int w = window_ ? window_->width() : 0;
|
|
||||||
const int h = window_ ? window_->height() : 0;
|
|
||||||
const int pad = 16;
|
|
||||||
const int status_h = line_h + 4;
|
|
||||||
const int avail_w = std::max(0, w - 2 * pad);
|
|
||||||
const int avail_h = std::max(0, h - 2 * pad - status_h);
|
|
||||||
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
|
|
||||||
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
|
|
||||||
ed.SetDimensions(rows, cols);
|
|
||||||
|
|
||||||
if (window_)
|
|
||||||
window_->update();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw current frame (request repaint)
|
|
||||||
renderer_.Draw(ed);
|
|
||||||
|
|
||||||
// Detect window close
|
|
||||||
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
|
|
||||||
if (mw->WasClosed()) {
|
|
||||||
running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void
|
|
||||||
GUIFrontend::Shutdown()
|
|
||||||
{
|
|
||||||
if (window_) {
|
|
||||||
window_->close();
|
|
||||||
delete window_;
|
|
||||||
window_ = nullptr;
|
|
||||||
}
|
|
||||||
if (app_) {
|
|
||||||
delete app_;
|
|
||||||
app_ = nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/*
|
|
||||||
* QtFrontend - couples QtInputHandler + QtRenderer and owns Qt lifecycle
|
|
||||||
*/
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "Frontend.h"
|
|
||||||
#include "GUIConfig.h"
|
|
||||||
#include "QtInputHandler.h"
|
|
||||||
#include "QtRenderer.h"
|
|
||||||
|
|
||||||
class QApplication;
|
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
// Keep the public class name GUIFrontend to match main.cc selection logic.
|
|
||||||
class GUIFrontend final : public Frontend {
|
|
||||||
public:
|
|
||||||
GUIFrontend() = default;
|
|
||||||
|
|
||||||
~GUIFrontend() override = default;
|
|
||||||
|
|
||||||
bool Init(int &argc, char **argv, Editor &ed) override;
|
|
||||||
|
|
||||||
void Step(Editor &ed, bool &running) override;
|
|
||||||
|
|
||||||
void Shutdown() override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
GUIConfig config_{};
|
|
||||||
QtInputHandler input_{};
|
|
||||||
QtRenderer renderer_{};
|
|
||||||
|
|
||||||
QApplication *app_ = nullptr; // owned
|
|
||||||
QWidget *window_ = nullptr; // owned
|
|
||||||
int width_ = 1280;
|
|
||||||
int height_ = 800;
|
|
||||||
};
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
// Refactor: route all key processing through command subsystem, mirroring ImGuiInputHandler
|
|
||||||
|
|
||||||
#include "QtInputHandler.h"
|
|
||||||
|
|
||||||
#include <QKeyEvent>
|
|
||||||
|
|
||||||
#include <ncurses.h>
|
|
||||||
|
|
||||||
#include "Editor.h"
|
|
||||||
#include "KKeymap.h"
|
|
||||||
|
|
||||||
// Temporary verbose logging to debug macOS Qt key translation issues
|
|
||||||
// Default to off; enable by defining QT_IH_DEBUG=1 at compile time when needed.
|
|
||||||
#ifndef QT_IH_DEBUG
|
|
||||||
#define QT_IH_DEBUG 0
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if QT_IH_DEBUG
|
|
||||||
#include <cstdio>
|
|
||||||
static const char *
|
|
||||||
mods_str(Qt::KeyboardModifiers m)
|
|
||||||
{
|
|
||||||
static thread_local char buf[64];
|
|
||||||
buf[0] = '\0';
|
|
||||||
bool first = true;
|
|
||||||
auto add = [&](const char *s) {
|
|
||||||
if (!first)
|
|
||||||
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "|");
|
|
||||||
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "%s", s);
|
|
||||||
first = false;
|
|
||||||
};
|
|
||||||
if (m & Qt::ShiftModifier)
|
|
||||||
add("Shift");
|
|
||||||
if (m & Qt::ControlModifier)
|
|
||||||
add("Ctrl");
|
|
||||||
if (m & Qt::AltModifier)
|
|
||||||
add("Alt");
|
|
||||||
if (m & Qt::MetaModifier)
|
|
||||||
add("Meta");
|
|
||||||
if (first)
|
|
||||||
std::snprintf(buf, sizeof(buf), "none");
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
#define LOGF(...) std::fprintf(stderr, __VA_ARGS__)
|
|
||||||
#else
|
|
||||||
#define LOGF(...) ((void)0)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static bool
|
|
||||||
IsPrintableQt(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
// Printable if it yields non-empty text and no Ctrl/Meta modifier
|
|
||||||
if (e.modifiers() & (Qt::ControlModifier | Qt::MetaModifier))
|
|
||||||
return false;
|
|
||||||
const QString t = e.text();
|
|
||||||
return !t.isEmpty() && !t.at(0).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static int
|
|
||||||
ToAsciiKey(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
const QString t = e.text();
|
|
||||||
if (!t.isEmpty()) {
|
|
||||||
const QChar c = t.at(0);
|
|
||||||
if (!c.isNull())
|
|
||||||
return KLowerAscii(c.unicode());
|
|
||||||
}
|
|
||||||
// When modifiers (like Control) are held, Qt::text() can be empty on macOS.
|
|
||||||
// Fall back to mapping common virtual keys to ASCII.
|
|
||||||
switch (e.key()) {
|
|
||||||
case Qt::Key_A:
|
|
||||||
return 'a';
|
|
||||||
case Qt::Key_B:
|
|
||||||
return 'b';
|
|
||||||
case Qt::Key_C:
|
|
||||||
return 'c';
|
|
||||||
case Qt::Key_D:
|
|
||||||
return 'd';
|
|
||||||
case Qt::Key_E:
|
|
||||||
return 'e';
|
|
||||||
case Qt::Key_F:
|
|
||||||
return 'f';
|
|
||||||
case Qt::Key_G:
|
|
||||||
return 'g';
|
|
||||||
case Qt::Key_H:
|
|
||||||
return 'h';
|
|
||||||
case Qt::Key_I:
|
|
||||||
return 'i';
|
|
||||||
case Qt::Key_J:
|
|
||||||
return 'j';
|
|
||||||
case Qt::Key_K:
|
|
||||||
return 'k';
|
|
||||||
case Qt::Key_L:
|
|
||||||
return 'l';
|
|
||||||
case Qt::Key_M:
|
|
||||||
return 'm';
|
|
||||||
case Qt::Key_N:
|
|
||||||
return 'n';
|
|
||||||
case Qt::Key_O:
|
|
||||||
return 'o';
|
|
||||||
case Qt::Key_P:
|
|
||||||
return 'p';
|
|
||||||
case Qt::Key_Q:
|
|
||||||
return 'q';
|
|
||||||
case Qt::Key_R:
|
|
||||||
return 'r';
|
|
||||||
case Qt::Key_S:
|
|
||||||
return 's';
|
|
||||||
case Qt::Key_T:
|
|
||||||
return 't';
|
|
||||||
case Qt::Key_U:
|
|
||||||
return 'u';
|
|
||||||
case Qt::Key_V:
|
|
||||||
return 'v';
|
|
||||||
case Qt::Key_W:
|
|
||||||
return 'w';
|
|
||||||
case Qt::Key_X:
|
|
||||||
return 'x';
|
|
||||||
case Qt::Key_Y:
|
|
||||||
return 'y';
|
|
||||||
case Qt::Key_Z:
|
|
||||||
return 'z';
|
|
||||||
case Qt::Key_0:
|
|
||||||
return '0';
|
|
||||||
case Qt::Key_1:
|
|
||||||
return '1';
|
|
||||||
case Qt::Key_2:
|
|
||||||
return '2';
|
|
||||||
case Qt::Key_3:
|
|
||||||
return '3';
|
|
||||||
case Qt::Key_4:
|
|
||||||
return '4';
|
|
||||||
case Qt::Key_5:
|
|
||||||
return '5';
|
|
||||||
case Qt::Key_6:
|
|
||||||
return '6';
|
|
||||||
case Qt::Key_7:
|
|
||||||
return '7';
|
|
||||||
case Qt::Key_8:
|
|
||||||
return '8';
|
|
||||||
case Qt::Key_9:
|
|
||||||
return '9';
|
|
||||||
case Qt::Key_Comma:
|
|
||||||
return ',';
|
|
||||||
case Qt::Key_Period:
|
|
||||||
return '.';
|
|
||||||
case Qt::Key_Semicolon:
|
|
||||||
return ';';
|
|
||||||
case Qt::Key_Apostrophe:
|
|
||||||
return '\'';
|
|
||||||
case Qt::Key_Minus:
|
|
||||||
return '-';
|
|
||||||
case Qt::Key_Equal:
|
|
||||||
return '=';
|
|
||||||
case Qt::Key_Slash:
|
|
||||||
return '/';
|
|
||||||
case Qt::Key_Backslash:
|
|
||||||
return '\\';
|
|
||||||
case Qt::Key_BracketLeft:
|
|
||||||
return '[';
|
|
||||||
case Qt::Key_BracketRight:
|
|
||||||
return ']';
|
|
||||||
case Qt::Key_QuoteLeft:
|
|
||||||
return '`';
|
|
||||||
case Qt::Key_Space:
|
|
||||||
return ' ';
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Case-preserving ASCII derivation for k-prefix handling where we need to
|
|
||||||
// distinguish between 'C' and 'c'. Falls back to virtual-key mapping if
|
|
||||||
// event text is unavailable (common when Control/Meta held on macOS).
|
|
||||||
static int
|
|
||||||
ToAsciiKeyPreserveCase(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
const QString t = e.text();
|
|
||||||
if (!t.isEmpty()) {
|
|
||||||
const QChar c = t.at(0);
|
|
||||||
if (!c.isNull())
|
|
||||||
return c.unicode();
|
|
||||||
}
|
|
||||||
// Fall back to virtual key mapping (letters as uppercase A..Z)
|
|
||||||
switch (e.key()) {
|
|
||||||
case Qt::Key_A:
|
|
||||||
return 'A';
|
|
||||||
case Qt::Key_B:
|
|
||||||
return 'B';
|
|
||||||
case Qt::Key_C:
|
|
||||||
return 'C';
|
|
||||||
case Qt::Key_D:
|
|
||||||
return 'D';
|
|
||||||
case Qt::Key_E:
|
|
||||||
return 'E';
|
|
||||||
case Qt::Key_F:
|
|
||||||
return 'F';
|
|
||||||
case Qt::Key_G:
|
|
||||||
return 'G';
|
|
||||||
case Qt::Key_H:
|
|
||||||
return 'H';
|
|
||||||
case Qt::Key_I:
|
|
||||||
return 'I';
|
|
||||||
case Qt::Key_J:
|
|
||||||
return 'J';
|
|
||||||
case Qt::Key_K:
|
|
||||||
return 'K';
|
|
||||||
case Qt::Key_L:
|
|
||||||
return 'L';
|
|
||||||
case Qt::Key_M:
|
|
||||||
return 'M';
|
|
||||||
case Qt::Key_N:
|
|
||||||
return 'N';
|
|
||||||
case Qt::Key_O:
|
|
||||||
return 'O';
|
|
||||||
case Qt::Key_P:
|
|
||||||
return 'P';
|
|
||||||
case Qt::Key_Q:
|
|
||||||
return 'Q';
|
|
||||||
case Qt::Key_R:
|
|
||||||
return 'R';
|
|
||||||
case Qt::Key_S:
|
|
||||||
return 'S';
|
|
||||||
case Qt::Key_T:
|
|
||||||
return 'T';
|
|
||||||
case Qt::Key_U:
|
|
||||||
return 'U';
|
|
||||||
case Qt::Key_V:
|
|
||||||
return 'V';
|
|
||||||
case Qt::Key_W:
|
|
||||||
return 'W';
|
|
||||||
case Qt::Key_X:
|
|
||||||
return 'X';
|
|
||||||
case Qt::Key_Y:
|
|
||||||
return 'Y';
|
|
||||||
case Qt::Key_Z:
|
|
||||||
return 'Z';
|
|
||||||
case Qt::Key_Comma:
|
|
||||||
return ',';
|
|
||||||
case Qt::Key_Period:
|
|
||||||
return '.';
|
|
||||||
case Qt::Key_Semicolon:
|
|
||||||
return ';';
|
|
||||||
case Qt::Key_Apostrophe:
|
|
||||||
return '\'';
|
|
||||||
case Qt::Key_Minus:
|
|
||||||
return '-';
|
|
||||||
case Qt::Key_Equal:
|
|
||||||
return '=';
|
|
||||||
case Qt::Key_Slash:
|
|
||||||
return '/';
|
|
||||||
case Qt::Key_Backslash:
|
|
||||||
return '\\';
|
|
||||||
case Qt::Key_BracketLeft:
|
|
||||||
return '[';
|
|
||||||
case Qt::Key_BracketRight:
|
|
||||||
return ']';
|
|
||||||
case Qt::Key_QuoteLeft:
|
|
||||||
return '`';
|
|
||||||
case Qt::Key_Space:
|
|
||||||
return ' ';
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
bool
|
|
||||||
QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
|
|
||||||
{
|
|
||||||
const Qt::KeyboardModifiers mods = e.modifiers();
|
|
||||||
LOGF("[QtIH] keyPress key=0x%X mods=%s text='%s' k_prefix=%d k_ctrl_pending=%d esc_meta=%d\n",
|
|
||||||
e.key(), mods_str(mods), e.text().toUtf8().constData(), (int)k_prefix_, (int)k_ctrl_pending_,
|
|
||||||
(int)esc_meta_);
|
|
||||||
|
|
||||||
// Control-chord detection: only treat the physical Control key as control-like.
|
|
||||||
// Do NOT include Meta (Command) here so that ⌘-letter shortcuts do not fall into
|
|
||||||
// the Ctrl map (prevents ⌘-T being mistaken for C-t).
|
|
||||||
const bool ctrl_like = (mods & Qt::ControlModifier);
|
|
||||||
|
|
||||||
// 1) Universal argument digits (when active), consume digits without enqueuing commands
|
|
||||||
if (ed_ && ed_
|
|
||||||
|
|
||||||
->
|
|
||||||
UArg() != 0
|
|
||||||
) {
|
|
||||||
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
|
|
||||||
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
|
|
||||||
int d = e.key() - Qt::Key_0;
|
|
||||||
ed_->UArgDigit(d);
|
|
||||||
// request status refresh
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::UArgStatus, std::string(), 0});
|
|
||||||
LOGF("[QtIH] UArg digit %d -> enqueue UArgStatus\n", d);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Enter k-prefix on C-k
|
|
||||||
if (ctrl_like && (e.key() == Qt::Key_K)) {
|
|
||||||
k_prefix_ = true;
|
|
||||||
k_ctrl_pending_ = false;
|
|
||||||
LOGF("[QtIH] Enter KPrefix\n");
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::KPrefix, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) If currently in k-prefix, resolve next key via KLookupKCommand
|
|
||||||
if (k_prefix_) {
|
|
||||||
// ESC/meta prefix should not interfere with k-suffix resolution
|
|
||||||
esc_meta_ = false;
|
|
||||||
// Support literal 'C' (uppercase) or '^' to indicate the next key is Ctrl-qualified.
|
|
||||||
// Use case-preserving derivation so that 'c' (lowercase) can still be a valid suffix
|
|
||||||
// like C-k c (BufferClose).
|
|
||||||
int ascii_raw = ToAsciiKeyPreserveCase(e);
|
|
||||||
if (ascii_raw == 'C' || ascii_raw == '^') {
|
|
||||||
k_ctrl_pending_ = true;
|
|
||||||
if (ed_)
|
|
||||||
ed_->SetStatus("C-k C _");
|
|
||||||
LOGF("[QtIH] KPrefix: set k_ctrl_pending via '%c'\n", (ascii_raw == 'C') ? 'C' : '^');
|
|
||||||
return true; // consume, wait for next key
|
|
||||||
}
|
|
||||||
int ascii_key = (ascii_raw != 0) ? ascii_raw : ToAsciiKey(e);
|
|
||||||
int lower = KLowerAscii(ascii_key);
|
|
||||||
// Only pass a control suffix for specific supported keys (d/x/q),
|
|
||||||
// matching ImGui behavior so that holding Ctrl during the suffix
|
|
||||||
// doesn't break other mappings like C-k c (BufferClose).
|
|
||||||
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
|
|
||||||
bool pass_ctrl = (ctrl_like || k_ctrl_pending_) && ctrl_suffix_supported;
|
|
||||||
k_ctrl_pending_ = false; // consume pending qualifier on any suffix
|
|
||||||
LOGF("[QtIH] KPrefix: ascii_key=%d lower=%d pass_ctrl=%d\n", ascii_key, lower, (int)pass_ctrl);
|
|
||||||
if (ascii_key != 0) {
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
|
|
||||||
LOGF("[QtIH] KPrefix: mapped to command id=%d\n", (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
} else {
|
|
||||||
// Unknown k-command: notify
|
|
||||||
std::string a;
|
|
||||||
a.push_back(static_cast<char>(ascii_key));
|
|
||||||
LOGF("[QtIH] KPrefix: unknown command for '%c'\n", (char)ascii_key);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::UnknownKCommand, a, 0});
|
|
||||||
}
|
|
||||||
k_prefix_ = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// If not resolvable, consume and exit k-prefix
|
|
||||||
k_prefix_ = false;
|
|
||||||
LOGF("[QtIH] KPrefix: unresolved key; exiting prefix\n");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3.5) GUI shortcut: Command/Meta + T opens the visual font picker (Qt only).
|
|
||||||
// Require Meta present and Control NOT present so Ctrl-T never triggers this.
|
|
||||||
if ((mods & Qt::MetaModifier) && !(mods & Qt::ControlModifier) && e.key() == Qt::Key_T) {
|
|
||||||
LOGF("[QtIH] Meta/Super-T -> VisualFontPickerToggle\n");
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::VisualFontPickerToggle, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) ESC as Meta prefix (set state). Alt/Meta chord handled below directly.
|
|
||||||
if (e.key() == Qt::Key_Escape) {
|
|
||||||
esc_meta_ = true;
|
|
||||||
LOGF("[QtIH] ESC: set esc_meta\n");
|
|
||||||
return true; // consumed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Alt/Meta bindings (ESC f/b equivalent). Handle either Alt/Meta or pending esc_meta_
|
|
||||||
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
|
|
||||||
#if defined(__APPLE__)
|
|
||||||
if (esc_meta_ || (mods & Qt::AltModifier)) {
|
|
||||||
#else
|
|
||||||
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
|
|
||||||
|
|
||||||
#endif
|
|
||||||
int ascii_key = 0;
|
|
||||||
if (e.key() == Qt::Key_Backspace) {
|
|
||||||
ascii_key = KEY_BACKSPACE;
|
|
||||||
} else if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
|
|
||||||
ascii_key = 'a' + (e.key() - Qt::Key_A);
|
|
||||||
} else if (e.key() == Qt::Key_Comma) {
|
|
||||||
ascii_key = '<';
|
|
||||||
} else if (e.key() == Qt::Key_Period) {
|
|
||||||
ascii_key = '>';
|
|
||||||
}
|
|
||||||
// If still unknown, try deriving from text (covers digits, punctuation, locale)
|
|
||||||
if (ascii_key == 0) {
|
|
||||||
ascii_key = ToAsciiKey(e);
|
|
||||||
}
|
|
||||||
esc_meta_ = false; // one-shot regardless
|
|
||||||
if (ascii_key != 0) {
|
|
||||||
ascii_key = KLowerAscii(ascii_key);
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupEscCommand(ascii_key, id)) {
|
|
||||||
LOGF("[QtIH] ESC/Meta: mapped '%d' -> id=%d\n", ascii_key, (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
// Report invalid ESC sequence just like ImGui path
|
|
||||||
LOGF("[QtIH] ESC/Meta: unknown command for ascii=%d\n", ascii_key);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::UnknownEscCommand, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nothing derivable: consume (ESC prefix cleared) and do not insert text
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Control-chord direct mappings (e.g., C-n/C-p/C-f/C-b...)
|
|
||||||
if (ctrl_like) {
|
|
||||||
// Universal argument handling: C-u starts collection; C-g cancels
|
|
||||||
if (e.key() == Qt::Key_U) {
|
|
||||||
if (ed_)
|
|
||||||
ed_->UArgStart();
|
|
||||||
LOGF("[QtIH] Ctrl-chord: start universal argument\n");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (e.key() == Qt::Key_G) {
|
|
||||||
if (ed_)
|
|
||||||
ed_->UArgClear();
|
|
||||||
k_ctrl_pending_ = false;
|
|
||||||
k_prefix_ = false;
|
|
||||||
LOGF("[QtIH] Ctrl-chord: cancel universal argument and k-prefix via C-g\n");
|
|
||||||
// Fall through to map C-g to Refresh via ctrl map
|
|
||||||
}
|
|
||||||
if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
|
|
||||||
int ascii_key = 'a' + (e.key() - Qt::Key_A);
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupCtrlCommand(ascii_key, id)) {
|
|
||||||
LOGF("[QtIH] Ctrl-chord: 'C-%c' -> id=%d\n", (char)ascii_key, (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no mapping, continue to allow other keys below
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7) Special navigation/edit keys (match ImGui behavior)
|
|
||||||
{
|
|
||||||
CommandId id;
|
|
||||||
bool has = false;
|
|
||||||
switch (e.key()) {
|
|
||||||
case Qt::Key_Return:
|
|
||||||
case Qt::Key_Enter:
|
|
||||||
id = CommandId::Newline;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Backspace:
|
|
||||||
id = CommandId::Backspace;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Delete:
|
|
||||||
id = CommandId::DeleteChar;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Left:
|
|
||||||
id = CommandId::MoveLeft;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Right:
|
|
||||||
id = CommandId::MoveRight;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Up:
|
|
||||||
id = CommandId::MoveUp;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Down:
|
|
||||||
id = CommandId::MoveDown;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_Home:
|
|
||||||
id = CommandId::MoveHome;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_End:
|
|
||||||
id = CommandId::MoveEnd;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_PageUp:
|
|
||||||
id = CommandId::PageUp;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
case Qt::Key_PageDown:
|
|
||||||
id = CommandId::PageDown;
|
|
||||||
has = true;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (has) {
|
|
||||||
LOGF("[QtIH] Special key -> id=%d\n", (int)id);
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8) Insert printable text
|
|
||||||
if (IsPrintableQt(e)) {
|
|
||||||
std::string s = e.text().toStdString();
|
|
||||||
if (!s.empty()) {
|
|
||||||
LOGF("[QtIH] InsertText '%s'\n", s.c_str());
|
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
|
||||||
q_.push(MappedInput{true, CommandId::InsertText, s, 0});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGF("[QtIH] Unhandled key\n");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
bool
|
|
||||||
QtInputHandler::Poll(MappedInput &out)
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
|
||||||
if (q_.empty())
|
|
||||||
return false;
|
|
||||||
out = q_.front();
|
|
||||||
q_.pop();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/*
|
|
||||||
* QtInputHandler - Qt-based input mapping for GUI mode
|
|
||||||
*/
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <mutex>
|
|
||||||
#include <queue>
|
|
||||||
|
|
||||||
#include "InputHandler.h"
|
|
||||||
|
|
||||||
class QKeyEvent;
|
|
||||||
|
|
||||||
class QtInputHandler final : public InputHandler {
|
|
||||||
public:
|
|
||||||
QtInputHandler() = default;
|
|
||||||
|
|
||||||
~QtInputHandler() override = default;
|
|
||||||
|
|
||||||
|
|
||||||
void Attach(Editor *ed) override
|
|
||||||
{
|
|
||||||
ed_ = ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Translate a Qt key event to editor command and enqueue if applicable.
|
|
||||||
// Returns true if it produced a mapped command or consumed input.
|
|
||||||
bool ProcessKeyEvent(const QKeyEvent &e);
|
|
||||||
|
|
||||||
bool Poll(MappedInput &out) override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::mutex mu_;
|
|
||||||
std::queue<MappedInput> q_;
|
|
||||||
bool k_prefix_ = false;
|
|
||||||
bool k_ctrl_pending_ = false; // C-k C-… qualifier
|
|
||||||
bool esc_meta_ = false; // ESC-prefix for next key
|
|
||||||
bool suppress_text_input_once_ = false; // reserved (Qt sends text in keyPressEvent)
|
|
||||||
Editor *ed_ = nullptr;
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
#include "QtRenderer.h"
|
|
||||||
|
|
||||||
#include <QWidget>
|
|
||||||
#include <QPainter>
|
|
||||||
#include <QPaintEvent>
|
|
||||||
#include <QFont>
|
|
||||||
#include <QFontMetrics>
|
|
||||||
|
|
||||||
#include "Editor.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
class EditorWidget : public QWidget {
|
|
||||||
public:
|
|
||||||
explicit EditorWidget(QWidget *parent = nullptr) : QWidget(parent)
|
|
||||||
{
|
|
||||||
setAttribute(Qt::WA_OpaquePaintEvent);
|
|
||||||
setFocusPolicy(Qt::StrongFocus);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetEditor(Editor *ed)
|
|
||||||
{
|
|
||||||
ed_ = ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void paintEvent(QPaintEvent *event) override
|
|
||||||
{
|
|
||||||
Q_UNUSED(event);
|
|
||||||
QPainter p(this);
|
|
||||||
// Background
|
|
||||||
const QColor bg(28, 28, 30);
|
|
||||||
p.fillRect(rect(), bg);
|
|
||||||
|
|
||||||
// Font and metrics
|
|
||||||
QFont f("JetBrains Mono", 13);
|
|
||||||
p.setFont(f);
|
|
||||||
QFontMetrics fm(f);
|
|
||||||
const int line_h = fm.height();
|
|
||||||
|
|
||||||
// Title
|
|
||||||
p.setPen(QColor(220, 220, 220));
|
|
||||||
p.drawText(8, fm.ascent() + 4, QStringLiteral("kte (Qt frontend)"));
|
|
||||||
|
|
||||||
// Status bar at bottom
|
|
||||||
const int bar_h = line_h + 6; // padding
|
|
||||||
const int bar_y = height() - bar_h;
|
|
||||||
QRect status_rect(0, bar_y, width(), bar_h);
|
|
||||||
p.fillRect(status_rect, QColor(40, 40, 44));
|
|
||||||
p.setPen(QColor(180, 180, 140));
|
|
||||||
if (ed_) {
|
|
||||||
const QString status = QString::fromStdString(ed_->Status());
|
|
||||||
// draw at baseline within the bar
|
|
||||||
const int baseline = bar_y + 3 + fm.ascent();
|
|
||||||
p.drawText(8, baseline, status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
Editor *ed_ = nullptr;
|
|
||||||
};
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void
|
|
||||||
QtRenderer::Draw(Editor &ed)
|
|
||||||
{
|
|
||||||
if (!widget_)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// If our widget is an EditorWidget, pass the editor pointer for painting
|
|
||||||
if (auto *ew = dynamic_cast<EditorWidget *>(widget_)) {
|
|
||||||
ew->SetEditor(&ed);
|
|
||||||
}
|
|
||||||
// Request a repaint
|
|
||||||
widget_->update();
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/*
|
|
||||||
* QtRenderer - minimal Qt-based renderer
|
|
||||||
*/
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "Renderer.h"
|
|
||||||
|
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
class QtRenderer final : public Renderer {
|
|
||||||
public:
|
|
||||||
QtRenderer() = default;
|
|
||||||
|
|
||||||
~QtRenderer() override = default;
|
|
||||||
|
|
||||||
|
|
||||||
void Attach(QWidget *widget)
|
|
||||||
{
|
|
||||||
widget_ = widget;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void Draw(Editor &ed) override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
QWidget *widget_ = nullptr; // not owned
|
|
||||||
};
|
|
||||||
+4
-5
@@ -848,7 +848,6 @@ pub enum CommandId {
|
|||||||
|
|
||||||
// GUI-specific
|
// GUI-specific
|
||||||
VisualFilePickerToggle,
|
VisualFilePickerToggle,
|
||||||
VisualFontPickerToggle,
|
|
||||||
ThemeNext,
|
ThemeNext,
|
||||||
ThemePrev,
|
ThemePrev,
|
||||||
ThemeSetByName,
|
ThemeSetByName,
|
||||||
@@ -938,7 +937,7 @@ pub enum CommandError {
|
|||||||
**Current C++ Implementation:**
|
**Current C++ Implementation:**
|
||||||
|
|
||||||
- `Frontend` trait with Init/Step/Shutdown lifecycle
|
- `Frontend` trait with Init/Step/Shutdown lifecycle
|
||||||
- Three implementations: Terminal (ncurses), ImGui (SDL2/OpenGL), Qt
|
- Two implementations: Terminal (ncurses), ImGui (SDL2/OpenGL)
|
||||||
- Step() does one iteration: poll input, dispatch commands, render
|
- Step() does one iteration: poll input, dispatch commands, render
|
||||||
- Each frontend owns InputHandler and Renderer instances
|
- Each frontend owns InputHandler and Renderer instances
|
||||||
|
|
||||||
@@ -986,7 +985,7 @@ pub enum FrontendError {
|
|||||||
- `InputHandler` trait with Poll() returning `MappedInput`
|
- `InputHandler` trait with Poll() returning `MappedInput`
|
||||||
- `MappedInput`: hasCommand bool, CommandId, arg string, count
|
- `MappedInput`: hasCommand bool, CommandId, arg string, count
|
||||||
- Terminal: ncurses-based, translates keys to commands
|
- Terminal: ncurses-based, translates keys to commands
|
||||||
- ImGui/Qt: event-based, handles text input differently
|
- ImGui: event-based, handles text input differently
|
||||||
- Keymap lookups via `KKeymap` functions (KCommand, Ctrl, Esc)
|
- Keymap lookups via `KKeymap` functions (KCommand, Ctrl, Esc)
|
||||||
|
|
||||||
**Rust Design:**
|
**Rust Design:**
|
||||||
@@ -1075,7 +1074,7 @@ impl InputHandler for TerminalInputHandler {
|
|||||||
|
|
||||||
- `Renderer` trait with single Draw(Editor&) method
|
- `Renderer` trait with single Draw(Editor&) method
|
||||||
- Terminal: ncurses-based, manual cursor positioning
|
- Terminal: ncurses-based, manual cursor positioning
|
||||||
- ImGui/Qt: retained mode GUI, draw calls within Begin/End blocks
|
- ImGui: immediate mode GUI, draw calls within Begin/End blocks
|
||||||
- Syntax highlighting integrated via Buffer::Highlighter()
|
- Syntax highlighting integrated via Buffer::Highlighter()
|
||||||
|
|
||||||
**Rust Design:**
|
**Rust Design:**
|
||||||
@@ -1200,7 +1199,7 @@ impl Frontend for TerminalFrontend {
|
|||||||
|
|
||||||
#### 9.2 GUI Frontend (egui-based)
|
#### 9.2 GUI Frontend (egui-based)
|
||||||
|
|
||||||
For GUI, consider replacing ImGui/Qt with `egui` (pure Rust, immediate
|
For GUI, consider replacing ImGui with `egui` (pure Rust, immediate
|
||||||
mode):
|
mode):
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
|||||||
+344
@@ -0,0 +1,344 @@
|
|||||||
|
#include "RegexEngine.h"
|
||||||
|
|
||||||
|
#include <regex>
|
||||||
|
|
||||||
|
#include "RegexGuard.h"
|
||||||
|
|
||||||
|
#if defined(KTE_USE_PCRE2)
|
||||||
|
#define PCRE2_CODE_UNIT_WIDTH 8
|
||||||
|
#include <pcre2.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
namespace {
|
||||||
|
// Expand an ECMAScript replacement for one match, as std::regex_replace
|
||||||
|
// does: $` is the text since the previous match (match_results::prefix()).
|
||||||
|
// group(i, begin, end) returns whether group i participated and where;
|
||||||
|
// ngroups counts group 0.
|
||||||
|
template<typename GroupFn>
|
||||||
|
void
|
||||||
|
append_format(std::string &out, std::string_view fmt, std::string_view subject, std::size_t prev_end,
|
||||||
|
std::size_t ms, std::size_t me, std::size_t ngroups, GroupFn &&group)
|
||||||
|
{
|
||||||
|
for (std::size_t i = 0; i < fmt.size(); ++i) {
|
||||||
|
const char c = fmt[i];
|
||||||
|
if (c != '$' || i + 1 >= fmt.size()) {
|
||||||
|
out.push_back(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const char n = fmt[i + 1];
|
||||||
|
if (n == '$') {
|
||||||
|
out.push_back('$');
|
||||||
|
++i;
|
||||||
|
} else if (n == '&') {
|
||||||
|
out.append(subject.substr(ms, me - ms));
|
||||||
|
++i;
|
||||||
|
} else if (n == '`') {
|
||||||
|
out.append(subject.substr(prev_end, ms - prev_end));
|
||||||
|
++i;
|
||||||
|
} else if (n == '\'') {
|
||||||
|
out.append(subject.substr(me));
|
||||||
|
++i;
|
||||||
|
} else if (n >= '0' && n <= '9') {
|
||||||
|
// As libstdc++'s match_results::format: up to two digits, read
|
||||||
|
// greedily; $0 is the whole match; a group the pattern does not
|
||||||
|
// have expands to nothing.
|
||||||
|
std::size_t idx = static_cast<std::size_t>(n - '0');
|
||||||
|
++i;
|
||||||
|
if (i + 1 < fmt.size() && fmt[i + 1] >= '0' && fmt[i + 1] <= '9') {
|
||||||
|
idx = idx * 10 + static_cast<std::size_t>(fmt[i + 1] - '0');
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
std::size_t b = 0, e = 0;
|
||||||
|
if (idx == 0)
|
||||||
|
out.append(subject.substr(ms, me - ms));
|
||||||
|
else if (idx < ngroups && group(idx, b, e))
|
||||||
|
out.append(subject.substr(b, e - b));
|
||||||
|
} else {
|
||||||
|
out.push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(KTE_USE_PCRE2)
|
||||||
|
|
||||||
|
// PCRE2 gives up on a match attempt after this many internal steps
|
||||||
|
// (catastrophic backtracking), reporting it instead of running for ages.
|
||||||
|
static constexpr std::uint32_t kMatchLimit = 10000000;
|
||||||
|
|
||||||
|
|
||||||
|
struct Regex::Impl {
|
||||||
|
pcre2_code *code = nullptr;
|
||||||
|
pcre2_match_data *md = nullptr;
|
||||||
|
pcre2_match_context *mctx = nullptr;
|
||||||
|
std::size_t ngroups = 1; // including group 0
|
||||||
|
mutable bool limit_hit = false;
|
||||||
|
|
||||||
|
|
||||||
|
~Impl()
|
||||||
|
{
|
||||||
|
if (md)
|
||||||
|
pcre2_match_data_free(md);
|
||||||
|
if (mctx)
|
||||||
|
pcre2_match_context_free(mctx);
|
||||||
|
if (code)
|
||||||
|
pcre2_code_free(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// pcre2_match with a subject that is never a null pointer.
|
||||||
|
int match(std::string_view s, std::size_t from, std::uint32_t opts) const
|
||||||
|
{
|
||||||
|
static const char empty = '\0';
|
||||||
|
const char *p = s.data() ? s.data() : ∅
|
||||||
|
const int rc = pcre2_match(code, reinterpret_cast<PCRE2_SPTR>(p), s.size(), from, opts, md, mctx);
|
||||||
|
if (rc == PCRE2_ERROR_MATCHLIMIT || rc == PCRE2_ERROR_DEPTHLIMIT || rc == PCRE2_ERROR_HEAPLIMIT)
|
||||||
|
limit_hit = true;
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
Regex::Regex() = default;
|
||||||
|
|
||||||
|
Regex::~Regex() = default;
|
||||||
|
|
||||||
|
Regex::Regex(Regex &&other) noexcept = default;
|
||||||
|
|
||||||
|
Regex &Regex::operator=(Regex &&other) noexcept = default;
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Regex::Compile(std::string_view pattern, std::string &err)
|
||||||
|
{
|
||||||
|
err.clear();
|
||||||
|
impl_.reset();
|
||||||
|
auto impl = std::make_unique<Impl>();
|
||||||
|
int errcode = 0;
|
||||||
|
PCRE2_SIZE erroff = 0;
|
||||||
|
static const char empty = '\0';
|
||||||
|
// As in ECMAScript (std::regex): '.' matches neither CR nor LF, and '$'
|
||||||
|
// matches only at the end (PCRE2's default newline is LF alone, so '.'
|
||||||
|
// matched the CR of a CRLF line and replace-all deleted it).
|
||||||
|
pcre2_compile_context *cctx = pcre2_compile_context_create(nullptr);
|
||||||
|
if (cctx)
|
||||||
|
pcre2_set_newline(cctx, PCRE2_NEWLINE_ANYCRLF);
|
||||||
|
impl->code = pcre2_compile(reinterpret_cast<PCRE2_SPTR>(pattern.data() ? pattern.data() : &empty),
|
||||||
|
pattern.size(), PCRE2_DOLLAR_ENDONLY, &errcode, &erroff, cctx);
|
||||||
|
if (cctx)
|
||||||
|
pcre2_compile_context_free(cctx);
|
||||||
|
if (!impl->code) {
|
||||||
|
PCRE2_UCHAR msg[256];
|
||||||
|
pcre2_get_error_message(errcode, msg, sizeof(msg));
|
||||||
|
err = std::string(reinterpret_cast<const char *>(msg)) + " at offset " + std::to_string(erroff);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
(void) pcre2_jit_compile(impl->code, PCRE2_JIT_COMPLETE); // interpreted if unavailable
|
||||||
|
impl->md = pcre2_match_data_create_from_pattern(impl->code, nullptr);
|
||||||
|
impl->mctx = pcre2_match_context_create(nullptr);
|
||||||
|
if (!impl->md || !impl->mctx) {
|
||||||
|
err = "out of memory compiling the pattern";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pcre2_set_match_limit(impl->mctx, kMatchLimit);
|
||||||
|
std::uint32_t captures = 0;
|
||||||
|
(void) pcre2_pattern_info(impl->code, PCRE2_INFO_CAPTURECOUNT, &captures);
|
||||||
|
impl->ngroups = static_cast<std::size_t>(captures) + 1;
|
||||||
|
impl_ = std::move(impl);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Regex::Search(std::string_view subject, std::size_t from, std::size_t &pos, std::size_t &len) const
|
||||||
|
{
|
||||||
|
if (!impl_ || from > subject.size())
|
||||||
|
return false;
|
||||||
|
impl_->limit_hit = false;
|
||||||
|
if (impl_->match(subject, from, 0) <= 0)
|
||||||
|
return false;
|
||||||
|
const PCRE2_SIZE *ov = pcre2_get_ovector_pointer(impl_->md);
|
||||||
|
pos = ov[0];
|
||||||
|
len = ov[1] - ov[0];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
Regex::ReplaceAll(std::string_view s, std::string_view fmt) const
|
||||||
|
{
|
||||||
|
if (!impl_)
|
||||||
|
return std::string(s);
|
||||||
|
impl_->limit_hit = false;
|
||||||
|
std::string out;
|
||||||
|
std::size_t copied = 0; // subject bytes up to here are in `out`
|
||||||
|
std::size_t start = 0;
|
||||||
|
std::uint32_t opts = 0;
|
||||||
|
while (start <= s.size()) {
|
||||||
|
const int rc = impl_->match(s, start, opts);
|
||||||
|
if (rc == PCRE2_ERROR_NOMATCH && opts != 0) {
|
||||||
|
// No non-empty match right after an empty one: step past a byte.
|
||||||
|
if (start >= s.size())
|
||||||
|
break;
|
||||||
|
++start;
|
||||||
|
opts = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rc <= 0) {
|
||||||
|
if (impl_->limit_hit)
|
||||||
|
return std::string(s); // gave up: leave the text as it was
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const PCRE2_SIZE *ov = pcre2_get_ovector_pointer(impl_->md);
|
||||||
|
const std::size_t ms = ov[0], me = ov[1];
|
||||||
|
out.append(s.substr(copied, ms - copied));
|
||||||
|
append_format(out, fmt, s, copied, ms, me, impl_->ngroups, [&](std::size_t i, std::size_t &b, std::size_t &e) {
|
||||||
|
if (i >= static_cast<std::size_t>(rc) || ov[2 * i] == PCRE2_UNSET)
|
||||||
|
return false;
|
||||||
|
b = ov[2 * i];
|
||||||
|
e = ov[2 * i + 1];
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
copied = me;
|
||||||
|
start = me;
|
||||||
|
// After an empty match, the next one must not be empty at the same place.
|
||||||
|
opts = (me == ms) ? (PCRE2_NOTEMPTY_ATSTART | PCRE2_ANCHORED) : 0;
|
||||||
|
}
|
||||||
|
out.append(s.substr(copied));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Regex::Valid() const
|
||||||
|
{
|
||||||
|
return static_cast<bool>(impl_);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Regex::LimitHit() const
|
||||||
|
{
|
||||||
|
return impl_ && impl_->limit_hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Regex::RunGuarded(const std::function<void()> &fn)
|
||||||
|
{
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const char *
|
||||||
|
Regex::EngineName()
|
||||||
|
{
|
||||||
|
return "PCRE2";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
Regex::IncrementalLineLimit()
|
||||||
|
{
|
||||||
|
return std::size_t{1} << 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else // std::regex
|
||||||
|
|
||||||
|
struct Regex::Impl {
|
||||||
|
std::regex rx;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
Regex::Regex() = default;
|
||||||
|
|
||||||
|
Regex::~Regex() = default;
|
||||||
|
|
||||||
|
Regex::Regex(Regex &&other) noexcept = default;
|
||||||
|
|
||||||
|
Regex &Regex::operator=(Regex &&other) noexcept = default;
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Regex::Compile(std::string_view pattern, std::string &err)
|
||||||
|
{
|
||||||
|
err.clear();
|
||||||
|
impl_.reset();
|
||||||
|
try {
|
||||||
|
auto impl = std::make_unique<Impl>();
|
||||||
|
impl->rx = std::regex(pattern.begin(), pattern.end());
|
||||||
|
impl_ = std::move(impl);
|
||||||
|
return true;
|
||||||
|
} catch (const std::regex_error &e) {
|
||||||
|
err = e.what();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Regex::Search(std::string_view subject, std::size_t from, std::size_t &pos, std::size_t &len) const
|
||||||
|
{
|
||||||
|
if (!impl_ || from > subject.size())
|
||||||
|
return false;
|
||||||
|
std::cmatch m;
|
||||||
|
const auto flags = from > 0 ? std::regex_constants::match_prev_avail : std::regex_constants::match_default;
|
||||||
|
const char *b = subject.data() + from;
|
||||||
|
const char *e = subject.data() + subject.size();
|
||||||
|
if (!std::regex_search(b, e, m, impl_->rx, flags))
|
||||||
|
return false;
|
||||||
|
pos = from + static_cast<std::size_t>(m.position(0));
|
||||||
|
len = static_cast<std::size_t>(m.length(0));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::string
|
||||||
|
Regex::ReplaceAll(std::string_view s, std::string_view fmt) const
|
||||||
|
{
|
||||||
|
if (!impl_)
|
||||||
|
return std::string(s);
|
||||||
|
std::string out;
|
||||||
|
std::regex_replace(std::back_inserter(out), s.begin(), s.end(), impl_->rx, std::string(fmt));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Regex::Valid() const
|
||||||
|
{
|
||||||
|
return static_cast<bool>(impl_);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Regex::LimitHit() const
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Regex::RunGuarded(const std::function<void()> &fn)
|
||||||
|
{
|
||||||
|
RunWithLargeStack(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const char *
|
||||||
|
Regex::EngineName()
|
||||||
|
{
|
||||||
|
return "std::regex";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
Regex::IncrementalLineLimit()
|
||||||
|
{
|
||||||
|
return 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* RegexEngine.h - the regex engine behind search, replace and match
|
||||||
|
* highlighting.
|
||||||
|
*
|
||||||
|
* With KTE_USE_PCRE2 (the default when libpcre2-8 is found) patterns are
|
||||||
|
* compiled by PCRE2 with its JIT: typically one to two orders of magnitude
|
||||||
|
* faster than std::regex, no recursion on the C stack, and a match limit
|
||||||
|
* that stops catastrophic backtracking (e.g. "^(a+)+$") instead of hanging
|
||||||
|
* the editor. Otherwise std::regex (ECMAScript) is used, run on a large
|
||||||
|
* stack (see RunGuarded). The syntax both accept covers ordinary use;
|
||||||
|
* PCRE2 additionally has lookbehind, possessive quantifiers and the like.
|
||||||
|
*
|
||||||
|
* Replacement strings use ECMAScript syntax with either engine, as
|
||||||
|
* std::regex_replace (libstdc++) reads it: $& or $0 (the match), $1..$99
|
||||||
|
* (groups, two digits read greedily; a group the pattern lacks expands to
|
||||||
|
* nothing), $` (the text since the previous match), $' (the rest of the
|
||||||
|
* line), $$ (a dollar sign). As in ECMAScript, '.' matches neither CR nor
|
||||||
|
* LF and '$' matches only at the end of the line's text.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
class Regex {
|
||||||
|
public:
|
||||||
|
Regex();
|
||||||
|
|
||||||
|
~Regex();
|
||||||
|
|
||||||
|
Regex(Regex &&other) noexcept;
|
||||||
|
|
||||||
|
Regex &operator=(Regex &&other) noexcept;
|
||||||
|
|
||||||
|
Regex(const Regex &) = delete;
|
||||||
|
|
||||||
|
Regex &operator=(const Regex &) = delete;
|
||||||
|
|
||||||
|
// Compile; on failure err describes the problem and Valid() is false.
|
||||||
|
bool Compile(std::string_view pattern, std::string &err);
|
||||||
|
|
||||||
|
[[nodiscard]] bool Valid() const;
|
||||||
|
|
||||||
|
// The first match in `subject` starting at byte `from` or later; `from`
|
||||||
|
// may be inside the subject (anchors and lookbehind see what precedes
|
||||||
|
// it). False if there is none, or if the engine gave up (LimitHit()).
|
||||||
|
bool Search(std::string_view subject, std::size_t from, std::size_t &pos, std::size_t &len) const;
|
||||||
|
|
||||||
|
// Every non-overlapping match replaced by `fmt` (ECMAScript syntax, see
|
||||||
|
// above); an empty match is not repeated at the same position.
|
||||||
|
[[nodiscard]] std::string ReplaceAll(std::string_view subject, std::string_view fmt) const;
|
||||||
|
|
||||||
|
// Whether the last Search/ReplaceAll stopped at the engine's match limit.
|
||||||
|
[[nodiscard]] bool LimitHit() const;
|
||||||
|
|
||||||
|
// Run fn, which does regex work that may span long text, where the
|
||||||
|
// engine cannot overflow the stack: std::regex recurses once per matched
|
||||||
|
// character, so fn runs to completion on a thread with a large stack
|
||||||
|
// (RegexGuard.h); PCRE2 does not recurse on the C stack, so fn runs
|
||||||
|
// inline. Exceptions thrown by fn are rethrown.
|
||||||
|
static void RunGuarded(const std::function<void()> &fn);
|
||||||
|
|
||||||
|
// "PCRE2" or "std::regex".
|
||||||
|
static const char *EngineName();
|
||||||
|
|
||||||
|
// The longest line search-as-you-type and match highlighting run the
|
||||||
|
// regex over (per keystroke/frame); std::regex can be quadratic in line
|
||||||
|
// length with no way to interrupt it, PCRE2 is bounded by its limit.
|
||||||
|
static std::size_t IncrementalLineLimit();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#include "RegexGuard.h"
|
||||||
|
|
||||||
|
#include <exception>
|
||||||
|
#include <pthread.h>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
namespace {
|
||||||
|
// 1 GiB supports matches spanning several million characters.
|
||||||
|
constexpr std::size_t kLargeStackBytes = std::size_t{1} << 30;
|
||||||
|
|
||||||
|
struct Job {
|
||||||
|
const std::function<void()> *fn;
|
||||||
|
std::exception_ptr error;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
void *
|
||||||
|
run_job(void *arg)
|
||||||
|
{
|
||||||
|
auto *job = static_cast<Job *>(arg);
|
||||||
|
try {
|
||||||
|
(*job->fn)();
|
||||||
|
} catch (...) {
|
||||||
|
job->error = std::current_exception();
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
RunWithLargeStack(const std::function<void()> &fn)
|
||||||
|
{
|
||||||
|
Job job{&fn, nullptr};
|
||||||
|
pthread_attr_t attr;
|
||||||
|
bool started = false;
|
||||||
|
pthread_t tid{};
|
||||||
|
if (pthread_attr_init(&attr) == 0) {
|
||||||
|
if (pthread_attr_setstacksize(&attr, kLargeStackBytes) == 0)
|
||||||
|
started = pthread_create(&tid, &attr, run_job, &job) == 0;
|
||||||
|
pthread_attr_destroy(&attr);
|
||||||
|
}
|
||||||
|
if (!started) {
|
||||||
|
fn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pthread_join(tid, nullptr);
|
||||||
|
if (job.error)
|
||||||
|
std::rethrow_exception(job.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// RegexGuard.h - keep std::regex from overflowing the stack
|
||||||
|
//
|
||||||
|
// libstdc++'s std::regex matcher recurses roughly once per character of a
|
||||||
|
// match, so a pattern such as ".*" or "a+" on a line of a few tens of
|
||||||
|
// thousands of characters (minified JS/JSON) overflows a default 8 MiB
|
||||||
|
// stack and crashes the editor.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
// Run fn to completion on a thread with a large stack (address space is
|
||||||
|
// reserved, pages are committed only as used), so regex work over long lines
|
||||||
|
// cannot overflow. If such a thread cannot be created, fn runs on the calling
|
||||||
|
// thread. Exceptions thrown by fn are rethrown to the caller.
|
||||||
|
void RunWithLargeStack(const std::function<void()> &fn);
|
||||||
|
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#include "SearchHighlight.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
SearchHighlight::SearchHighlight(const Editor &ed)
|
||||||
|
{
|
||||||
|
active_ = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||||
|
if (!active_)
|
||||||
|
return;
|
||||||
|
query_ = ed.SearchQuery();
|
||||||
|
regex_ = ed.PromptActive() && (ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
|
||||||
|
ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind);
|
||||||
|
if (regex_) {
|
||||||
|
std::string err;
|
||||||
|
rx_valid_ = rx_.Compile(query_, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
SearchHighlight::Ranges(std::string_view line, std::vector<std::pair<std::size_t, std::size_t> > &out) const
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
if (!active_)
|
||||||
|
return;
|
||||||
|
if (regex_) {
|
||||||
|
if (!rx_valid_ || line.size() > Regex::IncrementalLineLimit())
|
||||||
|
return;
|
||||||
|
std::size_t from = 0, pos = 0, len = 0;
|
||||||
|
while (from <= line.size() && rx_.Search(line, from, pos, len)) {
|
||||||
|
out.emplace_back(pos, pos + len);
|
||||||
|
from = pos + std::max<std::size_t>(len, 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while ((pos = line.find(query_, pos)) != std::string_view::npos) {
|
||||||
|
out.emplace_back(pos, pos + query_.size());
|
||||||
|
pos += query_.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* SearchHighlight.h - the search-match ranges every renderer highlights.
|
||||||
|
*
|
||||||
|
* Built once per frame from the editor's search state (the regex, when the
|
||||||
|
* regex search or regex replace prompt is up, is compiled here once rather
|
||||||
|
* than per line), then asked for each visible line's matches.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "RegexEngine.h"
|
||||||
|
|
||||||
|
class Editor;
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
class SearchHighlight {
|
||||||
|
public:
|
||||||
|
explicit SearchHighlight(const Editor &ed);
|
||||||
|
|
||||||
|
// Whether a search is up with a non-empty query.
|
||||||
|
[[nodiscard]] bool Active() const
|
||||||
|
{
|
||||||
|
return active_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The matches in one line as [start, end) byte ranges, ordered and not
|
||||||
|
// overlapping. Nothing for an invalid pattern (the status line shows the
|
||||||
|
// error) or, in regex mode, a line longer than
|
||||||
|
// Regex::IncrementalLineLimit().
|
||||||
|
void Ranges(std::string_view line, std::vector<std::pair<std::size_t, std::size_t> > &out) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool active_ = false;
|
||||||
|
bool regex_ = false;
|
||||||
|
bool rx_valid_ = false;
|
||||||
|
std::string query_;
|
||||||
|
Regex rx_;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
@@ -24,9 +24,17 @@ namespace kte {
|
|||||||
enum class SwapRecType : std::uint8_t {
|
enum class SwapRecType : std::uint8_t {
|
||||||
INS = 1,
|
INS = 1,
|
||||||
DEL = 2,
|
DEL = 2,
|
||||||
|
// SPLIT and JOIN are no longer written; replay still reads them.
|
||||||
SPLIT = 3,
|
SPLIT = 3,
|
||||||
JOIN = 4,
|
JOIN = 4,
|
||||||
META = 0xF0,
|
META = 0xF0,
|
||||||
|
// A checkpoint too large for one record (a record's length field is 24
|
||||||
|
// bits) is written as BEGIN [encver u8][total u64][crc32 u32], DATA
|
||||||
|
// chunks (raw bytes), END [encver u8]. Replay applies it only at END,
|
||||||
|
// with the size and CRC-32 matching; an unfinished one is ignored.
|
||||||
|
CHKPT_BEGIN = 0xFB,
|
||||||
|
CHKPT_DATA = 0xFC,
|
||||||
|
CHKPT_END = 0xFD,
|
||||||
CHKPT = 0xFE,
|
CHKPT = 0xFE,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +88,19 @@ public:
|
|||||||
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
||||||
void Checkpoint(Buffer *buf = nullptr);
|
void Checkpoint(Buffer *buf = nullptr);
|
||||||
|
|
||||||
|
// Main thread, called once per frame: request the recovery checkpoint of
|
||||||
|
// any journal left with a gap (a lost record) whose last attempt is more
|
||||||
|
// than the retry interval ago. Otherwise that checkpoint is only
|
||||||
|
// requested by the next edit, and a journal that lost a record just
|
||||||
|
// before the user stopped typing would stay behind indefinitely.
|
||||||
|
void RetryGapCheckpoints();
|
||||||
|
|
||||||
|
// Main thread: the oldest pending message for the user (a journal that
|
||||||
|
// another kte holds, or that fell behind because writes failed), or ""
|
||||||
|
// when there is none. Such failures otherwise went only to the error
|
||||||
|
// log, leaving crash recovery silently off or incomplete.
|
||||||
|
std::string TakeUserNotice();
|
||||||
|
|
||||||
|
|
||||||
void SetConfig(const SwapConfig &cfg)
|
void SetConfig(const SwapConfig &cfg)
|
||||||
{
|
{
|
||||||
@@ -94,6 +115,15 @@ public:
|
|||||||
// Detach(buf) or SwapManager destruction.
|
// Detach(buf) or SwapManager destruction.
|
||||||
SwapRecorder *RecorderFor(Buffer *buf);
|
SwapRecorder *RecorderFor(Buffer *buf);
|
||||||
|
|
||||||
|
// Re-key an attached buffer's journal/recorder entries after its Buffer object
|
||||||
|
// has moved to a new address (e.g. std::vector<Buffer> reallocation/erase-shift).
|
||||||
|
// Callers must ensure no swap records for old_addr are in flight (see Flush())
|
||||||
|
// before calling this, and must not call it with an address that isn't
|
||||||
|
// currently attached. Returns the recorder for new_addr (nullptr if old_addr
|
||||||
|
// wasn't attached); the caller is responsible for calling
|
||||||
|
// new_buf->SetSwapRecorder() with the result.
|
||||||
|
SwapRecorder *Rehome(Buffer *old_addr, Buffer *new_addr);
|
||||||
|
|
||||||
// Notify that the buffer's filename changed (e.g., SaveAs)
|
// Notify that the buffer's filename changed (e.g., SaveAs)
|
||||||
void NotifyFilenameChanged(Buffer &buf);
|
void NotifyFilenameChanged(Buffer &buf);
|
||||||
|
|
||||||
@@ -102,15 +132,34 @@ public:
|
|||||||
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
|
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
|
||||||
// state results from applying records up to the failure point; callers should
|
// state results from applying records up to the failure point; callers should
|
||||||
// treat this as a recovery failure and surface `err`.
|
// treat this as a recovery failure and surface `err`.
|
||||||
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
|
//
|
||||||
|
// A record cut short at end of file (a crash mid-append) ends the replay
|
||||||
|
// successfully; err then describes the incomplete record. If valid_bytes
|
||||||
|
// is given, it receives the length of the journal's valid prefix, so a
|
||||||
|
// caller that keeps using the journal can truncate the torn tail first.
|
||||||
|
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err,
|
||||||
|
std::uint64_t *valid_bytes = nullptr);
|
||||||
|
|
||||||
// Compute the swap path for a file-backed buffer by filename.
|
// Compute the swap path for a file-backed buffer by filename.
|
||||||
// Returns empty string if filename is empty.
|
// Returns empty string if filename is empty.
|
||||||
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
|
// True if another process currently holds the journal at swap_path (it
|
||||||
|
// is live, not left by a crash): it must be neither replayed nor removed.
|
||||||
|
static bool JournalInUse(const std::string &swap_path);
|
||||||
|
|
||||||
|
// False if the journal records which version of `file_path` it applies
|
||||||
|
// to and the file on disk no longer matches (edited or replaced since
|
||||||
|
// the journal started); replaying it would apply edits to the wrong text.
|
||||||
|
// Journals without that record (older kte) are assumed to match.
|
||||||
|
static bool JournalMatchesFile(const std::string &swap_path, const std::string &file_path);
|
||||||
|
|
||||||
// Test-only hook to keep swap path logic centralized.
|
// Test-only hook to keep swap path logic centralized.
|
||||||
// (Avoid duplicating naming rules in unit tests.)
|
// (Avoid duplicating naming rules in unit tests.)
|
||||||
#ifdef KTE_TESTS
|
#ifdef KTE_TESTS
|
||||||
|
// Base files larger than this get their CRC on the writer thread.
|
||||||
|
static void SetSyncCrcLimitForTests(std::uint64_t bytes);
|
||||||
|
|
||||||
static std::string ComputeSwapPathForTests(const Buffer &buf)
|
static std::string ComputeSwapPathForTests(const Buffer &buf)
|
||||||
{
|
{
|
||||||
return ComputeSidecarPath(buf);
|
return ComputeSidecarPath(buf);
|
||||||
@@ -165,10 +214,6 @@ private:
|
|||||||
|
|
||||||
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
|
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
|
||||||
|
|
||||||
void RecordSplit(Buffer &buf, int row, int col);
|
|
||||||
|
|
||||||
void RecordJoin(Buffer &buf, int row);
|
|
||||||
|
|
||||||
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
||||||
|
|
||||||
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
||||||
@@ -183,16 +228,53 @@ private:
|
|||||||
std::uint64_t last_chkpt_ns{0};
|
std::uint64_t last_chkpt_ns{0};
|
||||||
std::uint64_t edit_bytes_since_chkpt{0};
|
std::uint64_t edit_bytes_since_chkpt{0};
|
||||||
std::uint64_t approx_size_bytes{0};
|
std::uint64_t approx_size_bytes{0};
|
||||||
|
// A record was lost (write failure, breaker open, oversize). Records are
|
||||||
|
// position-based, so later ones no longer apply to the journal's state:
|
||||||
|
// drop them until a checkpoint re-establishes the full content.
|
||||||
|
bool gap{false};
|
||||||
|
bool gap_notified{false}; // the user was told about the current gap
|
||||||
|
bool chkpt_queued{false}; // a checkpoint is queued and not yet being written
|
||||||
|
std::uint32_t header_version{0}; // of the open journal file (0: none yet)
|
||||||
|
bool lockout_notified{false}; // told about the lock; kept across ResetJournal
|
||||||
|
std::uint64_t gap_chkpt_request_ns{0};
|
||||||
|
bool gap_unfixable_reported{false}; // gap on a buffer too large to checkpoint
|
||||||
|
// Another kte process holds this journal's lock: do not write to it.
|
||||||
|
bool locked_out{false};
|
||||||
|
// Identity of the file the journal's records apply to (captured when
|
||||||
|
// the journal starts) and written into its header, so recovery can
|
||||||
|
// tell whether the file changed since.
|
||||||
|
bool has_base{false};
|
||||||
|
std::uint64_t base_size{0};
|
||||||
|
std::int64_t base_mtime_ns{0};
|
||||||
|
bool has_base_crc{false}; // CRC-32 of the base file's content
|
||||||
|
std::uint32_t base_crc{0};
|
||||||
|
// A large base file's CRC is computed by the writer thread before it
|
||||||
|
// creates the journal (see process_one), not on open/save.
|
||||||
|
std::string base_crc_file;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Pending {
|
struct Pending {
|
||||||
Buffer *buf{nullptr};
|
Buffer *buf{nullptr};
|
||||||
SwapRecType type{SwapRecType::INS};
|
SwapRecType type{SwapRecType::INS};
|
||||||
std::vector<std::uint8_t> payload; // framed payload only
|
std::vector<std::uint8_t> payload; // framed payload only
|
||||||
|
std::string chkpt; // CHKPT: the buffer's content (framed by the writer)
|
||||||
bool urgent_flush{false};
|
bool urgent_flush{false};
|
||||||
std::uint64_t seq{0};
|
std::uint64_t seq{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A record (or, for a large checkpoint, several) as segments to write:
|
||||||
|
// small framing buffers owned here, and a reference to the checkpoint
|
||||||
|
// content (not copied).
|
||||||
|
struct RecordFrames {
|
||||||
|
std::deque<std::vector<std::uint8_t> > owned;
|
||||||
|
std::vector<std::pair<const std::uint8_t *, std::size_t> > segs;
|
||||||
|
std::size_t total = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void frame_pending(const Pending &p, RecordFrames &f);
|
||||||
|
|
||||||
|
static bool write_frames(int fd, const RecordFrames &f);
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||||
|
|
||||||
@@ -204,17 +286,39 @@ private:
|
|||||||
|
|
||||||
static std::string SwapDirRoot();
|
static std::string SwapDirRoot();
|
||||||
|
|
||||||
static bool write_header(int fd);
|
static bool write_header(int fd, const JournalCtx &ctx);
|
||||||
|
|
||||||
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err);
|
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err, bool *locked_out = nullptr);
|
||||||
|
|
||||||
static void close_ctx(JournalCtx &ctx);
|
static void close_ctx(JournalCtx &ctx);
|
||||||
|
|
||||||
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record,
|
static bool compact_to_checkpoint(JournalCtx &ctx, const RecordFrames &chkpt_record,
|
||||||
std::string &err);
|
std::string &err);
|
||||||
|
|
||||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||||
|
|
||||||
|
// CRC-32 of a file's content; false if it cannot be read.
|
||||||
|
static bool file_crc32(const std::string &path, std::uint32_t &out);
|
||||||
|
|
||||||
|
// Identity (size, mtime, content CRC) of the file a journal's records
|
||||||
|
// apply to. Computed without holding mtx_ (it reads the file), then
|
||||||
|
// stored into the JournalCtx under it.
|
||||||
|
struct BaseId {
|
||||||
|
bool has{false};
|
||||||
|
std::uint64_t size{0};
|
||||||
|
std::int64_t mtime_ns{0};
|
||||||
|
bool has_crc{false};
|
||||||
|
std::uint32_t crc{0};
|
||||||
|
std::string crc_file; // set when the CRC is deferred to the writer thread
|
||||||
|
};
|
||||||
|
|
||||||
|
static BaseId compute_base(const std::string &file);
|
||||||
|
|
||||||
|
// Writer thread: compute a deferred base CRC (see BaseId::crc_file).
|
||||||
|
void complete_base_crc(JournalCtx &ctx);
|
||||||
|
|
||||||
|
static void apply_base(JournalCtx &ctx, const BaseId &id);
|
||||||
|
|
||||||
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
|
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
|
||||||
|
|
||||||
static void put_le64(std::uint8_t dst[8], std::uint64_t v);
|
static void put_le64(std::uint8_t dst[8], std::uint64_t v);
|
||||||
@@ -235,6 +339,11 @@ private:
|
|||||||
std::unordered_map<Buffer *, JournalCtx> journals_;
|
std::unordered_map<Buffer *, JournalCtx> journals_;
|
||||||
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
||||||
mutable std::mutex mtx_;
|
mutable std::mutex mtx_;
|
||||||
|
// Serialises closing journal fds with the writer's periodic fsync, so
|
||||||
|
// neither can act on an fd the other has closed (or that was reused).
|
||||||
|
// Taken before mtx_ when both are needed; edits (which take mtx_) never
|
||||||
|
// wait for an fsync.
|
||||||
|
mutable std::mutex io_mtx_;
|
||||||
std::condition_variable cv_;
|
std::condition_variable cv_;
|
||||||
std::vector<Pending> queue_;
|
std::vector<Pending> queue_;
|
||||||
std::uint64_t next_seq_{0};
|
std::uint64_t next_seq_{0};
|
||||||
@@ -245,6 +354,10 @@ private:
|
|||||||
|
|
||||||
// Error tracking (protected by mtx_)
|
// Error tracking (protected by mtx_)
|
||||||
std::deque<SwapError> errors_; // bounded to max 100 entries
|
std::deque<SwapError> errors_; // bounded to max 100 entries
|
||||||
|
std::deque<std::string> user_notices_; // see TakeUserNotice(); guarded by mtx_
|
||||||
|
|
||||||
|
// Queue a message for the user (mtx_ must be held).
|
||||||
|
void notify_user_locked_(std::string msg);
|
||||||
std::size_t total_error_count_{0};
|
std::size_t total_error_count_{0};
|
||||||
|
|
||||||
// Circuit breaker for swap operations (protected by mtx_)
|
// Circuit breaker for swap operations (protected by mtx_)
|
||||||
|
|||||||
+17
-4
@@ -22,10 +22,12 @@ Open(const char *path, int flags, mode_t mode)
|
|||||||
int
|
int
|
||||||
Close(int fd)
|
Close(int fd)
|
||||||
{
|
{
|
||||||
int ret;
|
// Do not retry on EINTR: on Linux (and most systems) the descriptor is
|
||||||
do {
|
// released even when close() is interrupted, so a retry could close an
|
||||||
ret = ::close(fd);
|
// fd that another thread (e.g. the swap writer) has just opened.
|
||||||
} while (ret == -1 && errno == EINTR);
|
const int ret = ::close(fd);
|
||||||
|
if (ret == -1 && errno == EINTR)
|
||||||
|
return 0;
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,17 @@ Fstat(int fd, struct stat *buf)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int
|
||||||
|
Ftruncate(int fd, off_t length)
|
||||||
|
{
|
||||||
|
int ret;
|
||||||
|
do {
|
||||||
|
ret = ::ftruncate(fd, length);
|
||||||
|
} while (ret == -1 && errno == EINTR);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
int
|
int
|
||||||
Fchmod(int fd, mode_t mode)
|
Fchmod(int fd, mode_t mode)
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-4
@@ -12,11 +12,10 @@ namespace syscall {
|
|||||||
// Automatically retries on EINTR.
|
// Automatically retries on EINTR.
|
||||||
int Open(const char *path, int flags, mode_t mode = 0);
|
int Open(const char *path, int flags, mode_t mode = 0);
|
||||||
|
|
||||||
// EINTR-safe wrapper for close(2).
|
// Wrapper for close(2).
|
||||||
// Returns 0 on success, -1 on failure (errno set).
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
// Automatically retries on EINTR.
|
// EINTR is treated as success and never retried: the descriptor has already
|
||||||
// Note: Some systems may not restart close() on EINTR, but we retry anyway
|
// been released on Linux, and retrying could close an unrelated, reused fd.
|
||||||
// as recommended by POSIX.1-2008.
|
|
||||||
int Close(int fd);
|
int Close(int fd);
|
||||||
|
|
||||||
// EINTR-safe wrapper for fsync(2).
|
// EINTR-safe wrapper for fsync(2).
|
||||||
@@ -34,6 +33,11 @@ int Fstat(int fd, struct stat *buf);
|
|||||||
// Automatically retries on EINTR.
|
// Automatically retries on EINTR.
|
||||||
int Fchmod(int fd, mode_t mode);
|
int Fchmod(int fd, mode_t mode);
|
||||||
|
|
||||||
|
// EINTR-safe wrapper for ftruncate(2).
|
||||||
|
// Returns 0 on success, -1 on failure (errno set).
|
||||||
|
// Automatically retries on EINTR.
|
||||||
|
int Ftruncate(int fd, off_t length);
|
||||||
|
|
||||||
// EINTR-safe wrapper for mkstemp(3).
|
// EINTR-safe wrapper for mkstemp(3).
|
||||||
// Returns file descriptor on success, -1 on failure (errno set).
|
// Returns file descriptor on success, -1 on failure (errno set).
|
||||||
// Automatically retries on EINTR.
|
// Automatically retries on EINTR.
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
// TermWidth.h - terminal cell width of a decoded character
|
||||||
|
//
|
||||||
|
// Shared by TerminalRenderer (drawing and cursor placement) and the command
|
||||||
|
// layer (horizontal scrolling, mouse column mapping) so they always agree.
|
||||||
|
// Tabs are handled by the callers, which know the current column.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cwchar>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
// Tab stops every kTabWidth columns, in every frontend and in the command
|
||||||
|
// layer's horizontal scrolling (they must agree, or the cursor and the
|
||||||
|
// scroll offset are misplaced on lines with tabs).
|
||||||
|
constexpr std::size_t kTabWidth = 8;
|
||||||
|
|
||||||
|
|
||||||
|
// C0 controls and DEL are drawn as caret notation ("^M", "^?"): two cells.
|
||||||
|
// ncurses would otherwise act on them (carriage return, backspace) and
|
||||||
|
// corrupt the line.
|
||||||
|
inline bool
|
||||||
|
IsCaretControl(wchar_t wch)
|
||||||
|
{
|
||||||
|
return (wch >= 0 && wch < 0x20) || wch == 0x7f;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// C1 controls (U+0080..U+009F, also what a stray byte in that range decodes
|
||||||
|
// to) are drawn as '?': sent raw, U+009B is a terminal CSI.
|
||||||
|
inline bool
|
||||||
|
IsC1Control(wchar_t wch)
|
||||||
|
{
|
||||||
|
return wch >= 0x80 && wch <= 0x9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
inline int
|
||||||
|
CellWidth(wchar_t wch)
|
||||||
|
{
|
||||||
|
if (IsCaretControl(wch))
|
||||||
|
return 2;
|
||||||
|
if (IsC1Control(wch))
|
||||||
|
return 1;
|
||||||
|
const int w = wcwidth(wch);
|
||||||
|
return w < 0 ? 1 : w;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Decode the character at line[i]: its byte length and display width in
|
||||||
|
// cells (a tab reaches the next multiple of tabw from column col). Measures
|
||||||
|
// the way TerminalRenderer draws: an invalid byte or NUL is one byte, measured
|
||||||
|
// as the byte value.
|
||||||
|
inline void
|
||||||
|
MeasureChar(std::string_view line, std::size_t i, std::size_t col, std::size_t &len, std::size_t &width,
|
||||||
|
std::size_t tabw = kTabWidth)
|
||||||
|
{
|
||||||
|
std::mbstate_t state{};
|
||||||
|
wchar_t wch = 0;
|
||||||
|
const auto rc = std::mbrtowc(&wch, line.data() + i, line.size() - i, &state);
|
||||||
|
if (rc == static_cast<std::size_t>(-1) || rc == static_cast<std::size_t>(-2) || rc == 0) {
|
||||||
|
wch = static_cast<unsigned char>(line[i]);
|
||||||
|
len = 1;
|
||||||
|
} else {
|
||||||
|
len = rc;
|
||||||
|
}
|
||||||
|
if (wch == L'\t')
|
||||||
|
width = tabw - (col % tabw);
|
||||||
|
else
|
||||||
|
width = static_cast<std::size_t>(CellWidth(wch));
|
||||||
|
}
|
||||||
|
} // namespace kte
|
||||||
+55
-9
@@ -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.
|
||||||
MappedInput mi;
|
{
|
||||||
if (input_.Poll(mi)) {
|
const bool prompt_before = ed.PromptActive();
|
||||||
if (mi.hasCommand) {
|
const std::string status_before = ed.Status();
|
||||||
Execute(ed, mi.id, mi.arg, mi.count);
|
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;
|
||||||
|
int handled = 0;
|
||||||
|
while (handled < 4096 && input_.PollKey(mi)) {
|
||||||
|
changed = true;
|
||||||
|
if (mi.hasCommand)
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer_.Draw(ed);
|
// 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);
|
||||||
|
} 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_{};
|
||||||
|
|||||||
+159
-39
@@ -1,4 +1,6 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <climits>
|
||||||
|
#include <cwchar>
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
|
|
||||||
#include "TerminalInputHandler.h"
|
#include "TerminalInputHandler.h"
|
||||||
@@ -12,6 +14,22 @@ CTRL(char c)
|
|||||||
{
|
{
|
||||||
return c & 0x1F;
|
return c & 0x1F;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Encode a single wide character in the process locale's multibyte encoding
|
||||||
|
// (UTF-8, given main.cc's setlocale(LC_ALL, "")). Returns false if the
|
||||||
|
// codepoint can't be represented, leaving `out` untouched.
|
||||||
|
bool
|
||||||
|
wchar_to_mb(wchar_t wc, std::string &out)
|
||||||
|
{
|
||||||
|
std::mbstate_t state{};
|
||||||
|
char buf[MB_LEN_MAX];
|
||||||
|
std::size_t n = std::wcrtomb(buf, wc, &state);
|
||||||
|
if (n == static_cast<std::size_t>(-1))
|
||||||
|
return false;
|
||||||
|
out.assign(buf, n);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TerminalInputHandler::TerminalInputHandler() = default;
|
TerminalInputHandler::TerminalInputHandler() = default;
|
||||||
@@ -21,6 +39,7 @@ TerminalInputHandler::~TerminalInputHandler() = default;
|
|||||||
|
|
||||||
static bool
|
static bool
|
||||||
map_key_to_command(const int ch,
|
map_key_to_command(const int ch,
|
||||||
|
const bool is_keycode,
|
||||||
bool &k_prefix,
|
bool &k_prefix,
|
||||||
bool &esc_meta,
|
bool &esc_meta,
|
||||||
bool &k_ctrl_pending,
|
bool &k_ctrl_pending,
|
||||||
@@ -28,9 +47,22 @@ map_key_to_command(const int ch,
|
|||||||
Editor *ed,
|
Editor *ed,
|
||||||
MappedInput &out)
|
MappedInput &out)
|
||||||
{
|
{
|
||||||
// Handle special keys from ncurses
|
// Handle special keys from ncurses. These are only meaningful when
|
||||||
|
// get_wch() reported KEY_CODE_YES: a regular (possibly non-ASCII) wide
|
||||||
|
// character can numerically collide with a KEY_* constant otherwise
|
||||||
|
// (e.g. U+0107 'ć' equals KEY_BACKSPACE's value), which would wrongly
|
||||||
|
// swallow it as a special key instead of inserting it.
|
||||||
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
||||||
switch (ch) {
|
//
|
||||||
|
// ESC marks the next key as Meta. Consume that flag on every other key up
|
||||||
|
// front: keys handled before the Meta lookup below (arrows, mouse, Enter,
|
||||||
|
// control chords) used to leave it set, turning a later plain key into a
|
||||||
|
// Meta command (ESC, Left, d => delete word).
|
||||||
|
const bool meta = esc_meta && ch != 27;
|
||||||
|
if (ch != 27)
|
||||||
|
esc_meta = false;
|
||||||
|
|
||||||
|
switch (is_keycode ? ch : -1) {
|
||||||
case KEY_ENTER:
|
case KEY_ENTER:
|
||||||
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
@@ -142,9 +174,10 @@ map_key_to_command(const int ch,
|
|||||||
out = {true, CommandId::DeleteChar, "", 0};
|
out = {true, CommandId::DeleteChar, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_RESIZE:
|
case KEY_RESIZE:
|
||||||
k_prefix = false;
|
// TerminalFrontend::Step picks up the new size before the next draw.
|
||||||
k_ctrl_pending = false;
|
// Refresh is the C-g cancel handler, so issuing it here would clear the
|
||||||
out = {true, CommandId::Refresh, "", 0};
|
// mark and cancel open prompts on every window resize.
|
||||||
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -160,6 +193,37 @@ map_key_to_command(const int ch,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meta (ESC-prefixed) keys: backspace, Enter and printable keys use the
|
||||||
|
// ESC keymap. Checked before control chords so ESC ^H (meta-backspace) is
|
||||||
|
// not taken as C-h, and before Enter so ESC Enter reaches SmartNewline.
|
||||||
|
// Other control chords after ESC act as themselves.
|
||||||
|
const bool is_backspace = (is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H');
|
||||||
|
const bool meta_key = meta && (is_backspace || (!is_keycode && (ch == '\n' || ch == '\r' || ch >= 0x20)));
|
||||||
|
if (meta_key) {
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
|
int ascii_key = ch;
|
||||||
|
if (is_backspace) {
|
||||||
|
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
||||||
|
} else if (ch == ',') {
|
||||||
|
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
||||||
|
ascii_key = '<';
|
||||||
|
} else if (ch == '.') {
|
||||||
|
// Likewise, map '.' to '>'
|
||||||
|
ascii_key = '>';
|
||||||
|
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
|
||||||
|
ascii_key = ascii_key - 'A' + 'a';
|
||||||
|
}
|
||||||
|
CommandId id;
|
||||||
|
if (KLookupEscCommand(ascii_key, id)) {
|
||||||
|
out = {true, id, "", 0};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Unhandled ESC sequence: exit escape mode and show status
|
||||||
|
out = {true, CommandId::UnknownEscCommand, "", 0};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Control keys
|
// Control keys
|
||||||
if (ch == CTRL('K')) {
|
if (ch == CTRL('K')) {
|
||||||
// C-k prefix
|
// C-k prefix
|
||||||
@@ -254,34 +318,8 @@ map_key_to_command(const int ch,
|
|||||||
out = {true, CommandId::Newline, "", 0};
|
out = {true, CommandId::Newline, "", 0};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// If previous key was ESC, interpret as meta and use ESC keymap
|
|
||||||
if (esc_meta) {
|
|
||||||
esc_meta = false;
|
|
||||||
int ascii_key = ch;
|
|
||||||
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
|
|
||||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
|
||||||
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
|
||||||
} else if (ch == ',') {
|
|
||||||
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
|
||||||
ascii_key = '<';
|
|
||||||
} else if (ch == '.') {
|
|
||||||
// Likewise, map '.' to '>'
|
|
||||||
ascii_key = '>';
|
|
||||||
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
|
|
||||||
ascii_key = ascii_key - 'A' + 'a';
|
|
||||||
}
|
|
||||||
CommandId id;
|
|
||||||
if (KLookupEscCommand(ascii_key, id)) {
|
|
||||||
out = {true, id, "", 0};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Unhandled ESC sequence: exit escape mode and show status
|
|
||||||
out = {true, CommandId::UnknownEscCommand, "", 0};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
||||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
k_ctrl_pending = false;
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::Backspace, "", 0};
|
out = {true, CommandId::Backspace, "", 0};
|
||||||
@@ -297,12 +335,21 @@ map_key_to_command(const int ch,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Printable ASCII
|
// Printable character: ASCII, or (for a regular decoded wide character,
|
||||||
if (ch >= 0x20 && ch <= 0x7E) {
|
// not a keycode) any other printable Unicode codepoint - e.g. accented
|
||||||
|
// Latin, Cyrillic, CJK, etc.
|
||||||
|
if (!is_keycode && ch >= 0x20 && ch != 0x7F) {
|
||||||
|
std::string mb;
|
||||||
|
if (ch <= 0x7E) {
|
||||||
|
mb.assign(1, static_cast<char>(ch));
|
||||||
|
} else if (!wchar_to_mb(static_cast<wchar_t>(ch), mb)) {
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
out.hasCommand = true;
|
out.hasCommand = true;
|
||||||
out.id = CommandId::InsertText;
|
out.id = CommandId::InsertText;
|
||||||
out.arg.assign(1, static_cast<char>(ch));
|
out.arg = mb;
|
||||||
out.count = 0;
|
out.count = 0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,15 +358,88 @@ map_key_to_command(const int ch,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Read one key: an ncurses KEY_* code, or a character decoded from UTF-8
|
||||||
|
// here rather than by get_wch(). With a zero timeout (the frontend reads the
|
||||||
|
// rest of a batch without waiting), get_wch() gave up on a character whose
|
||||||
|
// bytes had not all arrived yet, and ncurses then discarded the remaining
|
||||||
|
// bytes as an invalid sequence: text pasted over a slow link lost
|
||||||
|
// characters, and whatever followed them in the same read.
|
||||||
|
// Returns false when there is no input.
|
||||||
|
static bool
|
||||||
|
read_key(int &ch, bool &is_keycode)
|
||||||
|
{
|
||||||
|
const int c = getch();
|
||||||
|
if (c == ERR)
|
||||||
|
return false;
|
||||||
|
is_keycode = c > 0xFF;
|
||||||
|
ch = c;
|
||||||
|
if (is_keycode || c < 0x80)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Lead byte of a multibyte character: how many continuation bytes follow.
|
||||||
|
int need;
|
||||||
|
char32_t cp;
|
||||||
|
if (c >= 0xC2 && c <= 0xDF) {
|
||||||
|
need = 1;
|
||||||
|
cp = static_cast<char32_t>(c & 0x1F);
|
||||||
|
} else if (c >= 0xE0 && c <= 0xEF) {
|
||||||
|
need = 2;
|
||||||
|
cp = static_cast<char32_t>(c & 0x0F);
|
||||||
|
} else if (c >= 0xF0 && c <= 0xF4) {
|
||||||
|
need = 3;
|
||||||
|
cp = static_cast<char32_t>(c & 0x07);
|
||||||
|
} else {
|
||||||
|
ch = -1; // stray continuation or invalid byte: ignore it
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// The rest of the character is on its way; wait for it (restoring the
|
||||||
|
// caller's timeout), but not long if it never comes: terminals that send
|
||||||
|
// Alt as a raw 8-bit byte (rxvt meta8) produce lone lead bytes, and
|
||||||
|
// every such key would otherwise stall input.
|
||||||
|
const int saved_delay = wgetdelay(stdscr);
|
||||||
|
timeout(250);
|
||||||
|
for (int i = 0; i < need; ++i) {
|
||||||
|
const int b = getch();
|
||||||
|
if (b == ERR || b > 0xFF || (b & 0xC0) != 0x80) {
|
||||||
|
if (b != ERR)
|
||||||
|
ungetch(b); // not part of this character; read it next
|
||||||
|
ch = -1;
|
||||||
|
timeout(saved_delay);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cp = (cp << 6) | static_cast<char32_t>(b & 0x3F);
|
||||||
|
}
|
||||||
|
timeout(saved_delay);
|
||||||
|
// Reject overlong forms and surrogates.
|
||||||
|
if ((need == 2 && cp < 0x800) || (need == 3 && cp < 0x10000) || (cp >= 0xD800 && cp <= 0xDFFF) ||
|
||||||
|
cp > 0x10FFFF) {
|
||||||
|
ch = -1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ch = static_cast<int>(cp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
TerminalInputHandler::decode_(MappedInput &out)
|
TerminalInputHandler::decode_(MappedInput &out)
|
||||||
{
|
{
|
||||||
int ch = getch();
|
int ch = 0;
|
||||||
if (ch == ERR) {
|
bool is_keycode = false;
|
||||||
|
if (!read_key(ch, is_keycode))
|
||||||
return false; // no input
|
return false; // no input
|
||||||
|
if (ch < 0) {
|
||||||
|
// Invalid byte sequence, dropped. It still ends a pending ESC or
|
||||||
|
// C-k prefix, as any other key would; left set, it applied to the
|
||||||
|
// next key instead.
|
||||||
|
esc_meta_ = false;
|
||||||
|
k_prefix_ = false;
|
||||||
|
k_ctrl_pending_ = false;
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
bool consumed = map_key_to_command(
|
bool consumed = map_key_to_command(
|
||||||
ch,
|
ch, is_keycode,
|
||||||
k_prefix_, esc_meta_,
|
k_prefix_, esc_meta_,
|
||||||
k_ctrl_pending_,
|
k_ctrl_pending_,
|
||||||
mouse_selecting_,
|
mouse_selecting_,
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ public:
|
|||||||
|
|
||||||
bool Poll(MappedInput &out) override;
|
bool Poll(MappedInput &out) override;
|
||||||
|
|
||||||
|
// Read one key if available. Returns false when no input was read; true
|
||||||
|
// when a key was consumed (out.hasCommand says whether it produced a
|
||||||
|
// command: prefixes such as C-k or ESC do not).
|
||||||
|
bool PollKey(MappedInput &out)
|
||||||
|
{
|
||||||
|
out = {};
|
||||||
|
return decode_(out);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool decode_(MappedInput &out);
|
bool decode_(MappedInput &out);
|
||||||
|
|
||||||
|
|||||||
+100
-95
@@ -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') {
|
|
||||||
std::size_t next_tab = tabw - (render_col_cur % tabw);
|
|
||||||
render_col_cur += next_tab;
|
|
||||||
} else {
|
|
||||||
int dw = wcwidth(wch);
|
|
||||||
render_col_cur += (dw < 0) ? 1 : dw;
|
|
||||||
}
|
|
||||||
src_i_cur += res;
|
|
||||||
}
|
}
|
||||||
|
if (wch == L'\t') {
|
||||||
|
std::size_t next_tab = tabw - (render_col_cur % tabw);
|
||||||
|
render_col_cur += next_tab;
|
||||||
|
} else {
|
||||||
|
render_col_cur += static_cast<std::size_t>(kte::CellWidth(wch));
|
||||||
|
}
|
||||||
|
src_i_cur += len;
|
||||||
}
|
}
|
||||||
rx_recomputed = render_col_cur;
|
rx_recomputed = render_col_cur;
|
||||||
|
cursor_cache_ = CursorCache{buf->Id(), buf->Version(), cy, cx, rx_recomputed, true};
|
||||||
}
|
}
|
||||||
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
|
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
|
||||||
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
|
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
|
||||||
@@ -541,7 +546,7 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
left += " [RO]";
|
left += " [RO]";
|
||||||
// Append total line count as "<n>L"
|
// Append total line count as "<n>L"
|
||||||
if (b) {
|
if (b) {
|
||||||
unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
|
unsigned long lcount = static_cast<unsigned long>(b->Nrows());
|
||||||
left += " ";
|
left += " ";
|
||||||
left += std::to_string(lcount);
|
left += std::to_string(lcount);
|
||||||
left += "L";
|
left += "L";
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* TerminalRenderer - ncurses-based renderer for terminal mode
|
* TerminalRenderer - ncurses-based renderer for terminal mode
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstddef>
|
||||||
#include "Renderer.h"
|
#include "Renderer.h"
|
||||||
|
|
||||||
|
|
||||||
@@ -12,4 +15,30 @@ public:
|
|||||||
~TerminalRenderer() override;
|
~TerminalRenderer() override;
|
||||||
|
|
||||||
void Draw(Editor &ed) override;
|
void Draw(Editor &ed) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Where a row's visible part starts (the draw loop's skip up to the
|
||||||
|
// horizontal scroll offset), cached per screen row. Scanning a very long
|
||||||
|
// line from column 0 on every frame made each redraw O(line length).
|
||||||
|
struct SkipCache {
|
||||||
|
std::uint64_t buf_id = 0; // Buffer::Id(); addresses get reused
|
||||||
|
std::uint64_t version = 0;
|
||||||
|
std::size_t row = 0;
|
||||||
|
std::size_t coloffs = 0;
|
||||||
|
std::size_t src = 0; // byte offset where drawing resumes
|
||||||
|
std::size_t col = 0; // render column at that offset
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<SkipCache> skip_cache_;
|
||||||
|
|
||||||
|
// Render column of the cursor, cached the same way.
|
||||||
|
struct CursorCache {
|
||||||
|
std::uint64_t buf_id = 0; // Buffer::Id(); addresses get reused
|
||||||
|
std::uint64_t version = 0;
|
||||||
|
std::size_t row = 0;
|
||||||
|
std::size_t cx = 0;
|
||||||
|
std::size_t rx = 0;
|
||||||
|
bool valid = false;
|
||||||
|
} cursor_cache_;
|
||||||
};
|
};
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* TextSpan.h - a reference to bytes held in a PieceTable's storage.
|
||||||
|
*
|
||||||
|
* PieceTable storage is append-only (edits add pieces; nothing rewrites
|
||||||
|
* stored bytes), so a span stays valid for the life of the table's storage.
|
||||||
|
* Undo records large texts as spans instead of copies of the bytes.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
|
||||||
|
struct TextSpan {
|
||||||
|
bool add{false}; // false: original storage, true: add storage
|
||||||
|
std::size_t start{0};
|
||||||
|
std::size_t len{0};
|
||||||
|
};
|
||||||
+34
@@ -1,6 +1,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "TextSpan.h"
|
||||||
|
|
||||||
|
|
||||||
enum class UndoType : std::uint8_t {
|
enum class UndoType : std::uint8_t {
|
||||||
@@ -10,6 +13,12 @@ enum class UndoType : std::uint8_t {
|
|||||||
Newline,
|
Newline,
|
||||||
DeleteRow,
|
DeleteRow,
|
||||||
InsertRow,
|
InsertRow,
|
||||||
|
// Inverse of Newline: forward = join_lines(row) [removes the newline at the
|
||||||
|
// end of `row`], backward = split_line(row, col) [recreates the original
|
||||||
|
// two lines]. Used by backspace-at-col-0 and delete-at-eol, whose forward
|
||||||
|
// action is a join, not a split — Newline's apply() semantics are the
|
||||||
|
// wrong direction for those.
|
||||||
|
JoinLines,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct UndoNode {
|
struct UndoNode {
|
||||||
@@ -18,7 +27,32 @@ struct UndoNode {
|
|||||||
int col{};
|
int col{};
|
||||||
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
||||||
std::string text;
|
std::string text;
|
||||||
|
// A large Insert/Paste/Delete text is held as references to the buffer's
|
||||||
|
// storage instead (text is then empty and span_len is its length): undo
|
||||||
|
// of a replace-all over a 100 MB file otherwise kept 200 MB of copies.
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
std::size_t span_len = 0;
|
||||||
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
||||||
UndoNode *child = nullptr; // next in current timeline
|
UndoNode *child = nullptr; // next in current timeline
|
||||||
UndoNode *next = nullptr; // redo branch
|
UndoNode *next = nullptr; // redo branch
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool HasSpans() const
|
||||||
|
{
|
||||||
|
return !spans.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Length of the node's text, however it is held.
|
||||||
|
[[nodiscard]] std::size_t TextSize() const
|
||||||
|
{
|
||||||
|
return HasSpans() ? span_len : text.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Approximate heap and object bytes held by this node.
|
||||||
|
[[nodiscard]] std::size_t CostBytes() const
|
||||||
|
{
|
||||||
|
return sizeof(UndoNode) + text.capacity() + spans.capacity() * sizeof(TextSpan);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <stack>
|
|
||||||
#include <vector>
|
|
||||||
#include <memory>
|
|
||||||
#include "UndoNode.h"
|
|
||||||
|
|
||||||
// Pool allocator for UndoNode to eliminate frequent malloc/free.
|
|
||||||
// Uses fixed-size blocks to keep node addresses stable.
|
|
||||||
class UndoNodePool {
|
|
||||||
public:
|
|
||||||
explicit UndoNodePool(std::size_t block_size = 64)
|
|
||||||
: block_size_(block_size) {}
|
|
||||||
|
|
||||||
|
|
||||||
UndoNode *acquire()
|
|
||||||
{
|
|
||||||
if (available_.empty())
|
|
||||||
allocate_block();
|
|
||||||
auto *node = available_.top();
|
|
||||||
available_.pop();
|
|
||||||
// Node comes zeroed; ensure links are reset
|
|
||||||
node->text.clear();
|
|
||||||
node->parent = nullptr;
|
|
||||||
node->child = nullptr;
|
|
||||||
node->next = nullptr;
|
|
||||||
node->row = node->col = 0;
|
|
||||||
node->type = UndoType{};
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void release(UndoNode *node)
|
|
||||||
{
|
|
||||||
if (!node)
|
|
||||||
return;
|
|
||||||
// Clear heavy fields to free memory held by strings
|
|
||||||
node->text.clear();
|
|
||||||
node->parent = nullptr;
|
|
||||||
node->child = nullptr;
|
|
||||||
node->next = nullptr;
|
|
||||||
node->row = node->col = 0;
|
|
||||||
node->type = UndoType{};
|
|
||||||
available_.push(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
void allocate_block()
|
|
||||||
{
|
|
||||||
// allocate a new block; keep ownership so memory stays valid
|
|
||||||
std::unique_ptr<UndoNode[]> block(new UndoNode[block_size_]);
|
|
||||||
UndoNode *base = block.get();
|
|
||||||
blocks_.push_back(std::move(block));
|
|
||||||
for (std::size_t i = 0; i < block_size_; ++i) {
|
|
||||||
// ensure the node is reset; rely on default constructor/zero init
|
|
||||||
available_.push(&base[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
std::size_t block_size_;
|
|
||||||
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
|
|
||||||
std::stack<UndoNode *> available_;
|
|
||||||
};
|
|
||||||
+303
-39
@@ -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();
|
||||||
active_group_id_ = 0;
|
if (group_depth_ > 0)
|
||||||
|
--group_depth_;
|
||||||
|
if (group_depth_ == 0) {
|
||||||
|
active_group_id_ = 0;
|
||||||
|
maybe_prune();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +47,7 @@ UndoSystem::Begin(UndoType type)
|
|||||||
|
|
||||||
// Some operations should always be standalone undo steps.
|
// Some operations should always be standalone undo steps.
|
||||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
||||||
UndoType::InsertRow);
|
UndoType::InsertRow || type == UndoType::JoinLines);
|
||||||
if (always_standalone) {
|
if (always_standalone) {
|
||||||
commit();
|
commit();
|
||||||
}
|
}
|
||||||
@@ -77,6 +87,7 @@ UndoSystem::Begin(UndoType type)
|
|||||||
case UndoType::Newline:
|
case UndoType::Newline:
|
||||||
case UndoType::DeleteRow:
|
case UndoType::DeleteRow:
|
||||||
case UndoType::InsertRow:
|
case UndoType::InsertRow:
|
||||||
|
case UndoType::JoinLines:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,6 +153,9 @@ UndoSystem::commit()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try_convert_to_spans(tree_.pending);
|
||||||
|
bytes_ += tree_.pending->CostBytes();
|
||||||
|
|
||||||
if (!tree_.root) {
|
if (!tree_.root) {
|
||||||
tree_.root = tree_.pending;
|
tree_.root = tree_.pending;
|
||||||
tree_.pending->parent = nullptr;
|
tree_.pending->parent = nullptr;
|
||||||
@@ -169,6 +183,8 @@ UndoSystem::commit()
|
|||||||
tree_.pending = nullptr;
|
tree_.pending = nullptr;
|
||||||
pending_mode_ = PendingAppendMode::Append;
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
|
if (group_depth_ == 0)
|
||||||
|
maybe_prune();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -239,7 +255,25 @@ void
|
|||||||
UndoSystem::mark_saved()
|
UndoSystem::mark_saved()
|
||||||
{
|
{
|
||||||
commit();
|
commit();
|
||||||
tree_.saved = tree_.current;
|
tree_.saved = tree_.current;
|
||||||
|
base_unsaved_ = false;
|
||||||
|
update_dirty_flag();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::AbortGroups()
|
||||||
|
{
|
||||||
|
commit();
|
||||||
|
group_depth_ = 0;
|
||||||
|
active_group_id_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::mark_base_unsaved()
|
||||||
|
{
|
||||||
|
base_unsaved_ = true;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,19 +293,61 @@ void
|
|||||||
UndoSystem::clear()
|
UndoSystem::clear()
|
||||||
{
|
{
|
||||||
discard_pending();
|
discard_pending();
|
||||||
free_node(tree_.root);
|
// The root may have siblings (edits made after undoing to the start).
|
||||||
|
FreeUndoForest(tree_.root);
|
||||||
tree_.root = nullptr;
|
tree_.root = nullptr;
|
||||||
tree_.current = nullptr;
|
tree_.current = nullptr;
|
||||||
tree_.saved = nullptr;
|
tree_.saved = nullptr;
|
||||||
|
base_unsaved_ = false;
|
||||||
|
bytes_ = 0;
|
||||||
active_group_id_ = 0;
|
active_group_id_ = 0;
|
||||||
|
group_depth_ = 0;
|
||||||
next_group_id_ = 1;
|
next_group_id_ = 1;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Position just after the node's text inserted at (node->col, node->row).
|
||||||
|
// The text may span lines: the column is then the length after its last
|
||||||
|
// newline, not col + size (which left the cursor past the end of the line,
|
||||||
|
// so later edits and their undo records went to the wrong place).
|
||||||
|
static void
|
||||||
|
end_of_text(const Buffer &buf, const UndoNode *node, std::size_t &x, std::size_t &y)
|
||||||
|
{
|
||||||
|
y = static_cast<std::size_t>(node->row);
|
||||||
|
x = static_cast<std::size_t>(node->col);
|
||||||
|
auto scan = [&](const char *d, std::size_t n) {
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
if (d[i] == '\n') {
|
||||||
|
++y;
|
||||||
|
x = 0;
|
||||||
|
} else {
|
||||||
|
++x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (node->HasSpans())
|
||||||
|
buf.VisitSpans(node->spans, scan);
|
||||||
|
else
|
||||||
|
scan(node->text.data(), node->text.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Insert the node's text at its position (from spans without copying).
|
||||||
|
static void
|
||||||
|
insert_node_text(Buffer &buf, const UndoNode *node)
|
||||||
|
{
|
||||||
|
if (node->HasSpans())
|
||||||
|
buf.insert_spans(node->row, node->col, node->spans);
|
||||||
|
else
|
||||||
|
buf.insert_text(node->row, node->col, node->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::apply(const UndoNode *node, int direction)
|
UndoSystem::apply(const UndoNode *node, int direction)
|
||||||
{
|
{
|
||||||
|
std::size_t ex = 0, ey = 0;
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
||||||
@@ -280,22 +356,22 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
case UndoType::Insert:
|
case UndoType::Insert:
|
||||||
case UndoType::Paste:
|
case UndoType::Paste:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
insert_node_text(*buf_, node);
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
end_of_text(*buf_, node, ex, ey);
|
||||||
static_cast<std::size_t>(node->row));
|
buf_->SetCursor(ex, ey);
|
||||||
} else {
|
} else {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->TextSize());
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Delete:
|
case UndoType::Delete:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->TextSize());
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
insert_node_text(*buf_, node);
|
||||||
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
end_of_text(*buf_, node, ex, ey);
|
||||||
static_cast<std::size_t>(node->row));
|
buf_->SetCursor(ex, ey);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Newline:
|
case UndoType::Newline:
|
||||||
@@ -325,6 +401,16 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case UndoType::JoinLines:
|
||||||
|
// Mirror image of Newline: forward removes a newline, backward restores it.
|
||||||
|
if (direction > 0) {
|
||||||
|
buf_->join_lines(node->row);
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
|
} else {
|
||||||
|
buf_->split_line(node->row, node->col);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,20 +418,13 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
void
|
void
|
||||||
UndoSystem::free_node(UndoNode *node)
|
UndoSystem::free_node(UndoNode *node)
|
||||||
{
|
{
|
||||||
|
// Free this node and its child subtree, but not its siblings.
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
// Free child subtree(s) and sibling branches
|
UndoNode *children = node->child;
|
||||||
if (node->child) {
|
node->child = nullptr;
|
||||||
// Free entire redo list starting at child, including each subtree
|
|
||||||
UndoNode *branch = node->child;
|
|
||||||
while (branch) {
|
|
||||||
UndoNode *next = branch->next;
|
|
||||||
free_node(branch);
|
|
||||||
branch = next;
|
|
||||||
}
|
|
||||||
node->child = nullptr;
|
|
||||||
}
|
|
||||||
delete node;
|
delete node;
|
||||||
|
FreeUndoForest(children);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -353,26 +432,27 @@ void
|
|||||||
UndoSystem::free_branch(UndoNode *node)
|
UndoSystem::free_branch(UndoNode *node)
|
||||||
{
|
{
|
||||||
// Free a branch list (node and its next siblings) including their subtrees
|
// Free a branch list (node and its next siblings) including their subtrees
|
||||||
while (node) {
|
FreeUndoForest(node);
|
||||||
UndoNode *next = node->next;
|
|
||||||
free_node(node);
|
|
||||||
node = next;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Iterative search for `target` below `cur`; sets out_parent to its parent.
|
||||||
static bool
|
static bool
|
||||||
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
||||||
{
|
{
|
||||||
if (!cur)
|
if (!cur)
|
||||||
return false;
|
return false;
|
||||||
for (UndoNode *child = cur->child; child != nullptr; child = child->next) {
|
std::vector<UndoNode *> stack{cur};
|
||||||
if (child == target) {
|
while (!stack.empty()) {
|
||||||
out_parent = cur;
|
UndoNode *node = stack.back();
|
||||||
return true;
|
stack.pop_back();
|
||||||
|
for (UndoNode *child = node->child; child != nullptr; child = child->next) {
|
||||||
|
if (child == target) {
|
||||||
|
out_parent = node;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
stack.push_back(child);
|
||||||
}
|
}
|
||||||
if (dfs_find_parent(child, target, out_parent))
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -391,11 +471,190 @@ UndoSystem::find_parent(UndoNode *from, UndoNode *target)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Texts at least this long are held as storage spans when possible.
|
||||||
|
static constexpr std::size_t kSpanMinBytes = PieceTable::kCaptureDeletedMin;
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::try_convert_to_spans(UndoNode *node)
|
||||||
|
{
|
||||||
|
if (!buf_ || !node || node->text.size() < kSpanMinBytes)
|
||||||
|
return;
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
switch (node->type) {
|
||||||
|
case UndoType::Insert:
|
||||||
|
case UndoType::Paste:
|
||||||
|
// The inserted text is in the buffer at the node's position.
|
||||||
|
spans = buf_->SpansAt(node->row, node->col, node->text.size());
|
||||||
|
break;
|
||||||
|
case UndoType::Delete:
|
||||||
|
// The deleted text is still in storage; the deletion remembered where.
|
||||||
|
spans = buf_->TakeDeletedSpans(node->row, node->col, node->text.size());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only when the spans hold exactly the recorded text; otherwise (the
|
||||||
|
// text is not where the node says) keep the copy.
|
||||||
|
if (spans.empty() || !buf_->SpansEqual(spans, node->text))
|
||||||
|
return;
|
||||||
|
node->span_len = node->text.size();
|
||||||
|
node->spans = std::move(spans);
|
||||||
|
node->spans.shrink_to_fit();
|
||||||
|
std::string().swap(node->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::maybe_prune()
|
||||||
|
{
|
||||||
|
std::size_t budget = budget_;
|
||||||
|
if (budget == 0) {
|
||||||
|
const std::size_t content = buf_ ? buf_->ContentBytes() : 0;
|
||||||
|
budget = std::max<std::size_t>(std::size_t{64} << 20, content * 2);
|
||||||
|
}
|
||||||
|
if (bytes_ <= budget)
|
||||||
|
return;
|
||||||
|
// Prune well below the budget so this does not run on every commit.
|
||||||
|
prune_to(budget - budget / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::forget_saved_state()
|
||||||
|
{
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
base_unsaved_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Total bytes held by a node, its children and its siblings.
|
||||||
|
static std::size_t
|
||||||
|
forest_bytes(const UndoNode *first)
|
||||||
|
{
|
||||||
|
std::size_t total = 0;
|
||||||
|
std::vector<const UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const UndoNode *n = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
total += n->CostBytes();
|
||||||
|
if (n->child)
|
||||||
|
stack.push_back(n->child);
|
||||||
|
if (n->next)
|
||||||
|
stack.push_back(n->next);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
forest_contains(const UndoNode *first, const UndoNode *target)
|
||||||
|
{
|
||||||
|
if (!target)
|
||||||
|
return false;
|
||||||
|
std::vector<const UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const UndoNode *n = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
if (n == target)
|
||||||
|
return true;
|
||||||
|
if (n->child)
|
||||||
|
stack.push_back(n->child);
|
||||||
|
if (n->next)
|
||||||
|
stack.push_back(n->next);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::prune_to(const std::size_t target)
|
||||||
|
{
|
||||||
|
// The line kept: root..current, then current's active redo line.
|
||||||
|
std::vector<UndoNode *> line;
|
||||||
|
for (UndoNode *n = tree_.current; n; n = n->parent)
|
||||||
|
line.push_back(n);
|
||||||
|
std::reverse(line.begin(), line.end());
|
||||||
|
{
|
||||||
|
UndoNode *n = tree_.current ? tree_.current->child : tree_.root;
|
||||||
|
for (; n; n = n->child)
|
||||||
|
line.push_back(n);
|
||||||
|
}
|
||||||
|
if (line.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Detach and free the siblings of `keep` in the list starting at *head.
|
||||||
|
auto drop_siblings = [&](UndoNode **head, UndoNode *keep) {
|
||||||
|
UndoNode *n = *head;
|
||||||
|
*head = keep;
|
||||||
|
while (n) {
|
||||||
|
UndoNode *nx = n->next;
|
||||||
|
if (n != keep) {
|
||||||
|
n->next = nullptr;
|
||||||
|
if (forest_contains(n, tree_.saved))
|
||||||
|
forget_saved_state();
|
||||||
|
FreeUndoForest(n);
|
||||||
|
}
|
||||||
|
n = nx;
|
||||||
|
}
|
||||||
|
keep->next = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Branches off the kept line: reachable only by choosing a redo branch.
|
||||||
|
drop_siblings(&tree_.root, line.front());
|
||||||
|
for (std::size_t i = 0; i + 1 < line.size(); ++i)
|
||||||
|
drop_siblings(&line[i]->child, line[i + 1]);
|
||||||
|
bytes_ = forest_bytes(tree_.root);
|
||||||
|
|
||||||
|
// 2. The oldest edits, while they are older than the current state. An
|
||||||
|
// undo group goes whole: undoing what remained of it would stop at a
|
||||||
|
// state inside the group, which the user never saw.
|
||||||
|
std::size_t i = 0;
|
||||||
|
bool mid_group = false;
|
||||||
|
while ((bytes_ > target || mid_group) && i < line.size() && line[i] != tree_.current && tree_.current) {
|
||||||
|
UndoNode *old = line[i];
|
||||||
|
UndoNode *nx = old->child;
|
||||||
|
if (!nx)
|
||||||
|
break;
|
||||||
|
if (old->group_id != 0 && old->group_id == tree_.current->group_id)
|
||||||
|
break; // the current state's own group stays whole
|
||||||
|
// Dropping `old` loses the state before it. `old`'s own state
|
||||||
|
// becomes the first state in history.
|
||||||
|
if (tree_.saved == nullptr)
|
||||||
|
forget_saved_state();
|
||||||
|
else if (tree_.saved == old)
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
mid_group = old->group_id != 0 && nx->group_id == old->group_id;
|
||||||
|
bytes_ -= old->CostBytes();
|
||||||
|
old->child = nullptr;
|
||||||
|
delete old;
|
||||||
|
nx->parent = nullptr;
|
||||||
|
tree_.root = nx;
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The redo line beyond the current state, newest first.
|
||||||
|
if (bytes_ > target && tree_.current && tree_.current->child) {
|
||||||
|
UndoNode *future = tree_.current->child;
|
||||||
|
if (forest_contains(future, tree_.saved))
|
||||||
|
forget_saved_state();
|
||||||
|
tree_.current->child = nullptr;
|
||||||
|
bytes_ -= forest_bytes(future);
|
||||||
|
FreeUndoForest(future);
|
||||||
|
}
|
||||||
|
update_dirty_flag();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::update_dirty_flag()
|
UndoSystem::update_dirty_flag()
|
||||||
{
|
{
|
||||||
// dirty if current != saved
|
// dirty if current != saved
|
||||||
bool dirty = (tree_.current != tree_.saved);
|
bool dirty = base_unsaved_ || (tree_.current != tree_.saved);
|
||||||
buf_->SetDirty(dirty);
|
buf_->SetDirty(dirty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +683,8 @@ UndoSystem::type_str(UndoType t)
|
|||||||
return "DeleteRow";
|
return "DeleteRow";
|
||||||
case UndoType::InsertRow:
|
case UndoType::InsertRow:
|
||||||
return "InsertRow";
|
return "InsertRow";
|
||||||
|
case UndoType::JoinLines:
|
||||||
|
return "JoinLines";
|
||||||
}
|
}
|
||||||
return "?";
|
return "?";
|
||||||
}
|
}
|
||||||
@@ -434,11 +695,14 @@ UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
|
|||||||
{
|
{
|
||||||
if (!root || !target)
|
if (!root || !target)
|
||||||
return false;
|
return false;
|
||||||
if (root == target)
|
std::vector<UndoNode *> stack{root};
|
||||||
return true;
|
while (!stack.empty()) {
|
||||||
for (UndoNode *child = root->child; child != nullptr; child = child->next) {
|
UndoNode *node = stack.back();
|
||||||
if (is_descendant(child, target))
|
stack.pop_back();
|
||||||
|
if (node == target)
|
||||||
return true;
|
return true;
|
||||||
|
for (UndoNode *child = node->child; child != nullptr; child = child->next)
|
||||||
|
stack.push_back(child);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,12 +76,35 @@ public:
|
|||||||
|
|
||||||
void mark_saved();
|
void mark_saved();
|
||||||
|
|
||||||
|
// No undo state matches the file on disk any more (e.g. after crash
|
||||||
|
// recovery replayed edits onto it): the buffer stays dirty in every state
|
||||||
|
// until the next mark_saved() or clear().
|
||||||
|
void mark_base_unsaved();
|
||||||
|
|
||||||
|
// Close any open group (after an interrupted command) and seal pending.
|
||||||
|
void AbortGroups();
|
||||||
|
|
||||||
void discard_pending();
|
void discard_pending();
|
||||||
|
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
void UpdateBufferReference(Buffer &new_buf);
|
void UpdateBufferReference(Buffer &new_buf);
|
||||||
|
|
||||||
|
// History memory budget in bytes (node texts, spans and nodes). When
|
||||||
|
// committed history exceeds it, branches off the current path and then
|
||||||
|
// the oldest edits are dropped. 0 (the default) means max(64 MiB, twice
|
||||||
|
// the buffer's size).
|
||||||
|
void SetByteBudget(std::size_t bytes)
|
||||||
|
{
|
||||||
|
budget_ = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t HistoryBytes() const
|
||||||
|
{
|
||||||
|
return bytes_;
|
||||||
|
}
|
||||||
|
|
||||||
#if defined(KTE_TESTS)
|
#if defined(KTE_TESTS)
|
||||||
// Test-only introspection hook.
|
// Test-only introspection hook.
|
||||||
const UndoTree &TreeForTests() const
|
const UndoTree &TreeForTests() const
|
||||||
@@ -111,9 +134,26 @@ private:
|
|||||||
|
|
||||||
void update_dirty_flag();
|
void update_dirty_flag();
|
||||||
|
|
||||||
|
// Hold a large committed text as storage spans (see UndoNode::spans).
|
||||||
|
void try_convert_to_spans(UndoNode *node);
|
||||||
|
|
||||||
|
// Enforce the byte budget (outside groups).
|
||||||
|
void maybe_prune();
|
||||||
|
|
||||||
|
void prune_to(std::size_t target);
|
||||||
|
|
||||||
|
// The saved state's node is being dropped: no state in history matches
|
||||||
|
// the file any more.
|
||||||
|
void forget_saved_state();
|
||||||
|
|
||||||
|
std::size_t bytes_ = 0; // approximate bytes held by committed history
|
||||||
|
std::size_t budget_ = 0;
|
||||||
|
|
||||||
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
||||||
|
|
||||||
std::uint64_t active_group_id_ = 0;
|
std::uint64_t active_group_id_ = 0;
|
||||||
|
int group_depth_ = 0; // nesting depth of BeginGroup/EndGroup
|
||||||
|
bool base_unsaved_ = false; // see mark_base_unsaved()
|
||||||
std::uint64_t next_group_id_ = 1;
|
std::uint64_t next_group_id_ = 1;
|
||||||
|
|
||||||
Buffer *buf_;
|
Buffer *buf_;
|
||||||
|
|||||||
+30
-2
@@ -1,3 +1,31 @@
|
|||||||
// Placeholder translation unit for UndoTree struct definition.
|
// Undo logic is implemented in UndoSystem; this file only owns node lifetime.
|
||||||
// Undo logic is implemented in UndoSystem.
|
|
||||||
#include "UndoTree.h"
|
#include "UndoTree.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
FreeUndoForest(UndoNode *first)
|
||||||
|
{
|
||||||
|
// Nodes form a first-child/next-sibling tree whose depth equals the
|
||||||
|
// length of the edit history, so walk it with an explicit stack.
|
||||||
|
std::vector<UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
UndoNode *node = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
if (node->child)
|
||||||
|
stack.push_back(node->child);
|
||||||
|
if (node->next)
|
||||||
|
stack.push_back(node->next);
|
||||||
|
delete node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
UndoTree::~UndoTree()
|
||||||
|
{
|
||||||
|
FreeUndoForest(root);
|
||||||
|
delete pending;
|
||||||
|
}
|
||||||
|
|||||||
+12
@@ -7,4 +7,16 @@ struct UndoTree {
|
|||||||
UndoNode *current = nullptr; // current state of buffer
|
UndoNode *current = nullptr; // current state of buffer
|
||||||
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
||||||
UndoNode *pending = nullptr; // in-progress batch (detached)
|
UndoNode *pending = nullptr; // in-progress batch (detached)
|
||||||
|
|
||||||
|
// Frees the entire node graph (root's subtree/branches plus any detached
|
||||||
|
// pending node). `current`/`saved` are aliases into that same graph, not
|
||||||
|
// separately owned. Without this, closing/reloading/resetting a buffer
|
||||||
|
// (which replaces or destroys its UndoTree) leaks every UndoNode ever
|
||||||
|
// created for it.
|
||||||
|
~UndoTree();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Frees `first`, its whole child subtree, and every node on its `next`
|
||||||
|
// (sibling) chain. Iterative, so history depth cannot overflow the stack.
|
||||||
|
void FreeUndoForest(UndoNode *first);
|
||||||
|
|||||||
+31
-4
@@ -1,6 +1,6 @@
|
|||||||
cmake_minimum_required(VERSION 3.15)
|
cmake_minimum_required(VERSION 3.15)
|
||||||
|
|
||||||
# Fix up a macOS .app bundle by copying non-Qt dylibs into
|
# Fix up a macOS .app bundle by copying non-system dylibs into
|
||||||
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
|
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
@@ -29,9 +29,8 @@ set(DIRS
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
|
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
|
||||||
# libs it references recursively. Qt frameworks already live in the bundle after
|
# libs it references recursively, copying every non-system dylib (e.g. Homebrew
|
||||||
# macdeployqt; this step is primarily for non-Qt dylibs (glib, icu, pcre2, zstd,
|
# SDL2, freetype, pcre2) into Contents/Frameworks; SDL3 is handled below.
|
||||||
# dbus, etc.).
|
|
||||||
# fixup_bundle often fails if copied libraries are read-only.
|
# fixup_bundle often fails if copied libraries are read-only.
|
||||||
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
|
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
|
||||||
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
|
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
|
||||||
@@ -44,6 +43,34 @@ execute_process(COMMAND chmod -R u+w "${APP_DIR}/Contents/Frameworks")
|
|||||||
|
|
||||||
fixup_bundle("${APP_DIR}" "" "${DIRS}")
|
fixup_bundle("${APP_DIR}" "" "${DIRS}")
|
||||||
|
|
||||||
|
# Homebrew's sdl2 is sdl2-compat, which dlopen()s SDL3 at startup instead of
|
||||||
|
# linking it, so fixup_bundle never sees it. Without SDL3 in the bundle kge
|
||||||
|
# stops at launch with "Failed loading SDL3 library". sdl2-compat looks for
|
||||||
|
# @loader_path/libSDL3.dylib first; put SDL3 there.
|
||||||
|
set(SDL2_IN_BUNDLE "${APP_DIR}/Contents/Frameworks/libSDL2-2.0.0.dylib")
|
||||||
|
if (EXISTS "${SDL2_IN_BUNDLE}")
|
||||||
|
file(STRINGS "${SDL2_IN_BUNDLE}" SDL2_COMPAT_REFS REGEX "libSDL3\\.dylib")
|
||||||
|
if (SDL2_COMPAT_REFS)
|
||||||
|
find_file(SDL3_DYLIB NAMES libSDL3.0.dylib libSDL3.dylib
|
||||||
|
PATHS "/opt/homebrew/opt/sdl3/lib" "/opt/homebrew/lib" "/usr/local/opt/sdl3/lib" "/usr/local/lib"
|
||||||
|
NO_DEFAULT_PATH)
|
||||||
|
if (NOT SDL3_DYLIB)
|
||||||
|
message(FATAL_ERROR "libSDL2 in the bundle is sdl2-compat, which needs SDL3 at run time, "
|
||||||
|
"but libSDL3 was not found (brew install sdl3)")
|
||||||
|
endif ()
|
||||||
|
file(REAL_PATH "${SDL3_DYLIB}" SDL3_REAL)
|
||||||
|
set(SDL3_IN_BUNDLE "${APP_DIR}/Contents/Frameworks/libSDL3.dylib")
|
||||||
|
message(STATUS "Bundling SDL3 for sdl2-compat: ${SDL3_REAL}")
|
||||||
|
file(COPY_FILE "${SDL3_REAL}" "${SDL3_IN_BUNDLE}")
|
||||||
|
execute_process(COMMAND chmod u+w "${SDL3_IN_BUNDLE}")
|
||||||
|
execute_process(COMMAND /usr/bin/install_name_tool -id "@loader_path/libSDL3.dylib" "${SDL3_IN_BUNDLE}"
|
||||||
|
RESULT_VARIABLE SDL3_ID_RESULT)
|
||||||
|
if (NOT SDL3_ID_RESULT EQUAL 0)
|
||||||
|
message(FATAL_ERROR "install_name_tool -id failed on ${SDL3_IN_BUNDLE}")
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
|
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
|
||||||
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
|
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
|
||||||
# for it to be allowed to run.
|
# for it to be allowed to run.
|
||||||
|
|||||||
+47
-24
@@ -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
|
cp kge $out/bin/kge
|
||||||
|
|
||||||
${if graphical-qt then ''
|
|
||||||
cp kge $out/bin/kge-qt
|
|
||||||
'' else ''
|
|
||||||
cp kge $out/bin/kge
|
|
||||||
''}
|
|
||||||
|
|
||||||
installManPage ../docs/kge.1
|
installManPage ../docs/kge.1
|
||||||
|
|
||||||
mkdir -p $out/share/icons/hicolor/256x256/apps
|
mkdir -p $out/share/icons/hicolor/256x256/apps
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ kte follows a clean separation of concerns with three main layers:
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────┐
|
┌─────────────────────────────────────────┐
|
||||||
│ Frontend Layer (Terminal/ImGui/Qt) │
|
│ Frontend Layer (Terminal/ImGui) │
|
||||||
│ - TerminalFrontend / ImGuiFrontend │
|
│ - TerminalFrontend / ImGuiFrontend │
|
||||||
│ - InputHandler + Renderer interfaces │
|
│ - InputHandler + Renderer interfaces │
|
||||||
└─────────────────────────────────────────┘
|
└─────────────────────────────────────────┘
|
||||||
@@ -154,7 +154,6 @@ Implementations:
|
|||||||
`TerminalInputHandler`, `TerminalRenderer`)
|
`TerminalInputHandler`, `TerminalRenderer`)
|
||||||
- **ImGui**: Dear ImGui-based (`ImGuiFrontend`, `ImGuiInputHandler`,
|
- **ImGui**: Dear ImGui-based (`ImGuiFrontend`, `ImGuiInputHandler`,
|
||||||
`ImGuiRenderer`)
|
`ImGuiRenderer`)
|
||||||
- **Qt**: Qt-based (`QtFrontend`, `QtInputHandler`, `QtRenderer`)
|
|
||||||
- **Test**: Programmatic testing (`TestFrontend`, `TestInputHandler`,
|
- **Test**: Programmatic testing (`TestFrontend`, `TestInputHandler`,
|
||||||
`TestRenderer`)
|
`TestRenderer`)
|
||||||
|
|
||||||
@@ -196,7 +195,6 @@ Large files that may need attention:
|
|||||||
into logical groups
|
into logical groups
|
||||||
- `Swap.cc` (1300 lines) - Crash recovery system (migrated to direct
|
- `Swap.cc` (1300 lines) - Crash recovery system (migrated to direct
|
||||||
PieceTable operations)
|
PieceTable operations)
|
||||||
- `QtFrontend.cc` (985 lines) - Qt integration
|
|
||||||
- `ImGuiRenderer.cc` (930 lines) - ImGui rendering
|
- `ImGuiRenderer.cc` (930 lines) - ImGui rendering
|
||||||
- `PieceTable.cc` (800 lines) - Core data structure
|
- `PieceTable.cc` (800 lines) - Core data structure
|
||||||
- `Buffer.cc` (763 lines) - Document model
|
- `Buffer.cc` (763 lines) - Document model
|
||||||
|
|||||||
@@ -0,0 +1,678 @@
|
|||||||
|
# Bug and Performance Audit
|
||||||
|
|
||||||
|
**Project:** kte (Kyle's Text Editor)
|
||||||
|
**Date:** 2026-09-23
|
||||||
|
**Auditor:** Claude Code (automated review, four parallel reviewers plus
|
||||||
|
spot verification)
|
||||||
|
**Baseline:** `d8d2ae8` (debug build clean, `kte_tests` 0 failures)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
The tree was split into four review areas: core text model (PieceTable,
|
||||||
|
Buffer, OptimizedSearch), undo and swap (UndoSystem/Tree/Node, Swap,
|
||||||
|
ErrorRecovery, SyscallWrappers), commands (Command.cc, KKeymap), and
|
||||||
|
frontends/syntax (Terminal, ImGui, Qt, Editor, `syntax/`). Every finding
|
||||||
|
below was traced to a concrete scenario. Findings marked **[repro]** were
|
||||||
|
reproduced with a small test program; the rest were confirmed by reading
|
||||||
|
the code.
|
||||||
|
|
||||||
|
Finding IDs are stable and are referenced by fix commits:
|
||||||
|
|
||||||
|
- **M** — memory safety (crash, out-of-bounds, use-after-free, data race)
|
||||||
|
- **D** — data integrity (undo, swap journal, text corruption)
|
||||||
|
- **B** — incorrect behaviour
|
||||||
|
- **P** — performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M — Memory safety
|
||||||
|
|
||||||
|
### M1. PieceTable assignment keeps a stale line index **[repro]**
|
||||||
|
|
||||||
|
`PieceTable.cc:47-98`. Copy and move assignment copy `pieces_`, `add_`,
|
||||||
|
and `total_size_`, but leave the destination's `line_index_` and
|
||||||
|
`line_index_dirty_` untouched. `Editor::CloseBuffer` erases from
|
||||||
|
`std::vector<Buffer>`, which move-assigns every later buffer into the
|
||||||
|
previous slot.
|
||||||
|
|
||||||
|
Scenario: open A (`"line1\nline2\nline3\n"`), open B (`"x"`), render
|
||||||
|
both, close A. Slot 0 now holds B's text with A's index: `LineCount()`
|
||||||
|
returns 4 and `GetLineView(1)` builds a view over bytes 6–12 of a 1-byte
|
||||||
|
buffer.
|
||||||
|
|
||||||
|
Fix: mark the line index dirty (and clear it) in both assignment
|
||||||
|
operators.
|
||||||
|
|
||||||
|
### M2. Background highlighter thread races buffer mutation
|
||||||
|
|
||||||
|
`syntax/HighlighterEngine.cc:184-249`. `PrefetchViewport` (called from
|
||||||
|
the terminal and ImGui renderers) queues rows for a worker thread which
|
||||||
|
calls `buf.GetLineString`, walking `pieces_` and reading `add_`. The main
|
||||||
|
thread's `PieceTable::Insert`/`Delete` mutate both without any shared
|
||||||
|
lock; `add_.append` may reallocate. The worker also stores a raw
|
||||||
|
`Buffer*` (`pending_.buf`) which dangles when `AddBuffer`'s `push_back`
|
||||||
|
reallocates or `CloseBuffer`'s `erase` shifts buffers.
|
||||||
|
|
||||||
|
Fix: remove the background warm-up, or feed it an immutable snapshot.
|
||||||
|
|
||||||
|
### M3. Per-window SwapManager leaves dangling recorders (ImGui multi-window)
|
||||||
|
|
||||||
|
`ImGuiFrontend.cc:381-385`, `Editor.cc:205-213`. Each window's `Editor`
|
||||||
|
owns its own `SwapManager` but shares the buffer vector. A buffer created
|
||||||
|
from a secondary window keeps a `swap_rec_` pointing into that window's
|
||||||
|
manager; closing the window destroys it, and the next edit in the main
|
||||||
|
window calls into freed memory. `Rehome` from the wrong manager also
|
||||||
|
returns `nullptr`, silently disabling journaling for the primary's
|
||||||
|
buffers.
|
||||||
|
|
||||||
|
Fix: share one `SwapManager` across windows, and detach every buffer an
|
||||||
|
`Editor` attached when that `Editor` is destroyed.
|
||||||
|
|
||||||
|
### M4. Recursive undo-tree free overflows the stack **[repro]**
|
||||||
|
|
||||||
|
`UndoTree.cc:12` (`free_node_graph`), also `UndoSystem.cc:354`
|
||||||
|
(`is_descendant`, `dfs_find_parent`). Recursion depth equals history
|
||||||
|
length. About 300k nodes segfaults a debug build on an 8 MB stack;
|
||||||
|
replace-all over ~150k matches (two nodes each) followed by close or
|
||||||
|
reload is enough.
|
||||||
|
|
||||||
|
Fix: free iteratively with an explicit stack.
|
||||||
|
|
||||||
|
### M5. Jump-to-mark places the cursor past the end of the buffer
|
||||||
|
|
||||||
|
`Command.cc:3720`. Marks are not adjusted on edits and
|
||||||
|
`cmd_jump_to_mark` does not clamp. After deleting lines below a mark,
|
||||||
|
C-k j puts the cursor on a nonexistent row; `cmd_move_left` and
|
||||||
|
`cmd_move_up` (`:3866`, `:3988`) then index `rows[y]` out of bounds.
|
||||||
|
|
||||||
|
Fix: clamp in `cmd_jump_to_mark` and in `compute_mark_region`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D — Data integrity
|
||||||
|
|
||||||
|
### D1. Smart-newline undo deletes the wrong text **[repro]**
|
||||||
|
|
||||||
|
`Command.cc:3139-3146`. The indent is inserted and the cursor moved past
|
||||||
|
it before `u->Begin(UndoType::Insert)`; `UndoSystem::Begin` records the
|
||||||
|
cursor position, so the node is placed `indent.size()` columns too far
|
||||||
|
right. `" foobar"` + SmartNewline + two undos yields
|
||||||
|
`" foo next"` (the following line's text is spliced in and `bar`
|
||||||
|
is lost).
|
||||||
|
|
||||||
|
Fix: `Begin` at `new_x`, then insert, then move the cursor; group the
|
||||||
|
newline and the indent.
|
||||||
|
|
||||||
|
### D2. Cursor column left past end-of-line corrupts undo
|
||||||
|
|
||||||
|
`cmd_scroll_up`/`cmd_scroll_down` (`Command.cc:4178`, `:4215`, bound to
|
||||||
|
the mouse wheel), replace-all's cursor restore (`:2580`), and
|
||||||
|
`cmd_jump_to_mark` call `SetCursor` without clamping x. `delete_text`
|
||||||
|
does not clamp; it treats the excess as crossing the newline.
|
||||||
|
Backspace then joins lines with an empty (dropped) undo node, and
|
||||||
|
typing followed by undo deletes a newline instead of the typed
|
||||||
|
character.
|
||||||
|
|
||||||
|
Fix: clamp x to the line length in `Buffer::SetCursor`.
|
||||||
|
|
||||||
|
### D3. `delete_row` on a final line without trailing newline leaves a stray `\n`
|
||||||
|
|
||||||
|
`Buffer.cc:803`. Affects `delete_region` (C-w, M-d, M-Backspace), regex
|
||||||
|
replace (`Command.cc:2996`), reflow (`:4884`), and the kill ring.
|
||||||
|
`"foo\nbar"` + M-d at end of `foo` yields `"foo\n"`.
|
||||||
|
|
||||||
|
### D4. Phantom empty row after a trailing newline
|
||||||
|
|
||||||
|
`PieceTable::LineCount()` counts the empty row after a final `\n`.
|
||||||
|
C-k C-d on that row records a `DeleteRow("")` whose undo inserts a
|
||||||
|
line (`Command.cc:3516`); regex replace of `^` matches it and appends
|
||||||
|
an extra line (`:2985`).
|
||||||
|
|
||||||
|
### D5. Missing or wrong undo grouping
|
||||||
|
|
||||||
|
- Plain replace-all (`Command.cc:2537-2577`) has no group: N
|
||||||
|
replacements need 2N undos, and the final Insert stays pending so the
|
||||||
|
next typed characters coalesce into it.
|
||||||
|
- Visual-line newline (`:3043-3075`) records no undo at all.
|
||||||
|
|
||||||
|
### D6. Reflow on a blank line merges the adjacent paragraphs
|
||||||
|
|
||||||
|
`Command.cc:4592`. `"aaa\n\nbbb"` with the cursor on the blank line and
|
||||||
|
ESC q gives `"aaa bbb"`.
|
||||||
|
|
||||||
|
### D7. Swap journal is fragile exactly where it matters
|
||||||
|
|
||||||
|
- `Swap.cc:1344-1373`: a torn final record (the power-loss case) makes
|
||||||
|
the whole file unreadable instead of replaying the valid prefix.
|
||||||
|
- `Swap.cc:1134-1157`, `:1187`, `:1224`: after any dropped record
|
||||||
|
(breaker open, >16 MB payload, partial write) later records still
|
||||||
|
replay against a different base state; a partial write leaves bytes
|
||||||
|
that later records are appended behind.
|
||||||
|
- `Swap.cc:1040` vs `:1187`: checkpoints allow 4 GB but the record
|
||||||
|
length is 24 bits; buffers over 16 MB are never checkpointed and the
|
||||||
|
journal grows without bound.
|
||||||
|
- `Swap.cc:644`, `:708`: a failed header write leaves an fd with no
|
||||||
|
header; answering N to "delete corrupt swap?" appends new edits to the
|
||||||
|
corrupt file.
|
||||||
|
|
||||||
|
### D8. Circuit breaker failure window never expires
|
||||||
|
|
||||||
|
`ErrorRecovery.cc:77`. `last_failure_time_` is updated before
|
||||||
|
`IsWindowExpired()` reads it, so elapsed time is always zero; five
|
||||||
|
failures hours apart still open the breaker.
|
||||||
|
|
||||||
|
### D9. Stale syntax highlighting after undo-to-clean or reload
|
||||||
|
|
||||||
|
`Buffer.h:391-400`. `version_` is bumped only in `SetDirty(true)`. Undo
|
||||||
|
back to the saved node calls `SetDirty(false)`, and `OpenFromFile`
|
||||||
|
(reload) never bumps it, so `HighlighterEngine` keeps serving spans for
|
||||||
|
the old text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B — Behaviour
|
||||||
|
|
||||||
|
### B1. `set_escdelay` is compiled out
|
||||||
|
|
||||||
|
`TerminalFrontend.cc:46`. `set_escdelay` is a function in ncurses, not a
|
||||||
|
macro, so `#ifdef set_escdelay` is always false and ESC waits the
|
||||||
|
1000 ms default.
|
||||||
|
|
||||||
|
### B2. Terminal resize runs C-g
|
||||||
|
|
||||||
|
`TerminalInputHandler.cc:167-171` maps `KEY_RESIZE` to
|
||||||
|
`CommandId::Refresh`, the cancel handler: it clears the mark, cancels
|
||||||
|
prompts (including the swap-recovery prompt, so the file is never
|
||||||
|
opened), and resets the universal argument.
|
||||||
|
|
||||||
|
### B3. ESC-as-Meta flag is not cleared on most paths
|
||||||
|
|
||||||
|
`TerminalInputHandler.cc:56-279`. ESC followed by an arrow, Enter, Tab,
|
||||||
|
or a control chord leaves `esc_meta` set; the next plain `d` runs M-d.
|
||||||
|
Same in `ImGuiInputHandler.cc:43-73`.
|
||||||
|
|
||||||
|
### B4. C-h shadows Backspace in the terminal
|
||||||
|
|
||||||
|
The generic control lookup (`TerminalInputHandler.cc:264`) maps ^H to
|
||||||
|
SearchReplace before the Backspace branch (`:307`) runs.
|
||||||
|
|
||||||
|
### B5. Byte-based editing splits UTF-8
|
||||||
|
|
||||||
|
`Command.cc:3259`, `:3352`, `:3861-3947`. Backspace, delete, and
|
||||||
|
horizontal motion step by byte: Backspace on `é` leaves a lone `0xC3`,
|
||||||
|
and C-b/C-f land inside code points.
|
||||||
|
|
||||||
|
### B6. Horizontal scroll counts bytes, renderer counts cells
|
||||||
|
|
||||||
|
`compute_render_x` (`Command.cc:52-60`) counts bytes while
|
||||||
|
`TerminalRenderer.cc:269-322` skips display cells: a long CJK line
|
||||||
|
scrolls to blank with the cursor off-screen.
|
||||||
|
|
||||||
|
### B7. GUI draws non-ASCII text as replacement glyphs
|
||||||
|
|
||||||
|
Highlighters emit one span per non-ASCII byte and `ImGuiRenderer.cc`
|
||||||
|
(~510) / `QtFrontend.cc` (~333) decode each span separately.
|
||||||
|
|
||||||
|
### B8. ImGui drops the character after Ctrl+V
|
||||||
|
|
||||||
|
`ImGuiInputHandler.cc:362`, `:382`, `:401` set a suppress-next-text flag
|
||||||
|
for chords that produce no text event.
|
||||||
|
|
||||||
|
### B9. Control characters mis-measured in the terminal
|
||||||
|
|
||||||
|
`TerminalRenderer.cc:314`, `:364`, `:413` count C0 controls as width 1;
|
||||||
|
ncurses draws them as `^X`.
|
||||||
|
|
||||||
|
### B10. Qt tab width disagrees with the command layer
|
||||||
|
|
||||||
|
`QtFrontend.cc:138` uses 4; `Command.cc` uses 8.
|
||||||
|
|
||||||
|
### B11. Smart newline edits the old buffer while a prompt is open
|
||||||
|
|
||||||
|
`Command.cc:3131`. With a prompt active, `cmd_newline` accepts it and
|
||||||
|
the saved indent is inserted through a possibly stale `Buffer*`.
|
||||||
|
|
||||||
|
### B12. Line count stale after merged appends **[repro]**
|
||||||
|
|
||||||
|
`PieceTable.cc:220-260`. `addPieceBack`/`addPieceFront` merge paths
|
||||||
|
skip `InvalidateLineIndex()`. Latent today.
|
||||||
|
|
||||||
|
### B13. Syntax highlighter edge cases
|
||||||
|
|
||||||
|
Go raw strings and Rust multi-line strings do not carry state across
|
||||||
|
lines; shell `$#` is highlighted as a comment; a C++ `#define` line
|
||||||
|
opening `/*` does not carry the comment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P — Performance
|
||||||
|
|
||||||
|
### P1. Whole-file `Rows()` rebuild per frame
|
||||||
|
|
||||||
|
Renderers call `buf->Rows()` every frame (`TerminalRenderer.cc:45`,
|
||||||
|
`ImGuiRenderer.cc:69`, `QtFrontend.cc:477`), and
|
||||||
|
`ensure_cursor_visible` (`Command.cc:91`) after every command. After an
|
||||||
|
edit this rebuilds a `std::string` per line, each via two `GetRange`
|
||||||
|
calls.
|
||||||
|
|
||||||
|
### P2. Whole-buffer materialization for one line
|
||||||
|
|
||||||
|
`Buffer::GetLineView` (`Buffer.cc:633`) calls `content_.Data()`, which
|
||||||
|
copies the entire file; the line index is rebuilt byte-by-byte after
|
||||||
|
every edit.
|
||||||
|
|
||||||
|
### P3. Re-highlight from row 0 on every keystroke
|
||||||
|
|
||||||
|
`SetDirty(true)` calls `InvalidateFrom(0)`; stateful highlighters then
|
||||||
|
re-lex from the top of the file to the viewport.
|
||||||
|
|
||||||
|
### P4. Quadratic region commands
|
||||||
|
|
||||||
|
Replace-all, `delete_region`, indent/unindent, reflow, and repeated
|
||||||
|
backspace re-fetch `Rows()` after each mutation inside their loops.
|
||||||
|
|
||||||
|
### P5. Opening a file checkpoints into a journal that is then deleted
|
||||||
|
|
||||||
|
`Swap.cc:493` snapshots, writes, and fsyncs the whole file into
|
||||||
|
`unnamed-N.swp` before removing it.
|
||||||
|
|
||||||
|
### P6. Boyer–Moore bad-character shift degenerates to 1
|
||||||
|
|
||||||
|
`OptimizedSearch.cc:63`, `:96`: when the mismatched byte is absent from
|
||||||
|
the pattern the shift is 1 instead of `j`. Test-only today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended order
|
||||||
|
|
||||||
|
1. M1 (one-line fix)
|
||||||
|
2. M2 (remove or snapshot the warm-up thread)
|
||||||
|
3. D2 + M5 (clamp in `SetCursor` and jump-to-mark)
|
||||||
|
4. D1 (smart-newline undo order)
|
||||||
|
5. D7 (accept a torn final swap record)
|
||||||
|
6. P1/P2 (`Nrows()` + `GetLineString` in hot paths)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix status
|
||||||
|
|
||||||
|
Updated as fixes land on `claude/funny-fermat-gj2vm5`.
|
||||||
|
|
||||||
|
| ID | Status |
|
||||||
|
|----|--------|
|
||||||
|
| M1 | fixed: assignment operators invalidate the line index |
|
||||||
|
| M2 | fixed: background warm-up thread removed; visible rows highlighted synchronously |
|
||||||
|
| M3 | fixed: editors sharing buffers share the owner's SwapManager |
|
||||||
|
| M4 | fixed: iterative free/search; `clear()` also leaked root siblings, now frees all |
|
||||||
|
| M5 | fixed: jump-to-mark clamps; move up/down clamp the row (move-down also underflowed) |
|
||||||
|
| D1 | fixed: `Begin` before the cursor moves; newline + indent grouped |
|
||||||
|
| D2 | fixed for scroll up/down and replace-all restore |
|
||||||
|
| D3 | fixed: kill-region, regex replace, reflow and kill-line edit byte ranges in place |
|
||||||
|
| D4 | fixed: regex replace skips the row after a final newline (a zero-width pattern used to hang the editor); kill-line there is a no-op |
|
||||||
|
| D5 | fixed: replace-all one undo group; visual-line newline recorded as one group |
|
||||||
|
| D6 | fixed: reflow on a blank line is a no-op |
|
||||||
|
| D7 | fixed: torn tail replays the valid prefix; lost records mark a gap resynced by checkpoint; partial writes truncated; header failure closes; large inserts split; oversize checkpoints skipped; declined corrupt swap moved to `.corrupt` |
|
||||||
|
| D8 | fixed: window checked before recording the failure |
|
||||||
|
| D9 | fixed: every text mutation bumps the version (including undo to the saved state); reload and replace also clear undo/highlighting |
|
||||||
|
| B1 | fixed: guard on `NCURSES_VERSION` |
|
||||||
|
| B2 | fixed: `KEY_RESIZE` issues no command |
|
||||||
|
| B3 | fixed: ESC-meta flag consumed on every key; ESC ^H and ESC Enter reach their bindings |
|
||||||
|
| B4 | not changed: C-h is a deliberate binding (search & replace); terminals whose terminfo `kbs` is ^H are translated to KEY_BACKSPACE by ncurses |
|
||||||
|
| B5 | fixed: backspace/delete/left/right step over whole UTF-8 characters; up/down snap to a boundary |
|
||||||
|
| B6 | fixed: horizontal scroll and mouse column use display cells (mbrtowc/wcwidth) |
|
||||||
|
| B7 | fixed: spans are widened to whole UTF-8 characters before the GUIs draw them (`SanitizeSpans`) |
|
||||||
|
| B8 | fixed: a text-event suppression lasts only until the next key press |
|
||||||
|
| B9 | fixed: C0 controls and DEL drawn and measured as two-cell caret notation (`TermWidth.h`) |
|
||||||
|
| B10 | fixed: one tab width (`kte::kTabWidth`); the Qt frontend has since been removed |
|
||||||
|
| B13 | open (highlighter edge cases) |
|
||||||
|
| B11 | fixed: SmartNewline with a prompt open delegates to Newline |
|
||||||
|
| B12 | fixed: merged appends invalidate the line index |
|
||||||
|
| P1 | fixed: `ensure_cursor_visible` and the terminal/ImGui renderers no longer call `Rows()` |
|
||||||
|
| P2 | fixed: line index updated in place on insert/delete (14.7 ms -> 0.28 ms per keystroke on a 38 MB buffer) |
|
||||||
|
| P3 | fixed: highlighting invalidated from the edited row (46 ms -> 0.14 ms per keystroke near the end of a 100k-line C++ file) |
|
||||||
|
| P4 | fixed: replace-all, delete-region, indent/unindent and visual-line edits each apply as one edit |
|
||||||
|
| P5 | fixed: no throwaway checkpoint on filename change |
|
||||||
|
| P6 | moot: `OptimizedSearch` was test-only and has been removed |
|
||||||
|
|
||||||
|
Regression tests: `tests/test_audit_regressions.cc`. Each test fails on the
|
||||||
|
unfixed code (M4 by crashing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-up review rounds
|
||||||
|
|
||||||
|
After the first batch of fixes, further review rounds (an adversarial
|
||||||
|
review of each batch of changes, plus fresh reviews of areas the first
|
||||||
|
audit covered lightly) found further defects. All of the following are
|
||||||
|
fixed on this branch, each with a regression test that fails on the code
|
||||||
|
before the fix (unless noted).
|
||||||
|
|
||||||
|
**Crash / hang**
|
||||||
|
- Opening a directory (bad_alloc exit), a FIFO (open blocked forever), or
|
||||||
|
a device node (later replaced by a regular file on save).
|
||||||
|
- Filesystem exceptions (ENAMETOOLONG, EACCES, deleted cwd) and any other
|
||||||
|
exception escaping a command terminated the editor.
|
||||||
|
- Regex search/replace, and renderer match highlighting, overflowed the
|
||||||
|
stack on long lines (std::regex recursion); a zero-width regex replace
|
||||||
|
hung the editor (D4).
|
||||||
|
- Piped (non-tty) stdin was executed as commands, then spun at full CPU.
|
||||||
|
|
||||||
|
**Data loss / corruption**
|
||||||
|
- Recovery deleted the swap it was about to replay when another named
|
||||||
|
buffer was current (pre-existing).
|
||||||
|
- Recovered sessions appended behind a torn final record.
|
||||||
|
- Reload left the old journal (discarded edits came back on recovery);
|
||||||
|
reload discarded unsaved edits without confirmation and "reloaded" a
|
||||||
|
deleted file as empty.
|
||||||
|
- Two buffers (duplicate open, save-as onto an open file) or two
|
||||||
|
sessions shared one journal; closing/saving one deleted the other's
|
||||||
|
live journal. Journals are now flock()ed; a session never removes a
|
||||||
|
journal it does not own; unnamed journals include the pid; distinct
|
||||||
|
paths no longer map to one swap name.
|
||||||
|
- Journals now record the base file's size, mtime and CRC-32 and are
|
||||||
|
not replayed onto a file that changed afterwards.
|
||||||
|
- Quit checked only the current buffer; Enter at "Recover?" / "Save
|
||||||
|
changes?" took the destructive branch; the close prompt's save
|
||||||
|
overwrote on-disk changes; save-as overwrote other files silently.
|
||||||
|
- Kill-line with a count, stale marks in kill-region, commands running
|
||||||
|
behind an open prompt (read-only bypass, wrong buffer closed), paste
|
||||||
|
into a prompt, nested undo groups, UTF-8 splitting in word motions.
|
||||||
|
- Save: fsync retried after failure (could report success for lost
|
||||||
|
data); new files got 0600; symlinks replaced; hard links split;
|
||||||
|
owner and setuid/setgid bits lost; umask race with the writer thread.
|
||||||
|
- Writer-thread fsync raced with closes (fd reuse; kept a closed
|
||||||
|
journal's lock alive); crc32 table init race; Close() retried EINTR.
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- Motion/delete commands rebuilt every line after an edit (80-90 ms per
|
||||||
|
keystroke on 500k lines; C-u 3000 C-d took 273 s, now 0.25 s).
|
||||||
|
- Line index invalidated by consolidation; incremental index added.
|
||||||
|
- Idle redraw every 16 ms rescanned long lines (one core busy); paste
|
||||||
|
handled one key per frame; RowsView retained unbounded copies.
|
||||||
|
|
||||||
|
**Fourth round (stress testing)**
|
||||||
|
- Undo/redo of multi-line text left the cursor past the end of the
|
||||||
|
line; later edits and their undo records went astray and
|
||||||
|
delete-word-prev read out of bounds (ASan).
|
||||||
|
- A recovered buffer looked clean after one edit and its undo; closing
|
||||||
|
it discarded the recovered text and journal. Save-and-quit did not
|
||||||
|
record the saved state; stale visual-line selections pointed past the
|
||||||
|
end of the buffer; open undo groups survived an exception.
|
||||||
|
- A reload that ran out of memory emptied the buffer, and the next save
|
||||||
|
truncated the file. Exceptions from deferred opens ended the editor.
|
||||||
|
A leftover hard-link copy blocked every later save; a file in a
|
||||||
|
read-only directory could not be saved.
|
||||||
|
- The journal writer's periodic fsync held the lock every keystroke
|
||||||
|
needs (typing froze on slow disks); every open and save re-read the
|
||||||
|
whole file under that lock; two ThreadSanitizer races.
|
||||||
|
- Replace-all, yank and repeat counts issued one edit per match, line
|
||||||
|
or repetition (quadratic: minutes for 229k matches, now 0.14 s).
|
||||||
|
- With LANG unset, multibyte input was dropped. Renderer caches and the
|
||||||
|
reload confirmation were keyed on buffer addresses, which are reused
|
||||||
|
after a close; a reload confirmation survived other commands; a swap
|
||||||
|
recovery prompt from a deferred open was not drawn until a key.
|
||||||
|
|
||||||
|
**Fifth round (review of the fourth round's changes)**
|
||||||
|
- The save fallbacks added in round four (write in place when no temp
|
||||||
|
file or hard-link copy can be made) also fired on ENOSPC/EDQUOT/EIO,
|
||||||
|
truncating the file and then failing: the file on disk was left
|
||||||
|
corrupt. They now apply only when the directory refuses a new name
|
||||||
|
(EACCES, EPERM, ENAMETOOLONG); otherwise the save fails and the file
|
||||||
|
is untouched.
|
||||||
|
- Whole-buffer replace recorded the entire old and new text for undo on
|
||||||
|
every run (about 150 MB more per replace-all on a 50 MB file); only
|
||||||
|
the span between the common prefix and suffix is now recorded.
|
||||||
|
- Regex search silently never found matches on lines over 20,000 bytes;
|
||||||
|
the skip now applies only to search-as-you-type (at 2,000 bytes, where
|
||||||
|
the worst case is well under a second), says so in the status line,
|
||||||
|
and moving to the next/previous match searches every line.
|
||||||
|
- After a lost journal record, the recovery checkpoint was requested
|
||||||
|
only by later edits, at most once a second; if the first attempt
|
||||||
|
failed and the user then stopped typing, the journal stayed behind
|
||||||
|
indefinitely. The editor loop now retries it each second while idle
|
||||||
|
(this was also why SwapReplay_LostRecord_ResyncsWithCheckpoint was
|
||||||
|
flaky under load).
|
||||||
|
- Undoing C-k C-k (and redoing an inserted row) journaled the row and
|
||||||
|
its newline as two records; a checkpoint triggered by the first
|
||||||
|
already contained the newline, so recovery silently produced an
|
||||||
|
extra blank line.
|
||||||
|
- Visual-line yank with the cursor moved out of the selection (C-k a,
|
||||||
|
Enter) left it past the end of a line or the buffer, and text typed
|
||||||
|
there could not be undone. Every command now leaves the cursor
|
||||||
|
inside the buffer (checked in the command dispatcher).
|
||||||
|
- ENABLE_ASAN instrumented only C sources, i.e. none of the project;
|
||||||
|
it now applies to C++ as well. (Earlier "ASan clean" results in this
|
||||||
|
audit came from that uninstrumented build; the full suite has since
|
||||||
|
passed under a correctly instrumented ASan build with leak checks,
|
||||||
|
and the stress fuzzers ran with ASan and UBSan.)
|
||||||
|
|
||||||
|
**Sixth round (real terminal, file I/O, recovery end to end)**
|
||||||
|
- UTF-8 typed or pasted over a slow link lost characters: with the
|
||||||
|
input batch read at zero timeout, ncurses gave up on a character split
|
||||||
|
across reads and discarded the rest as invalid. kte now assembles
|
||||||
|
UTF-8 itself and waits for continuation bytes. (This regression came
|
||||||
|
from this audit's own input batching.)
|
||||||
|
- Visual-line (multi-line) insert, delete, backspace and newline used
|
||||||
|
the cursor's byte column on every selected line, splitting multibyte
|
||||||
|
characters on the others; the column is now carried in characters.
|
||||||
|
- Journal failures reached only the error log: a session locked out of
|
||||||
|
the journal by another kte (which only happens when the other opened
|
||||||
|
the file before this one's first edit), or whose journal writes
|
||||||
|
failed, silently had no or partial crash recovery. The status line now
|
||||||
|
says so (once per failure).
|
||||||
|
- Basenames over about 233 bytes produced a swap name over NAME_MAX, so
|
||||||
|
such files were never journaled. (The first fix capped the name at
|
||||||
|
exactly 255 bytes, which broke compaction's ".tmp" sibling and let
|
||||||
|
the journal grow without bound; it now leaves room for it.)
|
||||||
|
|
||||||
|
**Seventh round (highlighters, region and paragraph commands, buffers)**
|
||||||
|
- Indent-region on a selection ending in the empty row after a final
|
||||||
|
newline added a tab-only last line (empty lines are now skipped).
|
||||||
|
- Reflow emitted a prefix-only line before a first word longer than the
|
||||||
|
width (a blank line before a long URL, a bullet split from its text),
|
||||||
|
adding another on every reflow; joined paragraphs separated by a
|
||||||
|
whitespace-only line; and moved the CRs of CRLF lines into the middle
|
||||||
|
of lines.
|
||||||
|
- A counted backspace/delete that joined lines took several undos.
|
||||||
|
- The C++ highlighter scanned to the end of the line for a raw-string
|
||||||
|
'(' at every R", quadratic on long lines.
|
||||||
|
- Fuzzing every highlighter (incremental vs full), 20,000 random command
|
||||||
|
sequences, and 6,300 random buffer-list sessions found nothing else.
|
||||||
|
|
||||||
|
**Eighth round (reflow review; large-file performance)**
|
||||||
|
- Reflow's CRLF handling applied only when every line had a CR, so the
|
||||||
|
last paragraph of a CRLF file without a final newline still got CRs
|
||||||
|
mid-line; wrapping could start a line with "-" or "1.", which the next
|
||||||
|
reflow read as a list.
|
||||||
|
- Large files (26-105 MB): the stateful highlighter cached spans for
|
||||||
|
every row above the viewport (854 MB for a 78 MB C++ file after
|
||||||
|
jumping to the end; now 197 MB); search built and kept a string per
|
||||||
|
line on every keystroke after an edit (now one scan of the
|
||||||
|
materialized text: 42 -> 28 ms per keystroke on 26 MB); indent,
|
||||||
|
unindent and visual-line insert/delete/backspace/newline/yank made one
|
||||||
|
piece-table edit per row (1.1 s per keystroke with 5,000 rows
|
||||||
|
selected; now 4 ms, as one edit).
|
||||||
|
|
||||||
|
**Ninth round (review of the eighth round's changes)**
|
||||||
|
- No serious findings. Highlighting (cached vs fresh), search (against
|
||||||
|
the old per-line code, 3,200 random buffers) and the row-range edits
|
||||||
|
(differential against the previous commit, ~88k random steps: text
|
||||||
|
identical after every step; undo-all and journal replay exact) all
|
||||||
|
agreed. Redo-all after heavy branching can end on an older branch of
|
||||||
|
the undo tree (a state that did exist), as before.
|
||||||
|
|
||||||
|
**Follow-up: undo memory**
|
||||||
|
- Undo texts of 4 KiB or more are held as references to the piece
|
||||||
|
table's (append-only) storage instead of copies, verified byte for
|
||||||
|
byte when converted; undo/redo re-splice the stored bytes instead of
|
||||||
|
copying them into storage again. A replace-all session on a 26 MB file
|
||||||
|
ends at 181 MB instead of 332 MB; undo/redo cycles of a 6.6 MB kill
|
||||||
|
stay at 75 MB instead of growing by 6.6 MB each.
|
||||||
|
- History has a byte budget (default max(64 MiB, twice the buffer)):
|
||||||
|
over it, branches off the current path, then the oldest edits (whole
|
||||||
|
undo groups), then the redo line are dropped. If the saved state goes,
|
||||||
|
the buffer stays modified until the next save.
|
||||||
|
- replace_all_bytes clears undo history (it was recorded against, and
|
||||||
|
may reference, the previous content).
|
||||||
|
|
||||||
|
**Follow-up: search**
|
||||||
|
- Search finds the next match from the search origin (then from the
|
||||||
|
current match) and stops there; it no longer collects every match in
|
||||||
|
the buffer on every keystroke and next/previous. The i/N count stops
|
||||||
|
at 1,000 matches (and, for regex, after 4 MiB). On 26 MB: plain
|
||||||
|
keystroke 30 ms -> 0.1 ms, next 24 -> 3 ms, regex keystroke 434 ->
|
||||||
|
1.6 ms. Previous-match scans backwards in 1 MiB forward-searched
|
||||||
|
blocks (string_view::rfind: 511 ms -> 6 ms on 105 MB).
|
||||||
|
- Semantics change: the first match is the first one at or after the
|
||||||
|
cursor (it was the first in the buffer), and Left/Right also step
|
||||||
|
through matches in the regex replace prompt; Up/Down in a regex prompt
|
||||||
|
use the regex.
|
||||||
|
|
||||||
|
**Follow-up: journals for large buffers**
|
||||||
|
- Buffers over 16 MiB are checkpointed as CHKPT_BEGIN (size, CRC-32),
|
||||||
|
data chunks and CHKPT_END; replay applies the checkpoint only when it
|
||||||
|
is complete and its CRC matches, and treats an unfinished one as a
|
||||||
|
torn tail. Such buffers could not be checkpointed at all: a lost
|
||||||
|
record left the journal permanently behind and it was never
|
||||||
|
compacted. The journal header is now version 2 (version 1 journals
|
||||||
|
are still read; older kte builds will not read version 2).
|
||||||
|
- Checkpoint thresholds scale with the buffer: after edits amounting to
|
||||||
|
half its size, on the timer only after a sixteenth, compaction at
|
||||||
|
twice its size, gap retries spaced by size. A checkpoint that would
|
||||||
|
compact is written straight into the compacted journal.
|
||||||
|
- The recovery prompt keeps the content it replayed for the preview;
|
||||||
|
answering y installs it instead of replaying the journal again.
|
||||||
|
- The CRC of a base file over 64 MiB is computed by the journal writer
|
||||||
|
thread (it was skipped), so a touched but unchanged large file no
|
||||||
|
longer makes its journal unusable for recovery.
|
||||||
|
|
||||||
|
**Follow-up: regex engine**
|
||||||
|
- kte::Regex (RegexEngine.h) wraps PCRE2 with its JIT when libpcre2-8
|
||||||
|
is found (KTE_USE_PCRE2, default ON), std::regex otherwise. Search,
|
||||||
|
regex replace-all and the terminal and ImGui renderers use it (the Qt
|
||||||
|
frontend's highlighting still calls std::regex). PCRE2 has a match
|
||||||
|
limit, so catastrophic backtracking ("^(a+)+$") stops with a status
|
||||||
|
note instead of hanging, and does not recurse on the C stack, so its
|
||||||
|
per-keystroke line limit is 1 MiB instead of 2,000 bytes. Replacement
|
||||||
|
strings keep std::regex_replace semantics with either engine.
|
||||||
|
- On 26 MB: regex keystroke 1.6 -> 0.14 ms, next 62 -> 6 ms,
|
||||||
|
replace-all 929 -> 438 ms; a rarely matching regex over 105 MB:
|
||||||
|
1.55 s -> 0.10 s per keystroke.
|
||||||
|
- Only one checkpoint per journal is queued at a time; whole-file edits
|
||||||
|
in a row on a large buffer each queued a full copy of it.
|
||||||
|
- Review of this work found, and these are fixed: with PCRE2, '.'
|
||||||
|
matched the CR of CRLF lines (regex replace-all deleted it) — PCRE2
|
||||||
|
now uses the ANYCRLF newline convention and $ matches only at the end;
|
||||||
|
$0, out-of-range $n and $nn in replacements now follow libstdc++;
|
||||||
|
plain search visits overlapping matches in both directions (the count
|
||||||
|
could read "2/1"); the recovery prompt replays again if the journal
|
||||||
|
changed while it was open; a version 1 journal continued after
|
||||||
|
recovery gets a chunked checkpoint only by being rewritten as
|
||||||
|
version 2.
|
||||||
|
|
||||||
|
**Known limitations (not fixed)**
|
||||||
|
- Without PCRE2 (std::regex builds), catastrophic regex backtracking
|
||||||
|
can still take very long; std::regex has no limit.
|
||||||
|
- Without PCRE2 on libc++ (macOS), '^' also matches where a search
|
||||||
|
resumes inside a line (libc++ ignores match_prev_avail, and
|
||||||
|
match_not_bol with it, for '^'), and '\b' never matches at the end of
|
||||||
|
a line. Search_NextPrevVisitReferenceMatches fails in that
|
||||||
|
configuration; the default macOS build uses PCRE2.
|
||||||
|
- Typing in a multi-megabyte single line costs O(line length) per
|
||||||
|
keystroke (cursor column computed by scanning the line).
|
||||||
|
- Highlighter edge cases B13 are unchanged. (B7, B8 and B10 were fixed
|
||||||
|
in the debt pass below, once the GUIs could be built and run.)
|
||||||
|
- Journals are created at the first edit, so a second kte that opens
|
||||||
|
the file before the first one edits it is not warned at open; the
|
||||||
|
session that is locked out is told at its first edit instead.
|
||||||
|
- A regex that matches rarely or not at all scans the whole buffer
|
||||||
|
(0.1 s on 105 MB with PCRE2, 1.6 s with std::regex).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debt and performance pass (2026-09-24)
|
||||||
|
|
||||||
|
After the merge of the fixes above. Both GUI frontends (ImGui and Qt) were
|
||||||
|
built in this environment and checked by screenshot under Xvfb; the ImGui
|
||||||
|
renderer changes are pixel-identical to before on a sample with tabs,
|
||||||
|
UTF-8, syntax colouring and search highlights.
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- Typing near the top of a large file: each edit shifted every later line
|
||||||
|
start. The shift is now pending and applied on read; piece lookup
|
||||||
|
binary-searches prefix sums instead of walking the piece list. Typing on
|
||||||
|
line 10 of a 1M-line file, with a frame's worth of line reads per
|
||||||
|
keystroke: 0.52 -> 0.017 ms.
|
||||||
|
- A loaded file is adopted as the piece table's original storage: the
|
||||||
|
first keystroke no longer copies the whole file (140 ms at 200 MB), and
|
||||||
|
the read buffer is not copied again. Save writes the pieces instead of
|
||||||
|
materializing a second full copy that then stayed resident. An unedited
|
||||||
|
(single-piece) buffer is viewed in place; `GetLineView` no longer copies
|
||||||
|
the whole buffer after an edit when the line lies within one piece.
|
||||||
|
- Line reads decide "ends with a newline" from the line index instead of
|
||||||
|
fetching the last byte; the index is rebuilt with `memchr`.
|
||||||
|
- ImGui measured every span and the cursor from column 0 of the line
|
||||||
|
(quadratic on long lines); the terminal renderer rescanned spans per
|
||||||
|
byte; Qt compiled a `std::regex` per visible line per frame. All now
|
||||||
|
linear, with search-match ranges computed by one shared
|
||||||
|
`SearchHighlight`.
|
||||||
|
- The highlighter engine returns cached spans by reference and no longer
|
||||||
|
`dynamic_cast`s per call.
|
||||||
|
|
||||||
|
**Bugs found on the way**
|
||||||
|
- Qt: operators were drawn in the dark background colour (invisible);
|
||||||
|
tabs were 4 columns against the command layer's 8; text after a
|
||||||
|
multi-byte character was placed one column per byte; the editor's
|
||||||
|
column count was computed three different ways from painting and the
|
||||||
|
cell width was rounded, so on long lines the cursor drifted off the
|
||||||
|
text. Unhighlighted text between spans is now drawn.
|
||||||
|
- B7, B8 (above).
|
||||||
|
|
||||||
|
**Debt removed**
|
||||||
|
- `Buffer::Rows()` and the `Line` wrapper (tests use `GetLineString` or a
|
||||||
|
test-only `LinesForTests()`); `OptimizedSearch`, `UndoNodePool.h`, the
|
||||||
|
unused swap SPLIT/JOIN writers and several unused Editor/Buffer/PieceTable
|
||||||
|
members; mutexes that guarded only reads in single-threaded classes
|
||||||
|
(the claim that PieceTable reads were thread-safe was false).
|
||||||
|
- Consolidated: filetype detection and highlighter installation
|
||||||
|
(`Buffer::ApplyDetectedFiletype`, `InstallFiletypeHighlighter`), whole
|
||||||
|
buffer copies (`Buffer::Bytes`), character measurement
|
||||||
|
(`kte::MeasureChar`, `DisplayColumns`), span sanitizing
|
||||||
|
(`kte::SanitizeSpans`), word motion scans, the search reset, and the
|
||||||
|
regex engine's stack policy (`kte::Regex::RunGuarded`).
|
||||||
|
|
||||||
|
**Left as is**
|
||||||
|
- The C-k / ESC-meta / universal-argument key state machine is still
|
||||||
|
written once per frontend; unifying it is a behavioural change best done
|
||||||
|
with its own tests.
|
||||||
|
- The highlighter keeps one line state per row (about 40 bytes each) and
|
||||||
|
highlighters copy each line; both are modest next to the file itself.
|
||||||
|
- A long run of backspaces grows its undo record by prepending (quadratic
|
||||||
|
only in the length of one typed run).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-up: macOS build and Qt removal (2026-09-24)
|
||||||
|
|
||||||
|
The gate had only run on Linux (as root). On macOS it failed three
|
||||||
|
tests and the app bundle could not start.
|
||||||
|
|
||||||
|
- kge stopped at launch with "Failed loading SDL3 library": Homebrew's
|
||||||
|
sdl2 is sdl2-compat, which dlopen()s SDL3, and the bundle fixup copies
|
||||||
|
only linked libraries. SDL3 is now bundled when sdl2-compat is.
|
||||||
|
- Saving lost setuid/setgid when not running as root: writing the data
|
||||||
|
clears them, and the mode was applied before the write. Ownership and
|
||||||
|
mode now go on after the data; in-place writes restore the mode.
|
||||||
|
- Two tests used libc++'s std::regex as the reference, which misses
|
||||||
|
'\b' at the end of the subject and ignores match_prev_avail for '^'.
|
||||||
|
- After C-k, pressing Shift (for C-k C, C-k ^ or a shifted symbol) or
|
||||||
|
pressing Ctrl again (C-k C-d) ended the prefix as an unknown command
|
||||||
|
in kge; modifier keys alone are now ignored there (checked with
|
||||||
|
synthetic SDL key events against kge's objects).
|
||||||
|
- The Qt frontend was removed: unused, out of make-app-release since
|
||||||
|
March, not built by the gate, and its bundle could not start (no
|
||||||
|
platform plugin). Every renderer and input change had to be made in
|
||||||
|
it separately.
|
||||||
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
Based on the project structure and the presence of files like
|
|
||||||
`imgui.ini`, `GUIFrontend.h`, and `TerminalFrontend.h`, here is an
|
|
||||||
analysis of the difficulty and challenges involved in adding a GTK or Qt
|
|
||||||
version of the GUI.
|
|
||||||
|
|
||||||
### **Executive Summary: Difficulty Level - Moderate**
|
|
||||||
|
|
||||||
The project is well-architected for this task. It already supports
|
|
||||||
multiple frontends (Terminal vs. GUI), meaning the "Core Logic" (
|
|
||||||
Buffers, Syntax, Commands) is successfully decoupled from the "View" (
|
|
||||||
Rendering/Input). However, the specific move from an **Immediate Mode**
|
|
||||||
GUI (likely Dear ImGui, implied by `imgui.ini` and standard naming
|
|
||||||
patterns) to a **Retained Mode** GUI (Qt/GTK) introduces specific
|
|
||||||
architectural frictions regarding the event loop and state management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **1. Architectural Analysis**
|
|
||||||
|
|
||||||
The existence of abstract interfaces—likely `Frontend`, `Renderer`, and
|
|
||||||
`InputHandler`—is the biggest asset here.
|
|
||||||
|
|
||||||
* **Current State:**
|
|
||||||
* **Abstract Layer:** `Frontend.h`, `Renderer.h`, `InputHandler.h`
|
|
||||||
likely define the contract.
|
|
||||||
* **Implementations:**
|
|
||||||
* `Terminal*` files implement the TUI (likely ncurses or VT100).
|
|
||||||
* `GUI*` files (currently ImGui) implement the graphical
|
|
||||||
version.
|
|
||||||
* **The Path Forward:**
|
|
||||||
* You would create `QtFrontend`, `QtRenderer`, `QtInputHandler` (or
|
|
||||||
GTK equivalents).
|
|
||||||
* Because the core logic (`Editor.cc`, `Buffer.cc`) calls these
|
|
||||||
interfaces, you theoretically don't need to touch the core text
|
|
||||||
manipulation code.
|
|
||||||
|
|
||||||
### **2. Key Challenges**
|
|
||||||
|
|
||||||
#### **A. The Event Loop Inversion (Main Challenge)**
|
|
||||||
|
|
||||||
* **Current (ImGui):** Typically, the application owns the loop:
|
|
||||||
`while (running) { HandleInput(); Update(); Render(); }`. The
|
|
||||||
application explicitly tells the GUI to draw every frame.
|
|
||||||
* **Target (Qt/GTK):** The framework owns the loop: `app.exec()` or
|
|
||||||
`gtk_main()`. The framework calls *you* when events happen.
|
|
||||||
* **Difficulty:** You will need to refactor `main.cc` or the entry point
|
|
||||||
to hand over control to the Qt/GTK application object. The Editor's "
|
|
||||||
tick" function might need to be connected to a timer or an idle event
|
|
||||||
in the new framework to ensure logic updates happen.
|
|
||||||
|
|
||||||
#### **B. Rendering Paradigm: Canvas vs. Widgets**
|
|
||||||
|
|
||||||
* **The "Easy" Way (Custom Canvas):**
|
|
||||||
* Implement the `QtRenderer` by subclassing `QWidget` and overriding
|
|
||||||
`paintEvent`.
|
|
||||||
* Use `QPainter` (or Cairo in GTK) to draw text, cursors, and
|
|
||||||
selections exactly where the `Renderer` interface says to.
|
|
||||||
* **Pros:** Keeps the code similar to the current ImGui/Terminal
|
|
||||||
renderers.
|
|
||||||
* **Cons:** You lose native accessibility and some native "feel" (
|
|
||||||
scrolling physics, native text context menus).
|
|
||||||
* **The "Hard" Way (Native Widgets):**
|
|
||||||
* Trying to map an internal `Buffer` directly to a `QTextEdit` or
|
|
||||||
`GtkTextView`.
|
|
||||||
* **Difficulty:** This is usually very hard because the Editor core
|
|
||||||
likely manages its own cursor, selection, and syntax highlighting.
|
|
||||||
Syncing that internal state with a complex native widget often
|
|
||||||
leads to conflicts.
|
|
||||||
* **Recommendation:** Stick to the "Custom Canvas" approach (drawing
|
|
||||||
text manually on a surface) to preserve the custom editor
|
|
||||||
behavior (vim-like modes, specific syntax highlighting).
|
|
||||||
|
|
||||||
#### **C. Input Handling**
|
|
||||||
|
|
||||||
* **Challenge:** Mapping Qt/GTK key events to the internal `Keymap`.
|
|
||||||
* **Detail:** ImGui and Terminal libraries often provide raw scancodes
|
|
||||||
or simple chars. Qt/GTK provide complex Event objects. You will need a
|
|
||||||
translation layer in `QtInputHandler::keyPressEvent` that converts
|
|
||||||
`Qt::Key_Escape` -> `KKey::Escape` (or your internal equivalent).
|
|
||||||
|
|
||||||
### **3. Portability of Assets**
|
|
||||||
|
|
||||||
#### **Themes (Colors)**
|
|
||||||
|
|
||||||
* **Feasibility:** High.
|
|
||||||
* **Approach:** `GUITheme.h` likely contains structs with RGB/Hex
|
|
||||||
values. Qt supports stylesheets (QSS) and GTK uses CSS. You can write
|
|
||||||
a converter that reads your current theme configuration and generates
|
|
||||||
a CSS string to apply to your window, or simply use the RGB values
|
|
||||||
directly in your custom `QPainter`/Cairo drawing logic.
|
|
||||||
|
|
||||||
#### **Fonts**
|
|
||||||
|
|
||||||
* **Feasibility:** Moderate.
|
|
||||||
* **Approach:**
|
|
||||||
* **ImGui:** Usually loads a TTF into a texture atlas.
|
|
||||||
* **Qt/GTK:** Uses the system font engine (Freetype/Pango).
|
|
||||||
* **Challenge:** You won't use the texture atlas anymore. You will
|
|
||||||
simply request a font family and size (e.g.,
|
|
||||||
`QFont("JetBrains Mono", 12)`). You may need to ensure your custom
|
|
||||||
renderer calculates character width/height metrics correctly using
|
|
||||||
`QFontMetrics` (Qt) or `PangoLayout` (GTK) to align the grid
|
|
||||||
correctly.
|
|
||||||
|
|
||||||
### **4. Summary Recommendation**
|
|
||||||
|
|
||||||
If you proceed, **Qt** is generally considered easier to integrate with
|
|
||||||
C++ projects than GTK (which is C-based, though `gtkmm` exists).
|
|
||||||
|
|
||||||
1. **Create a `QtFrontend`** class inheriting from `Frontend`.
|
|
||||||
2. **Create a `QtWindow`** class inheriting from `QWidget`.
|
|
||||||
3. **Implement `QtRenderer`** that holds a pointer to the `QtWindow`.
|
|
||||||
When the core calls `DrawText()`, `QtRenderer` should queue that
|
|
||||||
command or draw directly to the widget's paint buffer.
|
|
||||||
4. **Refactor `main.cc`** to instantiate `QApplication` instead of the
|
|
||||||
current manual loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Note (2025-12): The Qt frontend defers all key processing to the
|
|
||||||
existing command subsystem and keymaps, mirroring the ImGui path. There
|
|
||||||
are no Qt-only keybindings; `QtInputHandler` translates Qt key events
|
|
||||||
into the shared keymap flow (C-k prefix, Ctrl chords, ESC/Meta,
|
|
||||||
universal-argument digits, printable insertion).
|
|
||||||
+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
|
||||||
{
|
{
|
||||||
|
|||||||
+3227
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>
|
||||||
@@ -23,12 +25,8 @@
|
|||||||
#include "ErrorHandler.h"
|
#include "ErrorHandler.h"
|
||||||
|
|
||||||
#if defined(KTE_BUILD_GUI)
|
#if defined(KTE_BUILD_GUI)
|
||||||
#if defined(KTE_USE_QT)
|
|
||||||
#include "QtFrontend.h"
|
|
||||||
#else
|
|
||||||
#include "ImGuiFrontend.h"
|
#include "ImGuiFrontend.h"
|
||||||
#endif
|
#endif
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
#ifndef KTE_VERSION_STR
|
#ifndef KTE_VERSION_STR
|
||||||
@@ -112,10 +110,33 @@ RunStressHighlighter(unsigned seconds)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// kte edits UTF-8 text. With LANG unset (common in containers and ssh
|
||||||
|
// sessions) the C locale cannot decode or encode non-ASCII characters, and
|
||||||
|
// typed or pasted non-ASCII text was dropped (with the rest of the paste).
|
||||||
|
// Use a UTF-8 character-type locale when the environment does not give one.
|
||||||
|
static void
|
||||||
|
ensure_utf8_ctype()
|
||||||
|
{
|
||||||
|
auto is_utf8 = [] {
|
||||||
|
std::string cs = nl_langinfo(CODESET);
|
||||||
|
for (auto &c: cs)
|
||||||
|
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
return cs == "utf-8" || cs == "utf8";
|
||||||
|
};
|
||||||
|
if (is_utf8())
|
||||||
|
return;
|
||||||
|
for (const char *name: {"C.UTF-8", "C.utf8", "en_US.UTF-8", "en_US.utf8"}) {
|
||||||
|
if (std::setlocale(LC_CTYPE, name) && is_utf8())
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
int
|
int
|
||||||
main(int argc, char *argv[])
|
main(int argc, char *argv[])
|
||||||
{
|
{
|
||||||
std::setlocale(LC_ALL, "");
|
std::setlocale(LC_ALL, "");
|
||||||
|
ensure_utf8_ctype();
|
||||||
|
|
||||||
// Ensure the error handler (and its log file) is initialised early.
|
// Ensure the error handler (and its log file) is initialised early.
|
||||||
kte::ErrorHandler::Instance();
|
kte::ErrorHandler::Instance();
|
||||||
@@ -255,7 +276,18 @@ main(int argc, char *argv[])
|
|||||||
// Fall through: not a +number, treat as filename starting with '+'
|
// Fall through: not a +number, treat as filename starting with '+'
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string path = arg;
|
// Resolve to absolute path now, before any
|
||||||
|
// chdir (macOS GUI changes CWD to HOME before
|
||||||
|
// deferred opens are processed).
|
||||||
|
std::string path = arg;
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(path);
|
||||||
|
if (p.is_relative()) {
|
||||||
|
path = std::filesystem::absolute(p).string();
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
// Fall through with original path
|
||||||
|
}
|
||||||
editor.RequestOpenFile(path, pending_line);
|
editor.RequestOpenFile(path, pending_line);
|
||||||
pending_line = 0; // consumed (if set)
|
pending_line = 0; // consumed (if set)
|
||||||
}
|
}
|
||||||
@@ -280,6 +312,20 @@ main(int argc, char *argv[])
|
|||||||
fe = std::make_unique<TerminalFrontend>();
|
fe = std::make_unique<TerminalFrontend>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guarantee Shutdown() runs on every exit path from here on (normal
|
||||||
|
// return, early return, or exception unwinding e.g. from Init/Execute/
|
||||||
|
// Step) so a crash never leaves the terminal in raw/cbreak mode.
|
||||||
|
struct FrontendShutdownGuard {
|
||||||
|
Frontend *fe;
|
||||||
|
|
||||||
|
|
||||||
|
~FrontendShutdownGuard()
|
||||||
|
{
|
||||||
|
if (fe)
|
||||||
|
fe->Shutdown();
|
||||||
|
}
|
||||||
|
} shutdown_guard{fe.get()};
|
||||||
|
|
||||||
#if defined(KTE_BUILD_GUI) && defined(__APPLE__)
|
#if defined(KTE_BUILD_GUI) && defined(__APPLE__)
|
||||||
if (use_gui) {
|
if (use_gui) {
|
||||||
/* likely using the .app, so need to cd */
|
/* likely using the .app, so need to cd */
|
||||||
@@ -308,8 +354,6 @@ main(int argc, char *argv[])
|
|||||||
fe->Step(editor, running);
|
fe->Step(editor, running);
|
||||||
}
|
}
|
||||||
|
|
||||||
fe->Shutdown();
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception &e) {
|
} catch (const std::exception &e) {
|
||||||
std::string msg = std::string("Unhandled exception: ") + e.what();
|
std::string msg = std::string("Unhandled exception: ") + e.what();
|
||||||
|
|||||||
@@ -14,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 )
|
||||||
@@ -163,11 +163,14 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
|||||||
if (c == 'R' && i + 1 < n && s[i + 1] == '"') {
|
if (c == 'R' && i + 1 < n && s[i + 1] == '"') {
|
||||||
int k = i + 2;
|
int k = i + 2;
|
||||||
std::string delim;
|
std::string delim;
|
||||||
while (k < n && s[k] != '(') {
|
// A raw-string delimiter is at most 16 characters (and has no
|
||||||
|
// spaces, backslashes or parentheses); scanning the rest of the
|
||||||
|
// line for '(' at every R" made long lines quadratic.
|
||||||
|
while (k < n && s[k] != '(' && delim.size() <= 16) {
|
||||||
delim.push_back(s[k]);
|
delim.push_back(s[k]);
|
||||||
++k;
|
++k;
|
||||||
}
|
}
|
||||||
if (k < n && s[k] == '(') {
|
if (k < n && s[k] == '(' && delim.size() <= 16) {
|
||||||
int body_start = k + 1;
|
int body_start = k + 1;
|
||||||
std::string needle = ")" + delim + "\"";
|
std::string needle = ")" + delim + "\"";
|
||||||
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
||||||
|
|||||||
+35
-2
@@ -46,14 +46,45 @@ GoHighlighter::GoHighlighter()
|
|||||||
void
|
void
|
||||||
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
|
StatefulHighlighter::LineState prev;
|
||||||
|
(void) HighlightLineStateful(buf, row, prev, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
StatefulHighlighter::LineState
|
||||||
|
GoHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||||
|
int row,
|
||||||
|
const LineState &prev,
|
||||||
|
std::vector<HighlightSpan> &out) const
|
||||||
|
{
|
||||||
|
StatefulHighlighter::LineState state = prev;
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
return;
|
return state;
|
||||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
int bol = 0;
|
int bol = 0;
|
||||||
while (bol < n && (s[bol] == ' ' || s[bol] == '\t'))
|
while (bol < n && (s[bol] == ' ' || s[bol] == '\t'))
|
||||||
++bol;
|
++bol;
|
||||||
|
|
||||||
|
// Continue a multi-line block comment from the previous line.
|
||||||
|
if (state.in_block_comment) {
|
||||||
|
int j = i;
|
||||||
|
while (i + 1 < n) {
|
||||||
|
if (s[i] == '*' && s[i + 1] == '/') {
|
||||||
|
i += 2;
|
||||||
|
push(out, j, i, TokenKind::Comment);
|
||||||
|
state.in_block_comment = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (state.in_block_comment) {
|
||||||
|
push(out, j, n, TokenKind::Comment);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// line comment
|
// line comment
|
||||||
while (i < n) {
|
while (i < n) {
|
||||||
char c = s[i];
|
char c = s[i];
|
||||||
@@ -82,7 +113,8 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
|||||||
}
|
}
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
push(out, i, n, TokenKind::Comment);
|
push(out, i, n, TokenKind::Comment);
|
||||||
break;
|
state.in_block_comment = true;
|
||||||
|
return state;
|
||||||
} else {
|
} else {
|
||||||
push(out, i, j, TokenKind::Comment);
|
push(out, i, j, TokenKind::Comment);
|
||||||
i = j;
|
i = j;
|
||||||
@@ -152,5 +184,6 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
|||||||
push(out, i, i + 1, TokenKind::Default);
|
push(out, i, i + 1, TokenKind::Default);
|
||||||
++i;
|
++i;
|
||||||
}
|
}
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
@@ -5,12 +5,17 @@
|
|||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
class GoHighlighter final : public LanguageHighlighter {
|
class GoHighlighter final : public StatefulHighlighter {
|
||||||
public:
|
public:
|
||||||
GoHighlighter();
|
GoHighlighter();
|
||||||
|
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||||
|
|
||||||
|
LineState HighlightLineStateful(const Buffer &buf,
|
||||||
|
int row,
|
||||||
|
const LineState &prev,
|
||||||
|
std::vector<HighlightSpan> &out) const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
std::unordered_set<std::string> types_;
|
std::unordered_set<std::string> types_;
|
||||||
|
|||||||
+76
-164
@@ -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;
|
||||||
// Copy shared_ptr-like raw pointer for use outside critical sections
|
const int start = std::min(known, row - 1);
|
||||||
LanguageHighlighter *hl_ptr = hl_.get();
|
StatefulHighlighter::LineState state = (start >= 0) ? states_[static_cast<std::size_t>(start)]
|
||||||
bool is_stateful = dynamic_cast<StatefulHighlighter *>(hl_ptr) != nullptr;
|
: StatefulHighlighter::LineState{};
|
||||||
|
const int nrows = static_cast<int>(buf.Nrows());
|
||||||
if (!is_stateful) {
|
for (int r = start + 1; r <= row && r < nrows; ++r) {
|
||||||
// Stateless fast path: we can release the lock while computing to reduce contention
|
LineHighlight lh;
|
||||||
lock.unlock();
|
lh.version = buf_version;
|
||||||
hl_ptr->HighlightLine(buf, row, result.spans);
|
state = stateful->HighlightLineStateful(buf, r, state, lh.spans);
|
||||||
// Update cache and return
|
if (static_cast<std::size_t>(r) == states_.size())
|
||||||
std::lock_guard<std::mutex> gl(mtx_);
|
states_.push_back(state);
|
||||||
cache_[row] = result;
|
else
|
||||||
return result;
|
states_[static_cast<std::size_t>(r)] = state;
|
||||||
}
|
if (r == row)
|
||||||
|
result = std::move(lh);
|
||||||
// Stateful path: we need to walk from a known previous state. Keep lock while consulting caches,
|
else if (row - r <= kCacheNear)
|
||||||
// but release during heavy computation.
|
cache_.insert_or_assign(r, std::move(lh));
|
||||||
auto *stateful = static_cast<StatefulHighlighter *>(hl_ptr);
|
// Rows further above only needed their end state (kept in
|
||||||
|
// states_): caching their spans too held about 10x the file size
|
||||||
StatefulHighlighter::LineState prev_state;
|
// in memory after jumping to the end of a large file.
|
||||||
int start_row = -1;
|
|
||||||
if (!state_cache_.empty()) {
|
|
||||||
// linear search over map (unordered), track best candidate
|
|
||||||
int best = -1;
|
|
||||||
for (const auto &kv: state_cache_) {
|
|
||||||
int r = kv.first;
|
|
||||||
// Only use cached state if it's for the current version and row still exists
|
|
||||||
if (r <= row - 1 && kv.second.version == buf_version) {
|
|
||||||
// Validate that the cached row index is still valid in the buffer
|
|
||||||
if (r >= 0 && static_cast<std::size_t>(r) < buf.Nrows()) {
|
|
||||||
if (r > best)
|
|
||||||
best = r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (best >= 0) {
|
|
||||||
start_row = best;
|
|
||||||
prev_state = state_cache_.at(best).state;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Trim first: the entry returned must survive until the next call.
|
||||||
|
trim_cache_locked(row);
|
||||||
|
return cache_.insert_or_assign(row, std::move(result)).first->second;
|
||||||
|
}
|
||||||
|
|
||||||
// We'll compute states and the target line's spans without holding the lock for most of the work.
|
|
||||||
// Create a local copy of prev_state and iterate rows; we will update caches under lock.
|
|
||||||
lock.unlock();
|
|
||||||
StatefulHighlighter::LineState cur_state = prev_state;
|
|
||||||
for (int r = start_row + 1; r <= row; ++r) {
|
|
||||||
std::vector<HighlightSpan> tmp;
|
|
||||||
std::vector<HighlightSpan> &out = (r == row) ? result.spans : tmp;
|
|
||||||
auto next_state = stateful->HighlightLineStateful(buf, r, cur_state, out);
|
|
||||||
// Update state cache for r
|
|
||||||
std::lock_guard<std::mutex> gl(mtx_);
|
|
||||||
StateEntry se;
|
|
||||||
se.version = buf_version;
|
|
||||||
se.state = next_state;
|
|
||||||
state_cache_[r] = se;
|
|
||||||
cur_state = next_state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store in cache and return by value
|
void
|
||||||
lock.lock();
|
HighlighterEngine::trim_cache_locked(const int row) const
|
||||||
cache_[row] = result;
|
{
|
||||||
return result;
|
if (cache_.size() <= kCacheMax)
|
||||||
|
return;
|
||||||
|
cache_.erase(cache_.begin(), cache_.lower_bound(row - kCacheNear));
|
||||||
|
cache_.erase(cache_.upper_bound(row + kCacheNear), cache_.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
HighlighterEngine::InvalidateFrom(int row)
|
HighlighterEngine::InvalidateFrom(int row)
|
||||||
{
|
{
|
||||||
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
|
||||||
|
|||||||
+11
-16
@@ -55,31 +55,26 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS
|
|||||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
||||||
if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) {
|
if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) {
|
||||||
auto pos = s.find(state.raw_delim);
|
auto pos = s.find(state.raw_delim);
|
||||||
if (pos == std::string::npos) {
|
if (pos == std::string::npos) {
|
||||||
push(out, 0, n, TokenKind::String);
|
push(out, 0, n, TokenKind::String);
|
||||||
return state; // still inside
|
return state; // still inside
|
||||||
} else {
|
|
||||||
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
|
|
||||||
push(out, 0, end, TokenKind::String);
|
|
||||||
// remainder processed normally
|
|
||||||
s = s.substr(end);
|
|
||||||
n = static_cast<int>(s.size());
|
|
||||||
state.in_raw_string = false;
|
|
||||||
state.raw_delim.clear();
|
|
||||||
// Continue parsing remainder as a separate small loop
|
|
||||||
int base = end;
|
|
||||||
// original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
|
|
||||||
// For simplicity, mark rest as Default
|
|
||||||
if (n > 0)
|
|
||||||
push(out, base, base + n, TokenKind::Default);
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
|
||||||
|
push(out, 0, end, TokenKind::String);
|
||||||
|
state.in_raw_string = false;
|
||||||
|
state.raw_delim.clear();
|
||||||
|
// Resume the normal tokenizer at the closing delimiter's end, on the
|
||||||
|
// same (unmodified) `s`/`n`, so anything after it - including a new
|
||||||
|
// triple-quoted string opening on this same line - is re-scanned
|
||||||
|
// instead of being dumped into a single opaque Default span.
|
||||||
|
i = end;
|
||||||
}
|
}
|
||||||
|
|
||||||
int i = 0;
|
|
||||||
// Detect comment start '#', ignoring inside strings
|
// Detect comment start '#', ignoring inside strings
|
||||||
while (i < n) {
|
while (i < n) {
|
||||||
char c = s[i];
|
char c = s[i];
|
||||||
|
|||||||
@@ -47,11 +47,42 @@ RustHighlighter::RustHighlighter()
|
|||||||
void
|
void
|
||||||
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
|
StatefulHighlighter::LineState prev;
|
||||||
|
(void) HighlightLineStateful(buf, row, prev, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
StatefulHighlighter::LineState
|
||||||
|
RustHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||||
|
int row,
|
||||||
|
const LineState &prev,
|
||||||
|
std::vector<HighlightSpan> &out) const
|
||||||
|
{
|
||||||
|
StatefulHighlighter::LineState state = prev;
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
return;
|
return state;
|
||||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
|
// Continue a multi-line block comment from the previous line.
|
||||||
|
if (state.in_block_comment) {
|
||||||
|
int j = i;
|
||||||
|
while (i + 1 < n) {
|
||||||
|
if (s[i] == '*' && s[i + 1] == '/') {
|
||||||
|
i += 2;
|
||||||
|
push(out, j, i, TokenKind::Comment);
|
||||||
|
state.in_block_comment = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (state.in_block_comment) {
|
||||||
|
push(out, j, n, TokenKind::Comment);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (i < n) {
|
while (i < n) {
|
||||||
char c = s[i];
|
char c = s[i];
|
||||||
if (c == ' ' || c == '\t') {
|
if (c == ' ' || c == '\t') {
|
||||||
@@ -79,7 +110,8 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
|
|||||||
}
|
}
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
push(out, i, n, TokenKind::Comment);
|
push(out, i, n, TokenKind::Comment);
|
||||||
break;
|
state.in_block_comment = true;
|
||||||
|
return state;
|
||||||
} else {
|
} else {
|
||||||
push(out, i, j, TokenKind::Comment);
|
push(out, i, j, TokenKind::Comment);
|
||||||
i = j;
|
i = j;
|
||||||
@@ -140,5 +172,6 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
|
|||||||
push(out, i, i + 1, TokenKind::Default);
|
push(out, i, i + 1, TokenKind::Default);
|
||||||
++i;
|
++i;
|
||||||
}
|
}
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
@@ -5,12 +5,17 @@
|
|||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
class RustHighlighter final : public LanguageHighlighter {
|
class RustHighlighter final : public StatefulHighlighter {
|
||||||
public:
|
public:
|
||||||
RustHighlighter();
|
RustHighlighter();
|
||||||
|
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||||
|
|
||||||
|
LineState HighlightLineStateful(const Buffer &buf,
|
||||||
|
int row,
|
||||||
|
const LineState &prev,
|
||||||
|
std::vector<HighlightSpan> &out) const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
std::unordered_set<std::string> types_;
|
std::unordered_set<std::string> types_;
|
||||||
|
|||||||
@@ -47,12 +47,42 @@ SqlHighlighter::SqlHighlighter()
|
|||||||
void
|
void
|
||||||
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
|
StatefulHighlighter::LineState prev;
|
||||||
|
(void) HighlightLineStateful(buf, row, prev, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
StatefulHighlighter::LineState
|
||||||
|
SqlHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||||
|
int row,
|
||||||
|
const LineState &prev,
|
||||||
|
std::vector<HighlightSpan> &out) const
|
||||||
|
{
|
||||||
|
StatefulHighlighter::LineState state = prev;
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
return;
|
return state;
|
||||||
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
|
// Continue a multi-line block comment from the previous line.
|
||||||
|
if (state.in_block_comment) {
|
||||||
|
int j = i;
|
||||||
|
while (i + 1 < n) {
|
||||||
|
if (s[i] == '*' && s[i + 1] == '/') {
|
||||||
|
i += 2;
|
||||||
|
push(out, j, i, TokenKind::Comment);
|
||||||
|
state.in_block_comment = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (state.in_block_comment) {
|
||||||
|
push(out, j, n, TokenKind::Comment);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (i < n) {
|
while (i < n) {
|
||||||
char c = s[i];
|
char c = s[i];
|
||||||
if (c == ' ' || c == '\t') {
|
if (c == ' ' || c == '\t') {
|
||||||
@@ -68,7 +98,7 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
|||||||
push(out, i, n, TokenKind::Comment);
|
push(out, i, n, TokenKind::Comment);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// simple block comment on same line: /* ... */
|
// block comment: /* ... */ (may span multiple lines)
|
||||||
if (c == '/' && i + 1 < n && s[i + 1] == '*') {
|
if (c == '/' && i + 1 < n && s[i + 1] == '*') {
|
||||||
int j = i + 2;
|
int j = i + 2;
|
||||||
bool closed = false;
|
bool closed = false;
|
||||||
@@ -82,7 +112,8 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
|||||||
}
|
}
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
push(out, i, n, TokenKind::Comment);
|
push(out, i, n, TokenKind::Comment);
|
||||||
break;
|
state.in_block_comment = true;
|
||||||
|
return state;
|
||||||
} else {
|
} else {
|
||||||
push(out, i, j, TokenKind::Comment);
|
push(out, i, j, TokenKind::Comment);
|
||||||
i = j;
|
i = j;
|
||||||
@@ -151,5 +182,6 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
|||||||
push(out, i, i + 1, TokenKind::Default);
|
push(out, i, i + 1, TokenKind::Default);
|
||||||
++i;
|
++i;
|
||||||
}
|
}
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
@@ -5,12 +5,17 @@
|
|||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
class SqlHighlighter final : public LanguageHighlighter {
|
class SqlHighlighter final : public StatefulHighlighter {
|
||||||
public:
|
public:
|
||||||
SqlHighlighter();
|
SqlHighlighter();
|
||||||
|
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||||
|
|
||||||
|
LineState HighlightLineStateful(const Buffer &buf,
|
||||||
|
int row,
|
||||||
|
const LineState &prev,
|
||||||
|
std::vector<HighlightSpan> &out) const override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
std::unordered_set<std::string> types_;
|
std::unordered_set<std::string> types_;
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ public:
|
|||||||
[[nodiscard]] std::string
|
[[nodiscard]] std::string
|
||||||
Text() const
|
Text() const
|
||||||
{
|
{
|
||||||
const auto &rows = Buf().Rows();
|
const auto &rows = Buf().LinesForTests();
|
||||||
std::string out;
|
std::string out;
|
||||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
out += static_cast<std::string>(rows[i]);
|
out += static_cast<std::string>(rows[i]);
|
||||||
|
|||||||
+16
-1
@@ -1,17 +1,32 @@
|
|||||||
#include "Test.h"
|
#include "Test.h"
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
// Usage: kte_tests [substring...] runs the tests whose names contain any of
|
||||||
|
// the substrings (all tests when none are given).
|
||||||
int
|
int
|
||||||
main()
|
main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
using namespace std::chrono;
|
using namespace std::chrono;
|
||||||
auto ® = ktet::registry();
|
auto ® = ktet::registry();
|
||||||
|
std::vector<std::string> filters(argv + 1, argv + argc);
|
||||||
|
auto selected = [&filters](const std::string &name) {
|
||||||
|
if (filters.empty())
|
||||||
|
return true;
|
||||||
|
for (const auto &f: filters)
|
||||||
|
if (name.find(f) != std::string::npos)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
std::cout << "kte unit tests: " << reg.size() << " test(s)\n";
|
std::cout << "kte unit tests: " << reg.size() << " test(s)\n";
|
||||||
int failed = 0;
|
int failed = 0;
|
||||||
auto t0 = steady_clock::now();
|
auto t0 = steady_clock::now();
|
||||||
for (const auto &tc: reg) {
|
for (const auto &tc: reg) {
|
||||||
|
if (!selected(tc.name))
|
||||||
|
continue;
|
||||||
auto ts = steady_clock::now();
|
auto ts = steady_clock::now();
|
||||||
try {
|
try {
|
||||||
tc.fn();
|
tc.fn();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
* Benchmarks cover:
|
* Benchmarks cover:
|
||||||
* - PieceTable operations (insert, delete, GetLine, GetLineRange)
|
* - PieceTable operations (insert, delete, GetLine, GetLineRange)
|
||||||
* - Buffer operations (Nrows, GetLineString, GetLineView)
|
* - Buffer operations (Nrows, GetLineString, GetLineView)
|
||||||
* - Iteration patterns (comparing old Rows() vs new GetLineString/GetLineView)
|
* - Iteration patterns (GetLineString vs GetLineView)
|
||||||
* - Syntax highlighting on large files
|
* - Syntax highlighting on large files
|
||||||
*
|
*
|
||||||
* Each benchmark reports execution time in milliseconds.
|
* Each benchmark reports execution time in milliseconds.
|
||||||
@@ -240,26 +240,6 @@ TEST (Benchmark_Buffer_GetLineView_Sequential)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
TEST (Benchmark_Buffer_Rows_Materialization)
|
|
||||||
{
|
|
||||||
std::cout << "\n=== Buffer Rows() Materialization Benchmark ===\n";
|
|
||||||
Buffer buf;
|
|
||||||
std::string data = generate_large_file(10000, 80);
|
|
||||||
buf.insert_text(0, 0, data);
|
|
||||||
|
|
||||||
std::size_t total_chars = 0;
|
|
||||||
{
|
|
||||||
BenchmarkTimer timer("Rows() materialization + iteration on 10K lines");
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
|
||||||
total_chars += rows[i].size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EXPECT_TRUE(total_chars > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
TEST (Benchmark_Buffer_Iteration_Comparison)
|
TEST (Benchmark_Buffer_Iteration_Comparison)
|
||||||
{
|
{
|
||||||
std::cout << "\n=== Buffer Iteration Pattern Comparison ===\n";
|
std::cout << "\n=== Buffer Iteration Pattern Comparison ===\n";
|
||||||
@@ -267,16 +247,8 @@ TEST (Benchmark_Buffer_Iteration_Comparison)
|
|||||||
std::string data = generate_large_file(5000, 80);
|
std::string data = generate_large_file(5000, 80);
|
||||||
buf.insert_text(0, 0, data);
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
std::size_t sum1 = 0, sum2 = 0, sum3 = 0;
|
std::size_t sum2 = 0, sum3 = 0;
|
||||||
|
std::size_t newlines = 0;
|
||||||
// Pattern 1: Old style with Rows()
|
|
||||||
{
|
|
||||||
BenchmarkTimer timer("Pattern 1: Rows() + iteration");
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
|
||||||
sum1 += rows[i].size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern 2: New style with GetLineString
|
// Pattern 2: New style with GetLineString
|
||||||
{
|
{
|
||||||
@@ -290,14 +262,15 @@ TEST (Benchmark_Buffer_Iteration_Comparison)
|
|||||||
{
|
{
|
||||||
BenchmarkTimer timer("Pattern 3: Nrows() + GetLineView (zero-copy)");
|
BenchmarkTimer timer("Pattern 3: Nrows() + GetLineView (zero-copy)");
|
||||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
sum3 += buf.GetLineView(i).size();
|
const auto v = buf.GetLineView(i);
|
||||||
|
sum3 += v.size();
|
||||||
|
newlines += !v.empty() && v.back() == '\n';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sum1 and sum2 should match (both strip newlines)
|
// GetLineView includes the newlines GetLineString strips.
|
||||||
ASSERT_EQ(sum1, sum2);
|
ASSERT_EQ(sum3, sum2 + newlines);
|
||||||
// sum3 includes newlines, so it will be larger
|
EXPECT_TRUE(newlines > 0);
|
||||||
EXPECT_TRUE(sum3 > sum2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ static void
|
|||||||
check_buffer_matches_model(const Buffer &b, const std::string &model)
|
check_buffer_matches_model(const Buffer &b, const std::string &model)
|
||||||
{
|
{
|
||||||
auto expected_lines = split_lines_preserve_trailing_empty(model);
|
auto expected_lines = split_lines_preserve_trailing_empty(model);
|
||||||
const auto &rows = b.Rows();
|
const auto &rows = b.LinesForTests();
|
||||||
ASSERT_EQ(rows.size(), expected_lines.size());
|
ASSERT_EQ(rows.size(), expected_lines.size());
|
||||||
ASSERT_EQ(b.Nrows(), rows.size());
|
ASSERT_EQ(b.Nrows(), rows.size());
|
||||||
|
|
||||||
|
|||||||
@@ -108,3 +108,78 @@ TEST(CommandSemantics_CopyRegion_And_KillRegion)
|
|||||||
ASSERT_EQ(b.MarkSet(), false);
|
ASSERT_EQ(b.MarkSet(), false);
|
||||||
ASSERT_EQ(h.Text(), std::string("hello "));
|
ASSERT_EQ(h.Text(), std::string("hello "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(CommandSemantics_Syntax_OnOff_SetsUserOverride)
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
Editor &ed = h.EditorRef();
|
||||||
|
Buffer &b = h.Buf();
|
||||||
|
|
||||||
|
// Before any explicit :syntax command, nothing has overridden the
|
||||||
|
// frontend's config-driven default.
|
||||||
|
ASSERT_EQ(b.SyntaxUserOverride(), false);
|
||||||
|
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "off"));
|
||||||
|
ASSERT_EQ(b.SyntaxEnabled(), false);
|
||||||
|
// This flag is what a frontend's per-frame "apply config default" pass
|
||||||
|
// must check before re-enabling syntax, or a manual :syntax off gets
|
||||||
|
// silently undone on the next frame.
|
||||||
|
ASSERT_EQ(b.SyntaxUserOverride(), true);
|
||||||
|
|
||||||
|
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "on"));
|
||||||
|
ASSERT_EQ(b.SyntaxEnabled(), true);
|
||||||
|
ASSERT_EQ(b.SyntaxUserOverride(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Word motion and word deletion with counts, across line ends and
|
||||||
|
// punctuation; deleting backwards N words kills them in text order.
|
||||||
|
TEST(CommandSemantics_WordMotionAndDelete_WithCounts)
|
||||||
|
{
|
||||||
|
const std::string text = "alpha beta, gamma\n\n delta.epsilon zeta\nend";
|
||||||
|
struct Pos {
|
||||||
|
std::size_t x, y;
|
||||||
|
};
|
||||||
|
auto run_motion = [&](CommandId id, Pos start, int count) {
|
||||||
|
TestHarness h;
|
||||||
|
h.Buf().insert_text(0, 0, text);
|
||||||
|
h.Buf().SetCursor(start.x, start.y);
|
||||||
|
(void) h.Exec(id, "", count);
|
||||||
|
return Pos{h.Buf().Curx(), h.Buf().Cury()};
|
||||||
|
};
|
||||||
|
Pos p = run_motion(CommandId::WordNext, {0, 0}, 1);
|
||||||
|
ASSERT_EQ(p.x, (std::size_t) 6);
|
||||||
|
p = run_motion(CommandId::WordNext, {0, 0}, 3);
|
||||||
|
ASSERT_EQ(p.y, (std::size_t) 2);
|
||||||
|
ASSERT_EQ(p.x, (std::size_t) 2);
|
||||||
|
p = run_motion(CommandId::WordNext, {0, 0}, 5);
|
||||||
|
ASSERT_EQ(p.y, (std::size_t) 2);
|
||||||
|
ASSERT_EQ(p.x, (std::size_t) 16);
|
||||||
|
p = run_motion(CommandId::WordPrev, {3, 3}, 1);
|
||||||
|
ASSERT_EQ(p.y, (std::size_t) 3);
|
||||||
|
ASSERT_EQ(p.x, (std::size_t) 0);
|
||||||
|
p = run_motion(CommandId::WordPrev, {3, 3}, 3);
|
||||||
|
ASSERT_EQ(p.y, (std::size_t) 2);
|
||||||
|
ASSERT_EQ(p.x, (std::size_t) 8);
|
||||||
|
p = run_motion(CommandId::WordPrev, {3, 3}, 50);
|
||||||
|
ASSERT_EQ(p.y, (std::size_t) 0);
|
||||||
|
ASSERT_EQ(p.x, (std::size_t) 0);
|
||||||
|
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
h.Buf().insert_text(0, 0, text);
|
||||||
|
h.Buf().SetCursor(3, 3);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::DeleteWordPrev, "", 3));
|
||||||
|
ASSERT_EQ(h.Text(), std::string("alpha beta, gamma\n\n delta."));
|
||||||
|
ASSERT_EQ(h.EditorRef().KillRingHead(), std::string("epsilon zeta\nend"));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
TestHarness h;
|
||||||
|
h.Buf().insert_text(0, 0, text);
|
||||||
|
h.Buf().SetCursor(6, 0);
|
||||||
|
ASSERT_TRUE(h.Exec(CommandId::DeleteWordNext, "", 2));
|
||||||
|
ASSERT_EQ(h.Text(), std::string("alpha delta.epsilon zeta\nend"));
|
||||||
|
ASSERT_EQ(h.EditorRef().KillRingHead(), std::string("beta, gamma\n\n "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ read_file_bytes(const std::string &path)
|
|||||||
static std::string
|
static std::string
|
||||||
buffer_bytes_via_views(const Buffer &b)
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
{
|
{
|
||||||
const auto &rows = b.Rows();
|
const auto &rows = b.LinesForTests();
|
||||||
std::string out;
|
std::string out;
|
||||||
for (std::size_t i = 0; i < rows.size(); i++) {
|
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||||
auto v = b.GetLineView(i);
|
auto v = b.GetLineView(i);
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ TEST(KKeymap_KPrefix_CanonicalChords)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TEST(KKeymap_KPrefix_UppercaseE_RejectedAndDistinctFromLowercaseE)
|
||||||
|
{
|
||||||
|
CommandId id{};
|
||||||
|
|
||||||
|
// 'e' is a real binding (OpenFileStart).
|
||||||
|
ASSERT_TRUE(KLookupKCommand('e', false, id));
|
||||||
|
ASSERT_EQ(id, CommandId::OpenFileStart);
|
||||||
|
|
||||||
|
// 'E' must be rejected, not silently aliased to 'e' via case-insensitive
|
||||||
|
// lookup (the switch normalizes to lowercase, so this has to be special-
|
||||||
|
// cased before it).
|
||||||
|
ASSERT_EQ(KLookupKCommand('E', false, id), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
TEST(KKeymap_CtrlChords_CanonicalChords)
|
TEST(KKeymap_CtrlChords_CanonicalChords)
|
||||||
{
|
{
|
||||||
CommandId id{};
|
CommandId id{};
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
* test_migration_coverage.cc - Edge case tests for Buffer::Line migration
|
* test_migration_coverage.cc - Edge case tests for Buffer::Line migration
|
||||||
*
|
*
|
||||||
* This file provides comprehensive test coverage for the migration from
|
* This file provides comprehensive test coverage for the migration from
|
||||||
* Buffer::Rows() to direct PieceTable operations using Nrows(), GetLineString(),
|
* the removed Buffer::Rows() to direct PieceTable operations using Nrows(), GetLineString(),
|
||||||
* and GetLineView().
|
* and GetLineView().
|
||||||
*
|
*
|
||||||
* Tests cover:
|
* Tests cover:
|
||||||
* - Edge cases: empty buffers, single lines, very long lines
|
* - Edge cases: empty buffers, single lines, very long lines
|
||||||
* - Boundary conditions: first line, last line, out-of-bounds
|
* - Boundary conditions: first line, last line, out-of-bounds
|
||||||
* - Consistency: GetLineString vs GetLineView vs Rows()
|
* - Consistency: GetLineString vs GetLineView vs LinesForTests()
|
||||||
* - Performance: large files, many small operations
|
* - Performance: large files, many small operations
|
||||||
* - Correctness: special characters, newlines, unicode
|
* - Correctness: special characters, newlines, unicode
|
||||||
*/
|
*/
|
||||||
@@ -117,7 +117,7 @@ TEST (Migration_ManyEmptyLines)
|
|||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Consistency Tests: GetLineString vs GetLineView vs Rows()
|
// Consistency Tests: GetLineString vs GetLineView vs LinesForTests()
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
TEST (Migration_Consistency_AllMethods)
|
TEST (Migration_Consistency_AllMethods)
|
||||||
@@ -125,13 +125,13 @@ TEST (Migration_Consistency_AllMethods)
|
|||||||
Buffer buf;
|
Buffer buf;
|
||||||
buf.insert_text(0, 0, std::string("abc\n123\nxyz"));
|
buf.insert_text(0, 0, std::string("abc\n123\nxyz"));
|
||||||
|
|
||||||
const auto &rows = buf.Rows();
|
const auto &rows = buf.LinesForTests();
|
||||||
ASSERT_EQ(buf.Nrows(), rows.size());
|
ASSERT_EQ(buf.Nrows(), rows.size());
|
||||||
|
|
||||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
std::string via_string = buf.GetLineString(i);
|
std::string via_string = buf.GetLineString(i);
|
||||||
std::string via_rows = std::string(rows[i]);
|
std::string via_rows = std::string(rows[i]);
|
||||||
// GetLineString and Rows() both strip newlines
|
// GetLineString and LinesForTests() both strip newlines
|
||||||
ASSERT_EQ(via_string, via_rows);
|
ASSERT_EQ(via_string, via_rows);
|
||||||
// GetLineView includes the raw range (with newlines if present)
|
// GetLineView includes the raw range (with newlines if present)
|
||||||
// Just verify it's accessible
|
// Just verify it's accessible
|
||||||
@@ -148,18 +148,18 @@ TEST (Migration_Consistency_AfterEdits)
|
|||||||
// Edit: insert in middle
|
// Edit: insert in middle
|
||||||
buf.insert_text(1, 2, std::string("XX"));
|
buf.insert_text(1, 2, std::string("XX"));
|
||||||
|
|
||||||
const auto &rows = buf.Rows();
|
const auto &rows = buf.LinesForTests();
|
||||||
ASSERT_EQ(buf.Nrows(), rows.size());
|
ASSERT_EQ(buf.Nrows(), rows.size());
|
||||||
|
|
||||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
// GetLineString and Rows() both strip newlines
|
// GetLineString and LinesForTests() both strip newlines
|
||||||
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit: delete line
|
// Edit: delete line
|
||||||
buf.delete_row(1);
|
buf.delete_row(1);
|
||||||
|
|
||||||
const auto &rows2 = buf.Rows();
|
const auto &rows2 = buf.LinesForTests();
|
||||||
ASSERT_EQ(buf.Nrows(), rows2.size());
|
ASSERT_EQ(buf.Nrows(), rows2.size());
|
||||||
|
|
||||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
@@ -332,7 +332,7 @@ TEST (Migration_Stress_ManySmallInserts)
|
|||||||
ASSERT_EQ(buf.GetLineString(1).size(), (std::size_t) 100);
|
ASSERT_EQ(buf.GetLineString(1).size(), (std::size_t) 100);
|
||||||
|
|
||||||
// Verify consistency
|
// Verify consistency
|
||||||
const auto &rows = buf.Rows();
|
const auto &rows = buf.LinesForTests();
|
||||||
ASSERT_EQ(buf.GetLineString(1), std::string(rows[1]));
|
ASSERT_EQ(buf.GetLineString(1), std::string(rows[1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,11 +367,11 @@ TEST (Migration_Stress_AlternatingInsertDelete)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify consistency after many operations
|
// Verify consistency after many operations
|
||||||
const auto &rows = buf.Rows();
|
const auto &rows = buf.LinesForTests();
|
||||||
ASSERT_EQ(buf.Nrows(), rows.size());
|
ASSERT_EQ(buf.Nrows(), rows.size());
|
||||||
|
|
||||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
// GetLineString and Rows() both strip newlines
|
// GetLineString and LinesForTests() both strip newlines
|
||||||
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,17 +414,21 @@ TEST (Migration_EmptyBufferCheck_Pattern)
|
|||||||
|
|
||||||
TEST (Migration_SyntaxHighlighter_Pattern)
|
TEST (Migration_SyntaxHighlighter_Pattern)
|
||||||
{
|
{
|
||||||
// Test the pattern used in syntax highlighters
|
// Test the pattern used in syntax highlighters: loop Nrows(), fetch each
|
||||||
|
// line via GetLineString(row). Assert the pattern actually returns the
|
||||||
|
// right content, not just that it doesn't crash - see syntax highlighter
|
||||||
|
// correctness tests (test_syntax_highlighting.cc) for tokenization coverage.
|
||||||
Buffer buf;
|
Buffer buf;
|
||||||
buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}"));
|
buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}"));
|
||||||
|
|
||||||
|
ASSERT_EQ(buf.Nrows(), static_cast<std::size_t>(3));
|
||||||
|
const std::vector<std::string> expected = {"int main() {", " return 0;", "}"};
|
||||||
for (std::size_t row = 0; row < buf.Nrows(); ++row) {
|
for (std::size_t row = 0; row < buf.Nrows(); ++row) {
|
||||||
// This is the pattern used in all migrated highlighters
|
|
||||||
if (row >= buf.Nrows()) {
|
if (row >= buf.Nrows()) {
|
||||||
break; // Should never happen
|
break; // Should never happen
|
||||||
}
|
}
|
||||||
std::string line = buf.GetLineString(row);
|
std::string line = buf.GetLineString(row);
|
||||||
// Successfully accessed line - size() is always valid for std::string
|
ASSERT_EQ(line, expected[row]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user