Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
387d9d6dea | ||
|
|
b1576c4228 | ||
|
|
501245da3d | ||
|
|
2125583407 | ||
|
|
082f4facd3 | ||
|
|
72286ebb8b | ||
|
|
c9e8b7dbf2 | ||
|
|
fe4e2abb3a | ||
|
|
f4032dcf25 | ||
|
|
3be85ef4ad | ||
|
|
e1ebf5900b | ||
|
|
8f9a99899b | ||
|
|
2da35f57d1 | ||
|
|
c5df5035fa | ||
|
|
d490245f30 | ||
|
|
7039c14507 | ||
|
|
95bcf8ef09 | ||
|
|
927accbca4 | ||
|
|
323e2377fe | ||
|
|
29d0ee8244 | ||
|
|
7dae9cc305 | ||
|
|
306a337516 | ||
|
|
6bc3a6e0af | ||
|
|
2496722b43 | ||
|
|
41e28a8ab7 | ||
|
|
e782d24e32 | ||
|
|
a33c1759e7 | ||
|
|
fc15a58a2b | ||
|
|
404b983009 | ||
|
|
7165bb286b | ||
|
|
bba7d34539 | ||
|
|
b9dffd8645 | ||
|
|
790fbc90e1 | ||
|
|
b9f91b0ed2 | ||
|
|
78d78f6f54 | ||
|
|
868ee3fe2f | ||
|
|
8cb688c574 | ||
|
|
853416f65a | ||
|
|
1e312fcd0b | ||
|
|
b1b0b3de57 | ||
|
|
ca3a6e675b | ||
|
|
93901928da | ||
|
|
91ceb9bb01 | ||
|
|
a0441e4c25 | ||
|
|
13f1b7281f | ||
|
|
4dbefcc41d | ||
|
|
58706489bb | ||
|
|
61a7338f88 | ||
|
|
1005f5dbe8 | ||
|
|
6c2727789e | ||
|
|
fb50a02f6a | ||
|
|
e279945136 | ||
|
|
cb9b6bdf45 | ||
|
|
7e16680a26 | ||
|
|
eaa605f548 | ||
|
|
ec8159ea2e | ||
|
|
061e836fa4 | ||
|
|
9f3da72765 | ||
|
|
812cde090f | ||
|
|
0075c7aa86 | ||
|
|
7b8ccbb8db | ||
|
|
f853fa60b1 | ||
|
|
4c42761fc0 | ||
|
|
2a1c49a0f0 | ||
|
|
f861e42c2c | ||
|
|
a69664037d | ||
|
|
aa53d75f96 | ||
|
|
8cac2e9af8 | ||
|
|
fc9f83966c | ||
|
|
66259db177 | ||
|
|
ae5193eb5f | ||
|
|
00098fee16 | ||
|
|
1bdc04ce1a | ||
|
|
f36aa9f456 | ||
|
|
52089d3564 | ||
|
|
0f2cc41ed1 | ||
|
|
64139b4868 | ||
|
|
d8d2ae8a31 | ||
|
|
18ce005ec0 | ||
|
|
22f7573361 | ||
|
|
96e82cc6dd | ||
|
|
480f1390c7 | ||
|
|
3126a5e523 | ||
|
|
b60a8dc491 | ||
|
|
056c9af38e | ||
|
|
6413e14455 | ||
|
|
d1a45581bf | ||
|
|
81a5c25071 | ||
|
|
5667a6d7bd | ||
|
|
99c4bb2066 | ||
|
|
953fee97d7 | ||
|
|
d7e35727f1 | ||
|
|
23f04e4357 | ||
|
|
0585edad9e | ||
|
|
8712ea673d | ||
|
|
3148e16cf8 | ||
|
|
34eaa72033 | ||
|
|
f49f1698f4 | ||
|
|
f4b3188069 | ||
|
|
2571ab79c1 | ||
|
|
d768e56727 | ||
|
|
11c523ad52 | ||
|
|
c261261e26 | ||
|
|
27dcb41857 | ||
|
|
bc3433e988 | ||
|
|
690c51b0f3 | ||
|
|
0d87bc0b25 | ||
|
|
daeeecb342 | ||
|
|
a428b204a0 | ||
|
|
a21409e689 | ||
|
|
b0b5b55dce | ||
|
|
422b27b1ba | ||
|
|
9485d2aa24 | ||
|
|
8a6b7851d5 | ||
|
|
8ec0d6ac41 | ||
|
|
337b585ba0 | ||
|
|
95a588b0df | ||
|
|
199d7a20f7 | ||
|
|
44827fe53f | ||
|
|
2a6ff2a862 | ||
|
|
895e4ccb1e | ||
|
|
15b350bfaa | ||
|
|
cc8df36bdf | ||
|
|
1c0f04f076 | ||
|
|
ac0eadc345 | ||
|
|
f3bdced3d4 | ||
|
|
2551388420 | ||
|
|
d2d155f211 | ||
|
|
8634eb78f0 | ||
|
|
6eb240a0c4 | ||
|
|
4c402f5ef3 | ||
|
|
a8abda4b87 | ||
|
|
7347556aa2 | ||
|
|
289e155c98 | ||
|
|
147a52f3d4 | ||
|
|
dda7541e2f | ||
|
|
2408f5494c | ||
|
|
2542690eca | ||
|
|
cc0c187481 | ||
|
|
a8dcfbec58 | ||
|
|
65705e3354 | ||
|
|
e1f9a9eb6a | ||
|
|
c9f34003f2 | ||
|
|
f450ef825c | ||
|
|
f6f0c11be4 | ||
|
|
657c9bbc19 | ||
|
|
3493695165 | ||
|
|
5f57cf23dc | ||
|
|
9312550be4 | ||
|
|
f734f98891 | ||
|
|
1191e14ce9 | ||
|
|
12cc04d7e0 | ||
|
|
3f4c60d311 | ||
|
|
71c1c9e50b | ||
|
|
afb6888c31 | ||
|
|
222f73252b | ||
|
|
51ea473a91 | ||
|
|
fd517b5d57 | ||
|
|
952e1ed3f2 | ||
|
|
7069943df5 | ||
|
|
ee2c9939d7 | ||
|
|
f5a4625652 | ||
|
|
37472c71ec | ||
|
|
5ff4b2ed3e | ||
|
|
ab2f9918f3 | ||
|
|
d2b53601e2 | ||
|
|
78b9345799 | ||
|
|
495183ebd2 | ||
|
|
998b1b9817 | ||
|
|
dc2cf4c0a6 | ||
|
|
f6c4a5ab34 |
Generated
+1
@@ -1,5 +1,6 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="sccl" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
+2
-1
@@ -19,7 +19,7 @@
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
@@ -58,6 +58,7 @@
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefinitionsOrder/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module classpath="CMake" type="CPP_MODULE" version="4">
|
||||
<module classpath="CIDR" type="CPP_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="Python" name="Python facet">
|
||||
<configuration sdkName="" />
|
||||
|
||||
+2
-48
@@ -1,48 +1,2 @@
|
||||
# Project Guidelines
|
||||
|
||||
kte is Kyle's Text Editor — a simple, fast text editor written in C++17. 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`).
|
||||
- GapBuffer: editable in-memory text representation (`GapBuffer.h/.cpp`).
|
||||
- PieceTable: experimental/alternative 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++17.
|
||||
- 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)`.
|
||||
|
||||
Read AGENTS.md at the repository root before anything else. It is the only
|
||||
project guide; this file exists because Junie cannot import it.
|
||||
|
||||
@@ -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).
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* AppendBuffer.h - selector header to choose GapBuffer or PieceTable
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#ifdef KTE_USE_PIECE_TABLE
|
||||
#include "PieceTable.h"
|
||||
using AppendBuffer = PieceTable;
|
||||
#else
|
||||
#include "GapBuffer.h"
|
||||
using AppendBuffer = GapBuffer;
|
||||
#endif
|
||||
@@ -1,21 +1,75 @@
|
||||
/*
|
||||
* Buffer.h - editor buffer representing an open document
|
||||
*
|
||||
* Buffer is the central document model in kte. Each Buffer represents one open file
|
||||
* or scratch document and manages:
|
||||
*
|
||||
* - Content storage: Uses PieceTable for efficient text operations
|
||||
* - Cursor state: Current position (curx_, cury_)
|
||||
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
|
||||
* - File backing: Optional association with a file on disk
|
||||
* - Undo/Redo: Integrated UndoSystem for operation history
|
||||
* - Syntax highlighting: Optional HighlighterEngine for language-aware coloring
|
||||
* - Swap/crash recovery: Integration with SwapRecorder for journaling
|
||||
* - Dirty tracking: Modification state for save prompts
|
||||
*
|
||||
* Key concepts:
|
||||
*
|
||||
* 1. Cursor coordinates:
|
||||
* - (curx_, cury_): Logical character position in the document
|
||||
*
|
||||
* 2. File backing:
|
||||
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
|
||||
* - File identity tracking detects external modifications
|
||||
*
|
||||
* 3. Content access:
|
||||
* - Nrows(), GetLineString(): per-line access
|
||||
* - GetLineView(): line access via string_view (zero-copy within a piece)
|
||||
* - Direct PieceTable access for new editing operations
|
||||
*/
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
#include <string_view>
|
||||
|
||||
#include "AppendBuffer.h"
|
||||
#include "PieceTable.h"
|
||||
#include "UndoSystem.h"
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include "syntax/HighlighterEngine.h"
|
||||
#include "Highlight.h"
|
||||
|
||||
// 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
|
||||
namespace kte {
|
||||
class SwapRecorder;
|
||||
}
|
||||
|
||||
|
||||
class Buffer {
|
||||
public:
|
||||
@@ -37,6 +91,14 @@ public:
|
||||
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
||||
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
|
||||
|
||||
// External modification detection.
|
||||
// Returns true if the file on disk differs from the last observed identity recorded
|
||||
// on open/save.
|
||||
[[nodiscard]] bool ExternallyModifiedOnDisk() const;
|
||||
|
||||
// Refresh the stored on-disk identity to match current stat (used after open/save).
|
||||
void RefreshOnDiskIdentity();
|
||||
|
||||
// Accessors
|
||||
[[nodiscard]] std::size_t Curx() const
|
||||
{
|
||||
@@ -50,15 +112,9 @@ public:
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t Rx() const
|
||||
{
|
||||
return rx_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t Nrows() const
|
||||
{
|
||||
return nrows_;
|
||||
return content_LineCount_();
|
||||
}
|
||||
|
||||
|
||||
@@ -74,192 +130,39 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// Line wrapper backed by AppendBuffer (GapBuffer/PieceTable)
|
||||
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()
|
||||
{
|
||||
buf_.Clear();
|
||||
}
|
||||
|
||||
|
||||
// size/access
|
||||
[[nodiscard]] std::size_t size() const
|
||||
{
|
||||
return buf_.Size();
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool empty() const
|
||||
{
|
||||
return size() == 0;
|
||||
}
|
||||
|
||||
|
||||
// read-only raw view
|
||||
[[nodiscard]] const char *Data() const
|
||||
{
|
||||
return buf_.Data();
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t Size() const
|
||||
{
|
||||
return buf_.Size();
|
||||
}
|
||||
|
||||
|
||||
// element access (read-only)
|
||||
[[nodiscard]] char operator[](std::size_t i) const
|
||||
{
|
||||
const char *d = buf_.Data();
|
||||
return (i < buf_.Size() && d) ? d[i] : '\0';
|
||||
}
|
||||
|
||||
|
||||
// conversions
|
||||
explicit operator std::string() const
|
||||
{
|
||||
return {buf_.Data() ? buf_.Data() : "", buf_.Size()};
|
||||
}
|
||||
|
||||
|
||||
// string-like API used by command/renderer layers (implemented via materialization for now)
|
||||
[[nodiscard]] std::string substr(std::size_t pos) const
|
||||
{
|
||||
const std::size_t n = buf_.Size();
|
||||
if (pos >= n)
|
||||
return {};
|
||||
return {buf_.Data() + pos, n - pos};
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::string substr(std::size_t pos, std::size_t len) const
|
||||
{
|
||||
const std::size_t n = buf_.Size();
|
||||
if (pos >= n)
|
||||
return {};
|
||||
const std::size_t take = (pos + len > n) ? (n - pos) : len;
|
||||
return {buf_.Data() + pos, take};
|
||||
}
|
||||
|
||||
|
||||
// minimal find() to support search within a line
|
||||
[[nodiscard]] std::size_t find(const std::string &needle, const std::size_t pos = 0) const
|
||||
{
|
||||
// Materialize to std::string for now; Line is backed by AppendBuffer
|
||||
const auto s = static_cast<std::string>(*this);
|
||||
return s.find(needle, pos);
|
||||
}
|
||||
|
||||
|
||||
void erase(std::size_t pos)
|
||||
{
|
||||
// erase to end
|
||||
material_edit([&](std::string &s) {
|
||||
if (pos < s.size())
|
||||
s.erase(pos);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void erase(std::size_t pos, std::size_t len)
|
||||
{
|
||||
material_edit([&](std::string &s) {
|
||||
if (pos < s.size())
|
||||
s.erase(pos, len);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void insert(std::size_t pos, const std::string &seg)
|
||||
{
|
||||
material_edit([&](std::string &s) {
|
||||
if (pos > s.size())
|
||||
pos = s.size();
|
||||
s.insert(pos, seg);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Line &operator+=(const Line &other)
|
||||
{
|
||||
buf_.Append(other.buf_.Data(), other.buf_.Size());
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
Line &operator+=(const std::string &s)
|
||||
{
|
||||
buf_.Append(s.data(), s.size());
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
Line &operator=(const std::string &s)
|
||||
{
|
||||
assign_from(s);
|
||||
return *this;
|
||||
}
|
||||
|
||||
private:
|
||||
void assign_from(const std::string &s)
|
||||
{
|
||||
buf_.Clear();
|
||||
if (!s.empty())
|
||||
buf_.Append(s.data(), s.size());
|
||||
}
|
||||
|
||||
|
||||
template<typename F>
|
||||
void material_edit(F fn)
|
||||
{
|
||||
std::string tmp = static_cast<std::string>(*this);
|
||||
fn(tmp);
|
||||
assign_from(tmp);
|
||||
}
|
||||
|
||||
|
||||
AppendBuffer buf_;
|
||||
};
|
||||
|
||||
|
||||
[[nodiscard]] const std::vector<Line> &Rows() const
|
||||
// A line's text without its trailing newline.
|
||||
[[nodiscard]] std::string GetLineString(std::size_t row) const
|
||||
{
|
||||
return rows_;
|
||||
return content_.GetLine(row);
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::vector<Line> &Rows()
|
||||
[[nodiscard]] std::pair<std::size_t, std::size_t> GetLineRange(std::size_t row) const
|
||||
{
|
||||
return rows_;
|
||||
return content_.GetLineRange(row);
|
||||
}
|
||||
|
||||
|
||||
// View of a line's raw bytes, including its trailing '\n' if any. No copy
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
{
|
||||
return filename_;
|
||||
@@ -272,6 +175,7 @@ public:
|
||||
{
|
||||
filename_ = name;
|
||||
is_file_backed_ = false;
|
||||
is_virtual_ = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -281,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
|
||||
{
|
||||
return dirty_;
|
||||
@@ -313,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)
|
||||
{
|
||||
rowoffs_ = row;
|
||||
@@ -328,13 +243,17 @@ public:
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -371,8 +290,89 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// Visual-line selection support (multicursor/visual mode)
|
||||
void VisualLineClear()
|
||||
{
|
||||
visual_line_active_ = false;
|
||||
}
|
||||
|
||||
|
||||
void VisualLineStart()
|
||||
{
|
||||
visual_line_active_ = true;
|
||||
visual_line_anchor_y_ = cury_;
|
||||
visual_line_active_y_ = cury_;
|
||||
}
|
||||
|
||||
|
||||
void VisualLineToggle()
|
||||
{
|
||||
if (visual_line_active_)
|
||||
VisualLineClear();
|
||||
else
|
||||
VisualLineStart();
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool VisualLineActive() const
|
||||
{
|
||||
return visual_line_active_;
|
||||
}
|
||||
|
||||
|
||||
void VisualLineSetActiveY(std::size_t y)
|
||||
{
|
||||
visual_line_active_y_ = y;
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
{
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
// In visual-line (multi-cursor) mode, the UI should highlight only the per-line
|
||||
// cursor "spot" (Curx clamped to each line length), not the entire line.
|
||||
[[nodiscard]] bool VisualLineSpotSelected(std::size_t y, std::size_t sx) const
|
||||
{
|
||||
if (!visual_line_active_)
|
||||
return false;
|
||||
if (y < VisualLineStartY() || y > VisualLineEndY())
|
||||
return false;
|
||||
std::string_view ln = GetLineView(y);
|
||||
// `GetLineView()` returns the raw range, which may include a trailing '\n'.
|
||||
if (!ln.empty() && ln.back() == '\n')
|
||||
ln.remove_suffix(1);
|
||||
const std::size_t spot = std::min(Curx(), ln.size());
|
||||
return sx == spot;
|
||||
}
|
||||
|
||||
|
||||
[[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)
|
||||
[[nodiscard]] std::uint64_t Version() const
|
||||
{
|
||||
@@ -380,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)
|
||||
{
|
||||
syntax_enabled_ = on;
|
||||
@@ -392,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)
|
||||
{
|
||||
filetype_ = ft;
|
||||
@@ -404,13 +452,13 @@ public:
|
||||
}
|
||||
|
||||
|
||||
kte::HighlighterEngine *Highlighter()
|
||||
[[nodiscard]] kte::HighlighterEngine *Highlighter()
|
||||
{
|
||||
return highlighter_.get();
|
||||
}
|
||||
|
||||
|
||||
const kte::HighlighterEngine *Highlighter() const
|
||||
[[nodiscard]] const kte::HighlighterEngine *Highlighter() const
|
||||
{
|
||||
return highlighter_.get();
|
||||
}
|
||||
@@ -423,6 +471,28 @@ 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)
|
||||
void SetSwapRecorder(kte::SwapRecorder *rec)
|
||||
{
|
||||
swap_rec_ = rec;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
|
||||
{
|
||||
return swap_rec_;
|
||||
}
|
||||
|
||||
|
||||
// Raw, low-level editing APIs used by UndoSystem apply().
|
||||
// These must NOT trigger undo recording. They also do not move the cursor.
|
||||
void insert_text(int row, int col, std::string_view text);
|
||||
@@ -437,32 +507,134 @@ public:
|
||||
|
||||
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.
|
||||
// Intended for crash recovery (swap replay) and test harnesses.
|
||||
// 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);
|
||||
|
||||
// ===== 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)
|
||||
UndoSystem *Undo();
|
||||
[[nodiscard]] UndoSystem *Undo();
|
||||
|
||||
[[nodiscard]] const UndoSystem *Undo() const;
|
||||
|
||||
#if defined(KTE_TESTS)
|
||||
// Test-only: return the raw buffer bytes (including newlines) as a string.
|
||||
|
||||
// Every line without its newline (tests only).
|
||||
[[nodiscard]] std::vector<std::string> LinesForTests() const;
|
||||
|
||||
[[nodiscard]] std::string BytesForTests() const;
|
||||
#endif
|
||||
|
||||
private:
|
||||
struct FileIdentity {
|
||||
bool valid = false;
|
||||
std::uint64_t mtime_ns = 0;
|
||||
std::uint64_t size = 0;
|
||||
std::uint64_t dev = 0;
|
||||
std::uint64_t ino = 0;
|
||||
};
|
||||
|
||||
[[nodiscard]] static bool stat_identity(const std::string &path, FileIdentity &out);
|
||||
|
||||
[[nodiscard]] bool current_disk_identity(FileIdentity &out) const;
|
||||
|
||||
mutable FileIdentity on_disk_identity_{};
|
||||
|
||||
// State mirroring original C struct (without undo_tree)
|
||||
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::vector<Line> rows_; // buffer rows (without trailing newlines)
|
||||
// PieceTable is the source of truth.
|
||||
PieceTable content_{};
|
||||
|
||||
// Helper to query content_.LineCount() while keeping header minimal
|
||||
std::size_t content_LineCount_() const;
|
||||
|
||||
std::string filename_;
|
||||
bool is_file_backed_ = false;
|
||||
bool dirty_ = false;
|
||||
bool read_only_ = false;
|
||||
bool mark_set_ = false;
|
||||
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
||||
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 read_only_ = false;
|
||||
bool mark_set_ = false;
|
||||
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
||||
bool visual_line_active_ = false;
|
||||
std::size_t visual_line_anchor_y_ = 0;
|
||||
std::size_t visual_line_active_y_ = 0;
|
||||
|
||||
// Per-buffer undo state
|
||||
std::unique_ptr<struct UndoTree> undo_tree_;
|
||||
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
|
||||
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_user_override_ = false; // true once user explicitly set syntax state via a command
|
||||
std::string filetype_;
|
||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
||||
kte::SwapRecorder *swap_rec_ = nullptr;
|
||||
};
|
||||
+236
-57
@@ -3,24 +3,27 @@ project(kte)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(KTE_VERSION "1.3.4")
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(KTE_VERSION "1.13.0")
|
||||
|
||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
||||
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.")
|
||||
option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" ON)
|
||||
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
||||
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" 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)
|
||||
option(ENABLE_ASAN "Enable AddressSanitizer for builds" ON)
|
||||
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
|
||||
|
||||
if (ENABLE_ASAN)
|
||||
message(STATUS "ASan enabled")
|
||||
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)
|
||||
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
|
||||
endif ()
|
||||
@@ -32,14 +35,7 @@ else ()
|
||||
endif ()
|
||||
|
||||
add_compile_options(
|
||||
"-static"
|
||||
"-Wall"
|
||||
"-Wextra"
|
||||
"-Werror"
|
||||
"-Wno-unused-function"
|
||||
"-Wno-unused-parameter"
|
||||
"-g"
|
||||
"$<$<CONFIG:RELEASE>:-O2>"
|
||||
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
@@ -49,10 +45,15 @@ else ()
|
||||
"-Wall"
|
||||
"-Wextra"
|
||||
"-Werror"
|
||||
"-pedantic"
|
||||
"-Wno-unused-function"
|
||||
"-Wno-unused-parameter"
|
||||
"$<$<CONFIG:RELEASE>:-O2>"
|
||||
"$<$<CONFIG:DEBUG>:-g>"
|
||||
"$<$<CONFIG:RELEASE>:-O2>")
|
||||
)
|
||||
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
||||
add_compile_options("-stdlib=libc++")
|
||||
add_link_options("-stdlib=libc++")
|
||||
else ()
|
||||
# nothing special for gcc at the moment
|
||||
endif ()
|
||||
@@ -65,15 +66,69 @@ endif ()
|
||||
|
||||
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
||||
|
||||
if (${BUILD_GUI})
|
||||
# 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)
|
||||
include(cmake/imgui.cmake)
|
||||
endif ()
|
||||
|
||||
# NCurses for terminal mode
|
||||
set(CURSES_NEED_NCURSES)
|
||||
set(CURSES_NEED_WIDE)
|
||||
set(CURSES_NEED_NCURSES 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)
|
||||
include_directories(${CURSES_INCLUDE_DIR})
|
||||
|
||||
# FindCurses exports CURSES_INCLUDE_DIRS; the singular CURSES_INCLUDE_DIR was
|
||||
# never set, so this silently expanded to nothing and the compiler fell back
|
||||
# to whatever curses header happened to be on the default include path.
|
||||
include_directories(${CURSES_INCLUDE_DIRS})
|
||||
|
||||
# The wide-char entry points we rely on (get_wch, etc.) are declared only when
|
||||
# NCURSES_WIDECHAR is set; the header defaults it to 0 unless a feature-test
|
||||
# macro says otherwise. We always link the wide library, so ask for them.
|
||||
add_compile_definitions(NCURSES_WIDECHAR=1)
|
||||
|
||||
# On Alpine Linux, CMake's FindCurses looks in wrong paths
|
||||
# Manually find the correct ncurses library
|
||||
if (EXISTS "/etc/alpine-release")
|
||||
find_library(NCURSESW_LIB NAMES ncursesw PATHS /usr/lib /lib REQUIRED)
|
||||
set(CURSES_LIBRARIES ${NCURSESW_LIB})
|
||||
message(STATUS "Alpine Linux detected, using ncurses at: ${NCURSESW_LIB}")
|
||||
endif ()
|
||||
|
||||
set(SYNTAX_SOURCES
|
||||
syntax/GoHighlighter.cc
|
||||
@@ -103,23 +158,33 @@ set(FONT_SOURCES
|
||||
fonts/FontRegistry.cc
|
||||
)
|
||||
|
||||
set(GUI_SOURCES
|
||||
${FONT_SOURCES}
|
||||
GUIConfig.cc
|
||||
GUIRenderer.cc
|
||||
GUIInputHandler.cc
|
||||
GUIFrontend.cc
|
||||
)
|
||||
|
||||
if (BUILD_GUI)
|
||||
set(GUI_SOURCES
|
||||
GUIConfig.cc
|
||||
)
|
||||
set(GUI_SOURCES
|
||||
${GUI_SOURCES}
|
||||
${FONT_SOURCES}
|
||||
ImGuiFrontend.cc
|
||||
ImGuiInputHandler.cc
|
||||
ImGuiRenderer.cc
|
||||
)
|
||||
endif ()
|
||||
|
||||
set(COMMON_SOURCES
|
||||
GapBuffer.cc
|
||||
PieceTable.cc
|
||||
Buffer.cc
|
||||
Editor.cc
|
||||
Command.cc
|
||||
HelpText.cc
|
||||
KKeymap.cc
|
||||
Swap.cc
|
||||
ErrorHandler.cc
|
||||
SyscallWrappers.cc
|
||||
ErrorRecovery.cc
|
||||
RegexGuard.cc
|
||||
RegexEngine.cc
|
||||
SearchHighlight.cc
|
||||
TerminalInputHandler.cc
|
||||
TerminalRenderer.cc
|
||||
TerminalFrontend.cc
|
||||
@@ -180,6 +245,8 @@ set(FONT_HEADERS
|
||||
fonts/FontList.h
|
||||
fonts/B612Mono.h
|
||||
fonts/BrassMono.h
|
||||
fonts/CrimsonPro.h
|
||||
fonts/ETBook.h
|
||||
fonts/BrassMonoCode.h
|
||||
fonts/FiraCode.h
|
||||
fonts/Go.h
|
||||
@@ -191,20 +258,21 @@ set(FONT_HEADERS
|
||||
fonts/IosevkaExtended.h
|
||||
fonts/ShareTech.h
|
||||
fonts/SpaceMono.h
|
||||
fonts/Spectral.h
|
||||
fonts/Syne.h
|
||||
fonts/Triplicate.h
|
||||
fonts/Unispace.h
|
||||
fonts/BerkeleyMono.h
|
||||
)
|
||||
|
||||
set(COMMON_HEADERS
|
||||
GapBuffer.h
|
||||
PieceTable.h
|
||||
Buffer.h
|
||||
Editor.h
|
||||
AppendBuffer.h
|
||||
Command.h
|
||||
HelpText.h
|
||||
KKeymap.h
|
||||
Swap.h
|
||||
InputHandler.h
|
||||
TerminalInputHandler.h
|
||||
Renderer.h
|
||||
@@ -214,6 +282,9 @@ set(COMMON_HEADERS
|
||||
TestInputHandler.h
|
||||
TestRenderer.h
|
||||
TestFrontend.h
|
||||
TextSpan.h
|
||||
RegexEngine.h
|
||||
SearchHighlight.h
|
||||
UndoNode.h
|
||||
UndoTree.h
|
||||
UndoSystem.h
|
||||
@@ -222,14 +293,21 @@ set(COMMON_HEADERS
|
||||
${SYNTAX_HEADERS}
|
||||
)
|
||||
|
||||
set(GUI_HEADERS
|
||||
${THEME_HEADERS}
|
||||
${FONT_HEADERS}
|
||||
GUIConfig.h
|
||||
GUIRenderer.h
|
||||
GUIInputHandler.h
|
||||
GUIFrontend.h
|
||||
)
|
||||
if (BUILD_GUI)
|
||||
set(GUI_HEADERS
|
||||
GUIConfig.h
|
||||
)
|
||||
|
||||
set(GUI_HEADERS
|
||||
${GUI_HEADERS}
|
||||
${THEME_HEADERS}
|
||||
${FONT_HEADERS}
|
||||
ImGuiFrontend.h
|
||||
ImGuiInputHandler.h
|
||||
ImGuiRenderer.h
|
||||
fonts/BerkeleyMono.h
|
||||
)
|
||||
endif ()
|
||||
|
||||
# kte (terminal-first) executable
|
||||
add_executable(kte
|
||||
@@ -238,15 +316,17 @@ add_executable(kte
|
||||
${COMMON_HEADERS}
|
||||
)
|
||||
|
||||
if (KTE_USE_PIECE_TABLE)
|
||||
target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1)
|
||||
endif ()
|
||||
if (KTE_UNDO_DEBUG)
|
||||
target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1)
|
||||
endif ()
|
||||
|
||||
target_link_libraries(kte ${CURSES_LIBRARIES})
|
||||
|
||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||
target_link_options(kte PRIVATE -static)
|
||||
endif ()
|
||||
|
||||
if (KTE_ENABLE_TREESITTER)
|
||||
# Users can provide their own tree-sitter include/lib via cache variables
|
||||
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
|
||||
@@ -267,34 +347,86 @@ install(TARGETS kte
|
||||
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
||||
|
||||
if (BUILD_TESTS)
|
||||
# test_undo executable for testing undo/redo system
|
||||
add_executable(test_undo
|
||||
test_undo.cc
|
||||
${COMMON_SOURCES}
|
||||
${COMMON_HEADERS}
|
||||
# Unified unit test runner
|
||||
add_executable(kte_tests
|
||||
tests/TestRunner.cc
|
||||
tests/Test.h
|
||||
tests/TestHarness.h
|
||||
tests/test_daily_driver_harness.cc
|
||||
tests/test_daily_workflows.cc
|
||||
tests/test_buffer_io.cc
|
||||
tests/test_buffer_rows.cc
|
||||
tests/test_command_semantics.cc
|
||||
tests/test_kkeymap.cc
|
||||
tests/test_swap_recorder.cc
|
||||
tests/test_swap_writer.cc
|
||||
tests/test_swap_replay.cc
|
||||
tests/test_swap_edge_cases.cc
|
||||
tests/test_swap_recovery_prompt.cc
|
||||
tests/test_swap_cleanup.cc
|
||||
tests/test_swap_cleanup2.cc
|
||||
tests/test_swap_git_editor.cc
|
||||
tests/test_piece_table.cc
|
||||
tests/test_search_replace_flow.cc
|
||||
tests/test_reflow_paragraph.cc
|
||||
tests/test_reflow_indented_bullets.cc
|
||||
tests/test_undo.cc
|
||||
tests/test_visual_line_mode.cc
|
||||
tests/test_benchmarks.cc
|
||||
tests/test_migration_coverage.cc
|
||||
tests/test_smart_newline.cc
|
||||
tests/test_reflow_undo.cc
|
||||
tests/test_syntax_highlighting.cc
|
||||
tests/test_paste_split.cc
|
||||
tests/test_audit_regressions.cc
|
||||
|
||||
# minimal engine sources required by Buffer
|
||||
PieceTable.cc
|
||||
Buffer.cc
|
||||
Editor.cc
|
||||
Command.cc
|
||||
HelpText.cc
|
||||
Swap.cc
|
||||
ErrorHandler.cc
|
||||
SyscallWrappers.cc
|
||||
ErrorRecovery.cc
|
||||
RegexGuard.cc
|
||||
RegexEngine.cc
|
||||
SearchHighlight.cc
|
||||
KKeymap.cc
|
||||
SwapRecorder.h
|
||||
UndoNode.cc
|
||||
UndoTree.cc
|
||||
UndoSystem.cc
|
||||
${SYNTAX_SOURCES}
|
||||
)
|
||||
|
||||
if (KTE_USE_PIECE_TABLE)
|
||||
target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1)
|
||||
endif ()
|
||||
# Allow test-only introspection hooks (guarded in headers) without affecting production builds.
|
||||
target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
|
||||
|
||||
if (KTE_UNDO_DEBUG)
|
||||
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1)
|
||||
endif ()
|
||||
# Allow tests to include project headers like "Buffer.h"
|
||||
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
|
||||
target_link_libraries(test_undo ${CURSES_LIBRARIES})
|
||||
# Keep tests free of ncurses/GUI deps
|
||||
if (KTE_ENABLE_TREESITTER)
|
||||
if (TREESITTER_INCLUDE_DIR)
|
||||
target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR})
|
||||
target_include_directories(kte_tests PRIVATE ${TREESITTER_INCLUDE_DIR})
|
||||
endif ()
|
||||
if (TREESITTER_LIBRARY)
|
||||
target_link_libraries(test_undo ${TREESITTER_LIBRARY})
|
||||
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||
target_link_options(kte_tests PRIVATE -static)
|
||||
endif ()
|
||||
|
||||
enable_testing()
|
||||
add_test(NAME kte_tests COMMAND kte_tests)
|
||||
endif ()
|
||||
|
||||
if (${BUILD_GUI})
|
||||
if (BUILD_GUI)
|
||||
# ImGui::CreateContext();
|
||||
# ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
@@ -324,6 +456,11 @@ if (${BUILD_GUI})
|
||||
endif ()
|
||||
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
||||
|
||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||
target_link_options(kge PRIVATE -static)
|
||||
endif ()
|
||||
|
||||
# On macOS, build kge as a proper .app bundle
|
||||
if (APPLE)
|
||||
# Define the icon file
|
||||
@@ -342,12 +479,18 @@ if (${BUILD_GUI})
|
||||
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
|
||||
@ONLY)
|
||||
|
||||
# Ensure proper macOS bundle properties and RPATH so our bundled
|
||||
# frameworks are preferred over system/Homebrew ones.
|
||||
set_target_properties(kge PROPERTIES
|
||||
MACOSX_BUNDLE TRUE
|
||||
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
|
||||
MACOSX_BUNDLE_BUNDLE_NAME "kge"
|
||||
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
|
||||
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist")
|
||||
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist"
|
||||
# Prefer the app's bundled frameworks at runtime
|
||||
INSTALL_RPATH "@executable_path/../Frameworks"
|
||||
BUILD_WITH_INSTALL_RPATH TRUE
|
||||
)
|
||||
|
||||
add_dependencies(kge kte)
|
||||
add_custom_command(TARGET kge POST_BUILD
|
||||
@@ -371,4 +514,40 @@ if (${BUILD_GUI})
|
||||
# Install kge man page only when GUI is built
|
||||
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
||||
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).
|
||||
# This provides a CMake target to run BundleUtilities' fixup_bundle on the
|
||||
# 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)
|
||||
get_target_property(IS_BUNDLE kge MACOSX_BUNDLE)
|
||||
if (IS_BUNDLE)
|
||||
add_custom_target(kge_fixup_bundle ALL
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DAPP_BUNDLE=${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_PROPERTY:kge,MACOSX_BUNDLE_BUNDLE_NAME>.app
|
||||
-P ${CMAKE_CURRENT_LIST_DIR}/cmake/fix_bundle.cmake
|
||||
COMMENT "Running fixup_bundle on kge.app to internalize non-system dylibs"
|
||||
VERBATIM)
|
||||
add_dependencies(kge_fixup_bundle kge)
|
||||
endif ()
|
||||
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).
|
||||
+2657
-949
File diff suppressed because it is too large
Load Diff
@@ -27,14 +27,17 @@ enum class CommandId {
|
||||
SearchReplace, // begin search & replace (two-step prompt)
|
||||
OpenFileStart, // begin open-file prompt
|
||||
VisualFilePickerToggle,
|
||||
// GUI-only: toggle/show a visual font selector dialog
|
||||
// Buffers
|
||||
BufferSwitchStart, // begin buffer switch prompt
|
||||
BufferNew, // create a new empty, unnamed buffer (C-k i)
|
||||
BufferClose,
|
||||
BufferNext,
|
||||
BufferPrev,
|
||||
// Editing
|
||||
InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
|
||||
Newline, // insert a newline at cursor
|
||||
SmartNewline, // insert a newline with auto-indent (Shift-Enter)
|
||||
Backspace, // delete char before cursor (may join lines)
|
||||
DeleteChar, // delete char at cursor (may join lines)
|
||||
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
|
||||
@@ -44,6 +47,7 @@ enum class CommandId {
|
||||
MoveFileStart, // move to beginning of file
|
||||
MoveFileEnd, // move to end of file
|
||||
ToggleMark, // toggle mark at cursor
|
||||
VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /)
|
||||
JumpToMark, // jump to mark, set mark to previous cursor
|
||||
KillRegion, // kill region between mark and cursor (to kill ring)
|
||||
CopyRegion, // copy region to kill ring (Alt-w)
|
||||
@@ -90,6 +94,7 @@ enum class CommandId {
|
||||
ShowHelp, // open +HELP+ buffer with manual text (C-k h)
|
||||
// Meta
|
||||
UnknownKCommand, // arg: single character that was not recognized after C-k
|
||||
UnknownEscCommand, // invalid ESC (meta) command; show status and exit escape mode
|
||||
// Generic command prompt
|
||||
CommandPromptStart, // begin generic command prompt (C-k ;)
|
||||
// Theme by name
|
||||
@@ -103,6 +108,16 @@ enum class CommandId {
|
||||
// Syntax highlighting
|
||||
Syntax, // ":syntax on|off|reload"
|
||||
SetOption, // generic ":set key=value" (v1: filetype=<lang>)
|
||||
// Viewport control
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@@ -128,6 +143,9 @@ struct Command {
|
||||
CommandHandler handler;
|
||||
// Public commands are exposed in the ": " prompt (C-k ;)
|
||||
bool isPublic = false;
|
||||
// Whether this command should consume and honor a universal argument repeat count.
|
||||
// Default true per issue request; authors can turn off per-command.
|
||||
bool repeatable = true;
|
||||
};
|
||||
|
||||
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Minimal Dockerfile for building and testing kte on Linux
|
||||
# This container provides a build environment with all dependencies.
|
||||
# Mount the source tree at /kte when running the container.
|
||||
FROM alpine:3.19
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache \
|
||||
g++ \
|
||||
cmake \
|
||||
make \
|
||||
ncurses-dev \
|
||||
pcre2-dev \
|
||||
pkgconf \
|
||||
sdl2-dev \
|
||||
mesa-dev \
|
||||
freetype-dev \
|
||||
libx11-dev \
|
||||
libxext-dev
|
||||
|
||||
# Set working directory where source will be mounted
|
||||
WORKDIR /kte
|
||||
|
||||
# Default command: build and run tests
|
||||
# Add DirectFB include path for SDL2 compatibility on Alpine
|
||||
CMD ["sh", "-c", "cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON -DCMAKE_CXX_FLAGS='-I/usr/include/directfb' && cmake --build build --target kte && cmake --build build --target kge && cmake --build build --target kte_tests && ./build/kte_tests"]
|
||||
@@ -1,14 +1,86 @@
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
#include "Editor.h"
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
#include "ErrorHandler.h"
|
||||
#include "syntax/CppHighlighter.h"
|
||||
#include "syntax/NullHighlighter.h"
|
||||
|
||||
|
||||
Editor::Editor() = default;
|
||||
namespace {
|
||||
// Universal-argument repeat counts are capped here (well below INT_MAX).
|
||||
constexpr int kMaxUArgCount = 1000000;
|
||||
} // namespace
|
||||
|
||||
|
||||
namespace {
|
||||
static void
|
||||
apply_pending_line(Editor &ed, const std::size_t line1)
|
||||
{
|
||||
if (line1 == 0)
|
||||
return;
|
||||
Buffer *b = ed.CurrentBuffer();
|
||||
if (!b)
|
||||
return;
|
||||
const std::size_t nrows = b->Nrows();
|
||||
std::size_t line = line1 > 0 ? line1 - 1 : 0; // 1-based to 0-based
|
||||
if (nrows > 0) {
|
||||
if (line >= nrows)
|
||||
line = nrows - 1;
|
||||
} else {
|
||||
line = 0;
|
||||
}
|
||||
b->SetCursor(0, line);
|
||||
}
|
||||
} // 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()
|
||||
{
|
||||
swap_ = std::make_unique<kte::SwapManager>();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
@@ -30,20 +102,22 @@ Editor::SetStatus(const std::string &message)
|
||||
Buffer *
|
||||
Editor::CurrentBuffer()
|
||||
{
|
||||
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
|
||||
auto &bufs = Buffers();
|
||||
if (bufs.empty() || curbuf_ >= bufs.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &buffers_[curbuf_];
|
||||
return &bufs[curbuf_];
|
||||
}
|
||||
|
||||
|
||||
const Buffer *
|
||||
Editor::CurrentBuffer() const
|
||||
{
|
||||
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
|
||||
const auto &bufs = Buffers();
|
||||
if (bufs.empty() || curbuf_ >= bufs.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &buffers_[curbuf_];
|
||||
return &bufs[curbuf_];
|
||||
}
|
||||
|
||||
|
||||
@@ -78,8 +152,9 @@ Editor::DisplayNameFor(const Buffer &buf) const
|
||||
|
||||
// Prepare list of other buffer paths
|
||||
std::vector<std::vector<std::filesystem::path> > others;
|
||||
others.reserve(buffers_.size());
|
||||
for (const auto &b: buffers_) {
|
||||
const auto &bufs = Buffers();
|
||||
others.reserve(bufs.size());
|
||||
for (const auto &b: bufs) {
|
||||
if (&b == &buf)
|
||||
continue;
|
||||
if (b.Filename().empty())
|
||||
@@ -122,63 +197,96 @@ Editor::DisplayNameFor(const Buffer &buf) const
|
||||
std::size_t
|
||||
Editor::AddBuffer(const Buffer &buf)
|
||||
{
|
||||
buffers_.push_back(buf);
|
||||
if (buffers_.size() == 1) {
|
||||
auto &bufs = Buffers();
|
||||
// push_back may reallocate the vector's storage, moving every existing
|
||||
// Buffer to a new address. Drain any in-flight swap records first so the
|
||||
// writer thread never dereferences an address that's about to move, then
|
||||
// rehome each attached buffer that actually moved.
|
||||
if (Swap() && !bufs.empty())
|
||||
Swap()->Flush();
|
||||
std::vector<Buffer *> old_addrs;
|
||||
old_addrs.reserve(bufs.size());
|
||||
for (auto &b: bufs)
|
||||
old_addrs.push_back(&b);
|
||||
bufs.push_back(buf);
|
||||
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 (bufs.size() == 1) {
|
||||
curbuf_ = 0;
|
||||
}
|
||||
return buffers_.size() - 1;
|
||||
return bufs.size() - 1;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
Editor::AddBuffer(Buffer &&buf)
|
||||
{
|
||||
buffers_.push_back(std::move(buf));
|
||||
if (buffers_.size() == 1) {
|
||||
auto &bufs = Buffers();
|
||||
if (Swap() && !bufs.empty())
|
||||
Swap()->Flush();
|
||||
std::vector<Buffer *> old_addrs;
|
||||
old_addrs.reserve(bufs.size());
|
||||
for (auto &b: bufs)
|
||||
old_addrs.push_back(&b);
|
||||
bufs.push_back(std::move(buf));
|
||||
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 (bufs.size() == 1) {
|
||||
curbuf_ = 0;
|
||||
}
|
||||
return buffers_.size() - 1;
|
||||
return bufs.size() - 1;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::OpenFile(const std::string &path, std::string &err)
|
||||
{
|
||||
// If there is exactly one unnamed, empty, clean buffer, reuse it instead
|
||||
// of creating a new one.
|
||||
if (buffers_.size() == 1) {
|
||||
Buffer &cur = buffers_[curbuf_];
|
||||
// A file that is already open is switched to, not opened again: two
|
||||
// buffers for one file would share (and corrupt) one swap journal, and
|
||||
// closing either would delete the other's journal.
|
||||
if (const std::size_t open_idx = FindOpenBuffer(path); open_idx != static_cast<std::size_t>(-1)) {
|
||||
SwitchTo(open_idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the current buffer is an unnamed, empty, clean scratch buffer, reuse
|
||||
// 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 clean = !cur.Dirty();
|
||||
const auto &rows = cur.Rows();
|
||||
const bool rows_empty = rows.empty();
|
||||
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0);
|
||||
const std::size_t nrows = cur.Nrows();
|
||||
const bool rows_empty = (nrows == 0);
|
||||
const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
|
||||
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
||||
bool ok = cur.OpenFromFile(path, err);
|
||||
if (!ok)
|
||||
return false;
|
||||
// Setup highlighting using registry (extension + shebang)
|
||||
cur.EnsureHighlighter();
|
||||
std::string first = "";
|
||||
const auto &rows = cur.Rows();
|
||||
if (!rows.empty())
|
||||
first = static_cast<std::string>(rows[0]);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||
if (!ft.empty()) {
|
||||
cur.SetFiletype(ft);
|
||||
cur.SetSyntaxEnabled(true);
|
||||
if (auto *eng = cur.Highlighter()) {
|
||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
} else {
|
||||
cur.SetFiletype("");
|
||||
cur.SetSyntaxEnabled(true);
|
||||
if (auto *eng = cur.Highlighter()) {
|
||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
// Ensure swap recorder is attached for this buffer
|
||||
if (Swap()) {
|
||||
Swap()->Attach(&cur);
|
||||
cur.SetSwapRecorder(Swap()->RecorderFor(&cur));
|
||||
Swap()->NotifyFilenameChanged(cur);
|
||||
}
|
||||
// Highlighting by extension or shebang
|
||||
cur.ApplyDetectedFiletype();
|
||||
// Defensive: ensure any active prompt is closed after a successful open
|
||||
CancelPrompt();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -187,65 +295,323 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
if (!b.OpenFromFile(path, err)) {
|
||||
return false;
|
||||
}
|
||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
||||
b.EnsureHighlighter();
|
||||
std::string first = "";
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
if (!rows.empty())
|
||||
first = static_cast<std::string>(rows[0]);
|
||||
}
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||
if (!ft.empty()) {
|
||||
b.SetFiletype(ft);
|
||||
b.SetSyntaxEnabled(true);
|
||||
if (auto *eng = b.Highlighter()) {
|
||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
} else {
|
||||
b.SetFiletype("");
|
||||
b.SetSyntaxEnabled(true);
|
||||
if (auto *eng = b.Highlighter()) {
|
||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
}
|
||||
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
||||
// final location (vector) because swap manager keys off Buffer*.
|
||||
// Highlighting by extension or shebang
|
||||
b.ApplyDetectedFiletype();
|
||||
// Add as a new buffer and switch to it
|
||||
std::size_t idx = AddBuffer(std::move(b));
|
||||
if (Swap()) {
|
||||
Swap()->NotifyFilenameChanged(Buffers()[idx]);
|
||||
}
|
||||
SwitchTo(idx);
|
||||
// Defensive: ensure any active prompt is closed after a successful open
|
||||
CancelPrompt();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::RequestOpenFile(const std::string &path, const std::size_t line1)
|
||||
{
|
||||
PendingOpen p;
|
||||
p.path = path;
|
||||
p.line1 = line1;
|
||||
pending_open_.push_back(std::move(p));
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::HasPendingOpens() const
|
||||
{
|
||||
return !pending_open_.empty();
|
||||
}
|
||||
|
||||
|
||||
Editor::RecoveryPromptKind
|
||||
Editor::PendingRecoveryPrompt() const
|
||||
{
|
||||
return pending_recovery_prompt_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::CancelRecoveryPrompt()
|
||||
{
|
||||
pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||
pending_recovery_open_ = PendingOpen{};
|
||||
pending_recovery_swap_path_.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;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::ResolveRecoveryPrompt(const bool yes)
|
||||
{
|
||||
const RecoveryPromptKind kind = pending_recovery_prompt_;
|
||||
if (kind == RecoveryPromptKind::None)
|
||||
return false;
|
||||
const PendingOpen req = pending_recovery_open_;
|
||||
const std::string swp = pending_recovery_swap_path_;
|
||||
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();
|
||||
|
||||
std::string err;
|
||||
if (kind == RecoveryPromptKind::RecoverOrDiscard) {
|
||||
if (yes) {
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
Buffer *b = CurrentBuffer();
|
||||
if (!b) {
|
||||
SetStatus("Recovery failed: no buffer");
|
||||
return false;
|
||||
}
|
||||
std::uint64_t swap_size_now = 0;
|
||||
try {
|
||||
swap_size_now = std::filesystem::file_size(swp);
|
||||
} catch (...) {
|
||||
}
|
||||
if (have_content && swap_size_now == swap_size_then) {
|
||||
// The prompt already replayed the journal (unchanged since); install that.
|
||||
b->replace_all_bytes(content);
|
||||
std::string().swap(content);
|
||||
} else {
|
||||
std::string rerr;
|
||||
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);
|
||||
apply_pending_line(*this, req.line1);
|
||||
SetStatus("Recovered " + req.path);
|
||||
return true;
|
||||
}
|
||||
// Discard: best-effort delete swap, then open clean.
|
||||
(void) std::remove(swp.c_str());
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
apply_pending_line(*this, req.line1);
|
||||
SetStatus("Opened " + req.path);
|
||||
return true;
|
||||
}
|
||||
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
||||
std::string kept_as;
|
||||
if (yes) {
|
||||
(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)) {
|
||||
SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
apply_pending_line(*this, req.line1);
|
||||
// Include a short hint that the swap was corrupt.
|
||||
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)");
|
||||
} else {
|
||||
SetStatus("Opened " + req.path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
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())
|
||||
return false;
|
||||
if (pending_recovery_prompt_ != RecoveryPromptKind::None)
|
||||
return false;
|
||||
|
||||
bool opened_any = false;
|
||||
while (!pending_open_.empty()) {
|
||||
PendingOpen req = std::move(pending_open_.front());
|
||||
pending_open_.pop_front();
|
||||
if (req.path.empty())
|
||||
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);
|
||||
bool swp_exists = false;
|
||||
try {
|
||||
swp_exists = !swp.empty() && std::filesystem::exists(std::filesystem::path(swp));
|
||||
} catch (...) {
|
||||
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) {
|
||||
Buffer tmp;
|
||||
std::string oerr;
|
||||
if (tmp.OpenFromFile(req.path, oerr)) {
|
||||
const std::string orig = tmp.Bytes();
|
||||
std::string rerr;
|
||||
std::uint64_t valid = 0;
|
||||
if (kte::SwapManager::ReplayFile(tmp, swp, rerr, &valid)) {
|
||||
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_open_ = req;
|
||||
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", "");
|
||||
SetStatus("Recover swap edits for " + req.path + "? (y/n, C-g cancel)");
|
||||
return opened_any;
|
||||
}
|
||||
} else {
|
||||
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
|
||||
pending_recovery_open_ = req;
|
||||
pending_recovery_swap_path_ = swp;
|
||||
pending_recovery_replay_err_ = rerr;
|
||||
StartPrompt(PromptKind::Confirm, "Swap", "");
|
||||
SetStatus(
|
||||
"Swap file unreadable for " + req.path +
|
||||
". Delete it? (y/N, C-g cancel)");
|
||||
return opened_any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string err;
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
opened_any = false;
|
||||
continue;
|
||||
}
|
||||
apply_pending_line(*this, req.line1);
|
||||
SetStatus("Opened " + req.path);
|
||||
opened_any = true;
|
||||
// Open at most one per call; frontends can call us again next frame.
|
||||
break;
|
||||
}
|
||||
return opened_any;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::SwitchTo(std::size_t index)
|
||||
{
|
||||
if (index >= buffers_.size()) {
|
||||
auto &bufs = Buffers();
|
||||
if (index >= bufs.size()) {
|
||||
return false;
|
||||
}
|
||||
curbuf_ = index;
|
||||
// Robustness: ensure a valid highlighter is installed when switching buffers
|
||||
Buffer &b = buffers_[curbuf_];
|
||||
if (b.SyntaxEnabled()) {
|
||||
b.EnsureHighlighter();
|
||||
if (auto *eng = b.Highlighter()) {
|
||||
if (!eng->HasHighlighter()) {
|
||||
// Try to set based on existing filetype; fall back to NullHighlighter
|
||||
if (!b.Filetype().empty()) {
|
||||
auto hl = kte::HighlighterRegistry::CreateFor(b.Filetype());
|
||||
if (hl) {
|
||||
eng->SetHighlighter(std::move(hl));
|
||||
} else {
|
||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
}
|
||||
} else {
|
||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
}
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Buffer &b = bufs[curbuf_];
|
||||
if (b.SyntaxEnabled() && !(b.Highlighter() && b.Highlighter()->HasHighlighter()))
|
||||
b.InstallFiletypeHighlighter();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -253,14 +619,36 @@ Editor::SwitchTo(std::size_t index)
|
||||
bool
|
||||
Editor::CloseBuffer(std::size_t index)
|
||||
{
|
||||
if (index >= buffers_.size()) {
|
||||
auto &bufs = Buffers();
|
||||
if (index >= bufs.size()) {
|
||||
return false;
|
||||
}
|
||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
if (buffers_.empty()) {
|
||||
if (Swap()) {
|
||||
// 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.
|
||||
// This prevents stale swap files from accumulating (e.g., when used as git editor).
|
||||
Swap()->Detach(&bufs[index], true);
|
||||
bufs[index].SetSwapRecorder(nullptr);
|
||||
// Drain in-flight records before the erase-shift below moves other
|
||||
// buffers to new addresses (vector::erase never reallocates, but it does
|
||||
// move-assign each trailing buffer into the previous slot).
|
||||
Swap()->Flush();
|
||||
}
|
||||
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
if (Swap()) {
|
||||
// erase() shifts every buffer after `index` down by one slot in-place
|
||||
// (no reallocation), so the buffer now at slot i used to live at slot
|
||||
// i+1 (same underlying storage, since data() doesn't move on erase).
|
||||
for (std::size_t i = index; i < bufs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
Buffer *old_addr = new_addr + 1;
|
||||
bufs[i].SetSwapRecorder(Swap()->Rehome(old_addr, new_addr));
|
||||
}
|
||||
}
|
||||
if (bufs.empty()) {
|
||||
curbuf_ = 0;
|
||||
} else if (curbuf_ >= buffers_.size()) {
|
||||
curbuf_ = buffers_.size() - 1;
|
||||
} else if (curbuf_ >= bufs.size()) {
|
||||
curbuf_ = bufs.size() - 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -270,16 +658,76 @@ void
|
||||
Editor::Reset()
|
||||
{
|
||||
rows_ = cols_ = 0;
|
||||
mode_ = 0;
|
||||
kill_ = 0;
|
||||
no_kill_ = 0;
|
||||
dirtyex_ = 0;
|
||||
msg_.clear();
|
||||
msgtm_ = 0;
|
||||
uarg_ = 0;
|
||||
ucount_ = 0;
|
||||
quit_requested_ = false;
|
||||
quit_confirm_pending_ = false;
|
||||
buffers_.clear();
|
||||
// Reset close-confirm/save state
|
||||
close_confirm_pending_ = false;
|
||||
close_after_save_ = false;
|
||||
auto &bufs = Buffers();
|
||||
if (Swap()) {
|
||||
for (auto &buf : bufs)
|
||||
Swap()->Detach(&buf, true);
|
||||
}
|
||||
bufs.clear();
|
||||
curbuf_ = 0;
|
||||
}
|
||||
|
||||
|
||||
// --- Universal argument helpers ---
|
||||
void
|
||||
Editor::UArgStart()
|
||||
{
|
||||
// If not active, start fresh; else multiply by 4 per ke semantics
|
||||
if (uarg_ == 0) {
|
||||
ucount_ = 0;
|
||||
} else {
|
||||
if (ucount_ == 0) {
|
||||
ucount_ = 1;
|
||||
}
|
||||
ucount_ = std::min(ucount_ * 4, kMaxUArgCount); // no int overflow
|
||||
}
|
||||
uarg_ = 1;
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
||||
SetStatus(buf);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::UArgDigit(int d)
|
||||
{
|
||||
if (d < 0)
|
||||
d = 0;
|
||||
if (d > 9)
|
||||
d = 9;
|
||||
if (uarg_ == 0) {
|
||||
uarg_ = 1;
|
||||
ucount_ = 0;
|
||||
}
|
||||
ucount_ = (ucount_ > (kMaxUArgCount - d) / 10) ? kMaxUArgCount : ucount_ * 10 + d;
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
||||
SetStatus(buf);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::UArgClear()
|
||||
{
|
||||
uarg_ = 0;
|
||||
ucount_ = 0;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Editor::UArgGet()
|
||||
{
|
||||
int n = (ucount_ > 0) ? ucount_ : 1;
|
||||
UArgClear();
|
||||
return n;
|
||||
}
|
||||
@@ -1,13 +1,52 @@
|
||||
/*
|
||||
* Editor.h - top-level editor state and buffer management
|
||||
*
|
||||
* Editor is the top-level coordinator in kte. It manages:
|
||||
*
|
||||
* - Buffer collection: Multiple open documents (buffers_), current buffer selection
|
||||
* - UI state: Dimensions, status messages, prompts, search state
|
||||
* - Kill ring: Shared clipboard for cut/copy/paste operations across buffers
|
||||
* - Universal argument: Repeat count mechanism (C-u)
|
||||
* - Mode flags: Editor modes (normal, k-command, search, prompt, etc.)
|
||||
* - Swap/crash recovery: SwapManager integration for journaling
|
||||
* - File operations: Opening files, managing pending opens, recovery prompts
|
||||
*
|
||||
* Key responsibilities:
|
||||
*
|
||||
* 1. Buffer lifecycle:
|
||||
* - AddBuffer(): Add new buffers to the collection
|
||||
* - OpenFile(): Load files into buffers
|
||||
* - SwitchTo(): Change active buffer
|
||||
* - CloseBuffer(): Remove buffers with dirty checks
|
||||
*
|
||||
* 2. UI coordination:
|
||||
* - SetDimensions(): Terminal/window size for viewport calculations
|
||||
* - SetStatus(): Status line messages with timestamps
|
||||
* - Prompt system: Multi-step prompts for file open, buffer switch, etc.
|
||||
* - Search state: Active search, query, match position, origin tracking
|
||||
*
|
||||
* 3. Shared editor state:
|
||||
* - Kill ring: Circular buffer of killed text (max 60 entries)
|
||||
* - Universal argument: C-u digit collection for command repetition
|
||||
* - Mode tracking: Current input mode (normal, k-command, ESC, prompt)
|
||||
*
|
||||
* 4. Integration points:
|
||||
* - Commands operate on Editor and current Buffer
|
||||
* - Frontend (Terminal/GUI) queries Editor for rendering
|
||||
* - SwapManager journals all buffer modifications
|
||||
*
|
||||
* Design note: Editor owns the buffer collection but doesn't directly edit content.
|
||||
* Commands modify buffers through Buffer's API, and Editor coordinates the UI state.
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Swap.h"
|
||||
|
||||
|
||||
class Editor {
|
||||
@@ -40,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)
|
||||
{
|
||||
kill_ = on ? 1 : 0;
|
||||
@@ -65,18 +91,6 @@ public:
|
||||
}
|
||||
|
||||
|
||||
void SetNoKill(bool on)
|
||||
{
|
||||
no_kill_ = on ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool NoKill() const
|
||||
{
|
||||
return no_kill_ != 0;
|
||||
}
|
||||
|
||||
|
||||
// Kill ring API
|
||||
void KillRingClear()
|
||||
{
|
||||
@@ -125,18 +139,6 @@ public:
|
||||
}
|
||||
|
||||
|
||||
void SetDirtyEx(int d)
|
||||
{
|
||||
dirtyex_ = d;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] int DirtyEx() const
|
||||
{
|
||||
return dirtyex_;
|
||||
}
|
||||
|
||||
|
||||
void SetUniversalArg(int uarg, int ucount)
|
||||
{
|
||||
uarg_ = uarg;
|
||||
@@ -150,11 +152,19 @@ public:
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] int UCount() const
|
||||
{
|
||||
return ucount_;
|
||||
}
|
||||
// --- Universal argument control (C-u) ---
|
||||
// Begin or extend a universal argument (like ke's uarg_start)
|
||||
void UArgStart();
|
||||
|
||||
// Add a digit 0..9 to the current universal argument (like ke's uarg_digit)
|
||||
void UArgDigit(int d);
|
||||
|
||||
// Clear universal-argument state (like ke's uarg_clear)
|
||||
void UArgClear();
|
||||
|
||||
// Consume the current universal argument, returning count >= 1.
|
||||
// If no universal argument active, returns 1.
|
||||
int UArgGet();
|
||||
|
||||
// Status message storage. Rendering is renderer-dependent; the editor
|
||||
// merely stores the current message and its timestamp.
|
||||
@@ -180,6 +190,18 @@ public:
|
||||
}
|
||||
|
||||
|
||||
void SetNewWindowRequested(bool on)
|
||||
{
|
||||
new_window_requested_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool NewWindowRequested() const
|
||||
{
|
||||
return new_window_requested_;
|
||||
}
|
||||
|
||||
|
||||
void SetQuitConfirmPending(bool on)
|
||||
{
|
||||
quit_confirm_pending_ = on;
|
||||
@@ -192,6 +214,31 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// --- Buffer close/save confirmation state ---
|
||||
void SetCloseConfirmPending(bool on)
|
||||
{
|
||||
close_confirm_pending_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool CloseConfirmPending() const
|
||||
{
|
||||
return close_confirm_pending_;
|
||||
}
|
||||
|
||||
|
||||
void SetCloseAfterSave(bool on)
|
||||
{
|
||||
close_after_save_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool CloseAfterSave() const
|
||||
{
|
||||
return close_after_save_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::time_t StatusTime() const
|
||||
{
|
||||
return msgtm_;
|
||||
@@ -418,7 +465,7 @@ public:
|
||||
// Buffers
|
||||
[[nodiscard]] std::size_t BufferCount() const
|
||||
{
|
||||
return buffers_.size();
|
||||
return Buffers().size();
|
||||
}
|
||||
|
||||
|
||||
@@ -428,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();
|
||||
|
||||
const Buffer *CurrentBuffer() const;
|
||||
@@ -444,6 +504,35 @@ public:
|
||||
|
||||
bool OpenFile(const std::string &path, std::string &err);
|
||||
|
||||
// Request that a file be opened. The request is processed by calling
|
||||
// ProcessPendingOpens() (typically once per frontend frame).
|
||||
void RequestOpenFile(const std::string &path, std::size_t line1 = 0);
|
||||
|
||||
// If no modal prompt is active, process queued open requests.
|
||||
// Returns true if a file was opened during this call.
|
||||
bool ProcessPendingOpens();
|
||||
|
||||
private:
|
||||
bool process_pending_opens_();
|
||||
|
||||
public:
|
||||
|
||||
[[nodiscard]] bool HasPendingOpens() const;
|
||||
|
||||
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
||||
// prompt is active and the user's answer should be routed to ResolveRecoveryPrompt().
|
||||
enum class RecoveryPromptKind {
|
||||
None = 0,
|
||||
RecoverOrDiscard, // y = recover swap, else discard swap and open clean
|
||||
DeleteCorruptSwap // y = delete corrupt swap, else keep it
|
||||
};
|
||||
|
||||
[[nodiscard]] RecoveryPromptKind PendingRecoveryPrompt() const;
|
||||
|
||||
bool ResolveRecoveryPrompt(bool yes);
|
||||
|
||||
void CancelRecoveryPrompt();
|
||||
|
||||
// Buffer switching/closing
|
||||
bool SwitchTo(std::size_t index);
|
||||
|
||||
@@ -455,13 +544,38 @@ public:
|
||||
// Direct access when needed (try to prefer methods above)
|
||||
[[nodiscard]] const std::vector<Buffer> &Buffers() const
|
||||
{
|
||||
return buffers_;
|
||||
return shared_buffers_ ? *shared_buffers_ : 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)
|
||||
[[nodiscard]] kte::SwapManager *Swap()
|
||||
{
|
||||
return shared_swap_ ? shared_swap_ : swap_.get();
|
||||
}
|
||||
|
||||
|
||||
@@ -490,25 +604,35 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
struct PendingOpen {
|
||||
std::string path;
|
||||
std::size_t line1{0}; // 1-based; 0 = none
|
||||
};
|
||||
|
||||
std::size_t rows_ = 0, cols_ = 0;
|
||||
int mode_ = 0;
|
||||
int kill_ = 0; // KILL CHAIN
|
||||
int no_kill_ = 0; // don't kill in delete_row
|
||||
int dirtyex_ = 0;
|
||||
std::string msg_;
|
||||
std::time_t msgtm_ = 0;
|
||||
int uarg_ = 0, ucount_ = 0; // C-u support
|
||||
|
||||
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)
|
||||
std::unique_ptr<kte::SwapManager> swap_;
|
||||
kte::SwapManager *shared_swap_ = nullptr; // set with shared_buffers_; not owned
|
||||
|
||||
// Kill ring (Emacs-like)
|
||||
std::vector<std::string> kill_ring_;
|
||||
std::size_t kill_ring_max_ = 60;
|
||||
|
||||
// Quit state
|
||||
bool quit_requested_ = false;
|
||||
bool quit_confirm_pending_ = false;
|
||||
bool quit_requested_ = false;
|
||||
bool new_window_requested_ = false;
|
||||
bool quit_confirm_pending_ = false;
|
||||
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
|
||||
|
||||
// Search state
|
||||
bool search_active_ = false;
|
||||
@@ -527,6 +651,19 @@ private:
|
||||
std::string prompt_text_;
|
||||
std::string pending_overwrite_path_;
|
||||
|
||||
// Deferred open + swap recovery prompt state
|
||||
std::deque<PendingOpen> pending_open_;
|
||||
RecoveryPromptKind pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||
PendingOpen pending_recovery_open_{};
|
||||
std::string pending_recovery_swap_path_;
|
||||
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)
|
||||
bool file_picker_visible_ = false;
|
||||
std::string file_picker_dir_;
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
#include "ErrorHandler.h"
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace kte {
|
||||
ErrorHandler::ErrorHandler()
|
||||
{
|
||||
// Determine log file path: ~/.local/state/kte/error.log
|
||||
const char *home = std::getenv("HOME");
|
||||
if (home) {
|
||||
fs::path log_dir = fs::path(home) / ".local" / "state" / "kte";
|
||||
try {
|
||||
if (!fs::exists(log_dir)) {
|
||||
fs::create_directories(log_dir);
|
||||
}
|
||||
log_file_path_ = (log_dir / "error.log").string();
|
||||
// Create the log file immediately so it exists in the state directory
|
||||
ensure_log_file();
|
||||
} catch (...) {
|
||||
// If we can't create the directory, disable file logging
|
||||
file_logging_enabled_ = false;
|
||||
}
|
||||
} else {
|
||||
// No HOME, disable file logging
|
||||
file_logging_enabled_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ErrorHandler::~ErrorHandler()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (log_file_ && log_file_->is_open()) {
|
||||
log_file_->flush();
|
||||
log_file_->close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ErrorHandler &
|
||||
ErrorHandler::Instance()
|
||||
{
|
||||
static ErrorHandler instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Report(ErrorSeverity severity, const std::string &component,
|
||||
const std::string &message, const std::string &context)
|
||||
{
|
||||
ErrorRecord record;
|
||||
record.timestamp_ns = now_ns();
|
||||
record.severity = severity;
|
||||
record.component = component;
|
||||
record.message = message;
|
||||
record.context = context;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
// Add to in-memory queue
|
||||
errors_.push_back(record);
|
||||
while (errors_.size() > 100) {
|
||||
errors_.pop_front();
|
||||
}
|
||||
|
||||
++total_error_count_;
|
||||
if (severity == ErrorSeverity::Critical) {
|
||||
++critical_error_count_;
|
||||
}
|
||||
|
||||
// Write to log file if enabled
|
||||
if (file_logging_enabled_) {
|
||||
write_to_log(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Info(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Info, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Warning(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Warning, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Error(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Error, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Critical(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Critical, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
ErrorHandler::HasErrors() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return !errors_.empty();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
ErrorHandler::HasCriticalErrors() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return critical_error_count_ > 0;
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::GetLastError() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (errors_.empty())
|
||||
return "";
|
||||
|
||||
const ErrorRecord &e = errors_.back();
|
||||
std::string result = "[" + severity_to_string(e.severity) + "] ";
|
||||
result += e.component;
|
||||
if (!e.context.empty()) {
|
||||
result += " (" + e.context + ")";
|
||||
}
|
||||
result += ": " + e.message;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
ErrorHandler::GetErrorCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return total_error_count_;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
ErrorHandler::GetErrorCount(ErrorSeverity severity) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
std::size_t count = 0;
|
||||
for (const auto &e: errors_) {
|
||||
if (e.severity == severity) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
std::vector<ErrorHandler::ErrorRecord>
|
||||
ErrorHandler::GetRecentErrors(std::size_t max_count) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
std::vector<ErrorRecord> result;
|
||||
result.reserve(std::min(max_count, errors_.size()));
|
||||
|
||||
// Return most recent first
|
||||
auto it = errors_.rbegin();
|
||||
for (std::size_t i = 0; i < max_count && it != errors_.rend(); ++i, ++it) {
|
||||
result.push_back(*it);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::ClearErrors()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
errors_.clear();
|
||||
total_error_count_ = 0;
|
||||
critical_error_count_ = 0;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::SetFileLoggingEnabled(bool enabled)
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
file_logging_enabled_ = enabled;
|
||||
if (!enabled && log_file_ && log_file_->is_open()) {
|
||||
log_file_->flush();
|
||||
log_file_->close();
|
||||
log_file_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::GetLogFilePath() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return log_file_path_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::write_to_log(const ErrorRecord &record)
|
||||
{
|
||||
// Must be called with mtx_ held
|
||||
if (log_file_path_.empty())
|
||||
return;
|
||||
|
||||
ensure_log_file();
|
||||
if (!log_file_ || !log_file_->is_open())
|
||||
return;
|
||||
|
||||
// Format: [timestamp] [SEVERITY] component (context): message
|
||||
std::string timestamp = format_timestamp(record.timestamp_ns);
|
||||
std::string severity = severity_to_string(record.severity);
|
||||
|
||||
*log_file_ << "[" << timestamp << "] [" << severity << "] " << record.component;
|
||||
if (!record.context.empty()) {
|
||||
*log_file_ << " (" << record.context << ")";
|
||||
}
|
||||
*log_file_ << ": " << record.message << "\n";
|
||||
log_file_->flush();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::ensure_log_file()
|
||||
{
|
||||
// Must be called with mtx_ held
|
||||
if (log_file_ && log_file_->is_open())
|
||||
return;
|
||||
|
||||
if (log_file_path_.empty())
|
||||
return;
|
||||
|
||||
try {
|
||||
log_file_ = std::make_unique<std::ofstream>(log_file_path_,
|
||||
std::ios::app | std::ios::out);
|
||||
if (!log_file_->is_open()) {
|
||||
log_file_.reset();
|
||||
}
|
||||
} catch (...) {
|
||||
log_file_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::format_timestamp(std::uint64_t timestamp_ns) const
|
||||
{
|
||||
// Convert nanoseconds to time_t (seconds)
|
||||
std::time_t seconds = static_cast<std::time_t>(timestamp_ns / 1000000000ULL);
|
||||
std::uint64_t nanos = timestamp_ns % 1000000000ULL;
|
||||
|
||||
std::tm tm_buf{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm_buf, &seconds);
|
||||
#else
|
||||
localtime_r(&seconds, &tm_buf);
|
||||
#endif
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(&tm_buf, "%Y-%m-%d %H:%M:%S");
|
||||
oss << "." << std::setfill('0') << std::setw(3) << (nanos / 1000000ULL);
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::severity_to_string(ErrorSeverity severity) const
|
||||
{
|
||||
switch (severity) {
|
||||
case ErrorSeverity::Info:
|
||||
return "INFO";
|
||||
case ErrorSeverity::Warning:
|
||||
return "WARNING";
|
||||
case ErrorSeverity::Error:
|
||||
return "ERROR";
|
||||
case ErrorSeverity::Critical:
|
||||
return "CRITICAL";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::uint64_t
|
||||
ErrorHandler::now_ns()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<nanoseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
} // namespace kte
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// ErrorHandler.h - Centralized error handling and logging for kte
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <fstream>
|
||||
|
||||
namespace kte {
|
||||
enum class ErrorSeverity {
|
||||
Info, // Informational messages
|
||||
Warning, // Non-critical issues
|
||||
Error, // Errors that affect functionality but allow continuation
|
||||
Critical // Critical errors that may cause data loss or crashes
|
||||
};
|
||||
|
||||
// Centralized error handler with logging and in-memory error tracking
|
||||
class ErrorHandler {
|
||||
public:
|
||||
struct ErrorRecord {
|
||||
std::uint64_t timestamp_ns{0};
|
||||
ErrorSeverity severity{ErrorSeverity::Error};
|
||||
std::string component; // e.g., "SwapManager", "Buffer", "main"
|
||||
std::string message;
|
||||
std::string context; // e.g., filename, buffer name, operation
|
||||
};
|
||||
|
||||
// Get the global ErrorHandler instance
|
||||
static ErrorHandler &Instance();
|
||||
|
||||
// Report an error with severity, component, message, and optional context
|
||||
void Report(ErrorSeverity severity, const std::string &component,
|
||||
const std::string &message, const std::string &context = "");
|
||||
|
||||
// Convenience methods for common severity levels
|
||||
void Info(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
void Warning(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
void Error(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
void Critical(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
// Query error state (thread-safe)
|
||||
bool HasErrors() const;
|
||||
|
||||
bool HasCriticalErrors() const;
|
||||
|
||||
std::string GetLastError() const;
|
||||
|
||||
std::size_t GetErrorCount() const;
|
||||
|
||||
std::size_t GetErrorCount(ErrorSeverity severity) const;
|
||||
|
||||
// Get recent errors (up to max_count, most recent first)
|
||||
std::vector<ErrorRecord> GetRecentErrors(std::size_t max_count = 10) const;
|
||||
|
||||
// Clear in-memory error history (does not affect log file)
|
||||
void ClearErrors();
|
||||
|
||||
// Enable/disable file logging (enabled by default)
|
||||
void SetFileLoggingEnabled(bool enabled);
|
||||
|
||||
// Get the path to the error log file
|
||||
std::string GetLogFilePath() const;
|
||||
|
||||
private:
|
||||
ErrorHandler();
|
||||
|
||||
~ErrorHandler();
|
||||
|
||||
// Non-copyable, non-movable
|
||||
ErrorHandler(const ErrorHandler &) = delete;
|
||||
|
||||
ErrorHandler &operator=(const ErrorHandler &) = delete;
|
||||
|
||||
ErrorHandler(ErrorHandler &&) = delete;
|
||||
|
||||
ErrorHandler &operator=(ErrorHandler &&) = delete;
|
||||
|
||||
void write_to_log(const ErrorRecord &record);
|
||||
|
||||
void ensure_log_file();
|
||||
|
||||
std::string format_timestamp(std::uint64_t timestamp_ns) const;
|
||||
|
||||
std::string severity_to_string(ErrorSeverity severity) const;
|
||||
|
||||
static std::uint64_t now_ns();
|
||||
|
||||
mutable std::mutex mtx_;
|
||||
std::deque<ErrorRecord> errors_; // bounded to max 100 entries
|
||||
std::size_t total_error_count_{0};
|
||||
std::size_t critical_error_count_{0};
|
||||
bool file_logging_enabled_{true};
|
||||
std::string log_file_path_;
|
||||
std::unique_ptr<std::ofstream> log_file_;
|
||||
};
|
||||
} // namespace kte
|
||||
@@ -0,0 +1,159 @@
|
||||
// ErrorRecovery.cc - Error recovery mechanisms implementation
|
||||
#include "ErrorRecovery.h"
|
||||
#include <mutex>
|
||||
|
||||
namespace kte {
|
||||
CircuitBreaker::CircuitBreaker(const Config &cfg)
|
||||
: config_(cfg), state_(State::Closed), failure_count_(0), success_count_(0),
|
||||
last_failure_time_(std::chrono::steady_clock::time_point::min()),
|
||||
state_change_time_(std::chrono::steady_clock::now()) {}
|
||||
|
||||
|
||||
bool
|
||||
CircuitBreaker::AllowRequest()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
|
||||
switch (state_) {
|
||||
case State::Closed:
|
||||
// Normal operation, allow all requests
|
||||
return true;
|
||||
|
||||
case State::Open: {
|
||||
// Check if timeout has elapsed to transition to HalfOpen
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - state_change_time_
|
||||
);
|
||||
if (elapsed >= config_.open_timeout) {
|
||||
TransitionTo(State::HalfOpen);
|
||||
return true; // Allow one request to test recovery
|
||||
}
|
||||
return false; // Circuit is open, reject request
|
||||
}
|
||||
|
||||
case State::HalfOpen:
|
||||
// Allow limited requests to test recovery
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::RecordSuccess()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
switch (state_) {
|
||||
case State::Closed:
|
||||
// Reset failure count on success in normal operation
|
||||
failure_count_ = 0;
|
||||
break;
|
||||
|
||||
case State::HalfOpen:
|
||||
++success_count_;
|
||||
if (success_count_ >= config_.success_threshold) {
|
||||
// Enough successes, close the circuit
|
||||
TransitionTo(State::Closed);
|
||||
}
|
||||
break;
|
||||
|
||||
case State::Open:
|
||||
// Shouldn't happen (requests rejected), but handle gracefully
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::RecordFailure()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
// The window is measured from the previous failure, so check it before
|
||||
// recording this one.
|
||||
const bool window_expired = IsWindowExpired();
|
||||
last_failure_time_ = std::chrono::steady_clock::now();
|
||||
|
||||
switch (state_) {
|
||||
case State::Closed:
|
||||
// Check if we need to reset the failure count (window expired)
|
||||
if (window_expired) {
|
||||
failure_count_ = 0;
|
||||
}
|
||||
|
||||
++failure_count_;
|
||||
if (failure_count_ >= config_.failure_threshold) {
|
||||
// Too many failures, open the circuit
|
||||
TransitionTo(State::Open);
|
||||
}
|
||||
break;
|
||||
|
||||
case State::HalfOpen:
|
||||
// Failure during recovery test, reopen the circuit
|
||||
TransitionTo(State::Open);
|
||||
break;
|
||||
|
||||
case State::Open:
|
||||
// Already open, just track the failure
|
||||
++failure_count_;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::Reset()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
TransitionTo(State::Closed);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::TransitionTo(State new_state)
|
||||
{
|
||||
if (state_ == new_state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state_ = new_state;
|
||||
state_change_time_ = std::chrono::steady_clock::now();
|
||||
|
||||
switch (new_state) {
|
||||
case State::Closed:
|
||||
failure_count_ = 0;
|
||||
success_count_ = 0;
|
||||
break;
|
||||
|
||||
case State::Open:
|
||||
success_count_ = 0;
|
||||
// Keep failure_count_ for diagnostics
|
||||
break;
|
||||
|
||||
case State::HalfOpen:
|
||||
success_count_ = 0;
|
||||
// Keep failure_count_ for diagnostics
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
CircuitBreaker::IsWindowExpired() const
|
||||
{
|
||||
if (failure_count_ == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - last_failure_time_
|
||||
);
|
||||
|
||||
return elapsed >= config_.window;
|
||||
}
|
||||
} // namespace kte
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
// ErrorRecovery.h - Error recovery mechanisms for kte
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <cerrno>
|
||||
|
||||
namespace kte {
|
||||
// Classify errno values as transient (retryable) or permanent
|
||||
inline bool
|
||||
IsTransientError(int err)
|
||||
{
|
||||
switch (err) {
|
||||
case EAGAIN:
|
||||
#if EAGAIN != EWOULDBLOCK
|
||||
case EWOULDBLOCK:
|
||||
#endif
|
||||
case EBUSY:
|
||||
case EIO: // I/O error (may be transient on network filesystems)
|
||||
case ETIMEDOUT:
|
||||
case ENOSPC: // Disk full (may become available)
|
||||
case EDQUOT: // Quota exceeded (may become available)
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// RetryPolicy defines retry behavior for transient failures
|
||||
struct RetryPolicy {
|
||||
std::size_t max_attempts{3}; // Maximum retry attempts
|
||||
std::chrono::milliseconds initial_delay{100}; // Initial delay before first retry
|
||||
double backoff_multiplier{2.0}; // Exponential backoff multiplier
|
||||
std::chrono::milliseconds max_delay{5000}; // Maximum delay between retries
|
||||
|
||||
// Default policy: 3 attempts, 100ms initial, 2x backoff, 5s max
|
||||
static RetryPolicy Default()
|
||||
{
|
||||
return RetryPolicy{};
|
||||
}
|
||||
|
||||
|
||||
// Aggressive policy for critical operations: more attempts, faster retries
|
||||
static RetryPolicy Aggressive()
|
||||
{
|
||||
return RetryPolicy{5, std::chrono::milliseconds(50), 1.5, std::chrono::milliseconds(2000)};
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
// Retry a function with exponential backoff for transient errors
|
||||
// Returns true on success, false on permanent failure or exhausted retries
|
||||
// The function `fn` should return true on success, false on failure, and set errno on failure
|
||||
template<typename Func>
|
||||
bool
|
||||
RetryOnTransientError(Func fn, const RetryPolicy &policy, std::string &err)
|
||||
{
|
||||
std::size_t attempt = 0;
|
||||
std::chrono::milliseconds delay = policy.initial_delay;
|
||||
|
||||
while (attempt < policy.max_attempts) {
|
||||
++attempt;
|
||||
errno = 0;
|
||||
if (fn()) {
|
||||
return true; // Success
|
||||
}
|
||||
|
||||
int saved_errno = errno;
|
||||
if (!IsTransientError(saved_errno)) {
|
||||
// Permanent error, don't retry
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attempt >= policy.max_attempts) {
|
||||
// Exhausted retries
|
||||
err += " (exhausted " + std::to_string(policy.max_attempts) + " retry attempts)";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sleep before retry
|
||||
std::this_thread::sleep_for(delay);
|
||||
|
||||
// Exponential backoff
|
||||
delay = std::chrono::milliseconds(
|
||||
static_cast<long long>(delay.count() * policy.backoff_multiplier)
|
||||
);
|
||||
if (delay > policy.max_delay) {
|
||||
delay = policy.max_delay;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// CircuitBreaker prevents repeated attempts to failing operations
|
||||
// States: Closed (normal), Open (failing, reject immediately), HalfOpen (testing recovery)
|
||||
class CircuitBreaker {
|
||||
public:
|
||||
enum class State {
|
||||
Closed, // Normal operation, allow all requests
|
||||
Open, // Failing, reject requests immediately
|
||||
HalfOpen // Testing recovery, allow limited requests
|
||||
};
|
||||
|
||||
struct Config {
|
||||
std::size_t failure_threshold; // Failures before opening circuit
|
||||
std::chrono::seconds open_timeout; // Time before attempting recovery (Open → HalfOpen)
|
||||
std::size_t success_threshold; // Successes in HalfOpen before closing
|
||||
std::chrono::seconds window; // Time window for counting failures
|
||||
|
||||
Config()
|
||||
: failure_threshold(5), open_timeout(30), success_threshold(2), window(60) {}
|
||||
};
|
||||
|
||||
|
||||
explicit CircuitBreaker(const Config &cfg = Config());
|
||||
|
||||
|
||||
// Check if operation is allowed (returns false if circuit is Open)
|
||||
bool AllowRequest();
|
||||
|
||||
// Record successful operation
|
||||
void RecordSuccess();
|
||||
|
||||
// Record failed operation
|
||||
void RecordFailure();
|
||||
|
||||
// Get current state
|
||||
State GetState() const
|
||||
{
|
||||
return state_;
|
||||
}
|
||||
|
||||
|
||||
// Get failure count in current window
|
||||
std::size_t GetFailureCount() const
|
||||
{
|
||||
return failure_count_;
|
||||
}
|
||||
|
||||
|
||||
// Reset circuit to Closed state (for testing or manual intervention)
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
void TransitionTo(State new_state);
|
||||
|
||||
bool IsWindowExpired() const;
|
||||
|
||||
Config config_;
|
||||
State state_;
|
||||
std::size_t failure_count_;
|
||||
std::size_t success_count_;
|
||||
std::chrono::steady_clock::time_point last_failure_time_;
|
||||
std::chrono::steady_clock::time_point state_change_time_;
|
||||
mutable std::mutex mtx_;
|
||||
};
|
||||
} // namespace kte
|
||||
+2
-2
@@ -12,11 +12,11 @@ public:
|
||||
virtual ~Frontend() = default;
|
||||
|
||||
// Initialize the frontend (create window/terminal, etc.)
|
||||
virtual bool Init(Editor &ed) = 0;
|
||||
virtual bool Init(int &argc, char **argv, Editor &ed) = 0;
|
||||
|
||||
// Execute one iteration (poll input, dispatch, draw). Set running=false to exit.
|
||||
virtual void Step(Editor &ed, bool &running) = 0;
|
||||
|
||||
// Shutdown/cleanup
|
||||
virtual void Shutdown() = 0;
|
||||
};
|
||||
};
|
||||
|
||||
+152
-13
@@ -1,11 +1,49 @@
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
|
||||
#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
|
||||
trim(std::string &s)
|
||||
@@ -19,37 +57,124 @@ trim(std::string &s)
|
||||
|
||||
|
||||
static std::string
|
||||
default_config_path()
|
||||
config_dir()
|
||||
{
|
||||
const char *home = std::getenv("HOME");
|
||||
if (!home || !*home)
|
||||
return {};
|
||||
std::string path(home);
|
||||
path += "/.config/kte/kge.ini";
|
||||
return path;
|
||||
return std::string(home) + "/.config/kte";
|
||||
}
|
||||
|
||||
|
||||
GUIConfig
|
||||
GUIConfig::Load()
|
||||
{
|
||||
GUIConfig cfg; // defaults already set
|
||||
const std::string path = default_config_path();
|
||||
GUIConfig cfg;
|
||||
std::string dir = config_dir();
|
||||
if (dir.empty())
|
||||
return cfg;
|
||||
|
||||
if (!path.empty()) {
|
||||
cfg.LoadFromFile(path);
|
||||
// Try TOML first
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
if (!in.good())
|
||||
return false;
|
||||
|
||||
bool explicit_code_font = false;
|
||||
bool explicit_writing_font = false;
|
||||
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
// Remove comments starting with '#' or ';'
|
||||
@@ -85,25 +210,31 @@ GUIConfig::LoadFromFile(const std::string &path)
|
||||
try {
|
||||
v = std::stoi(val);
|
||||
} catch (...) {}
|
||||
if (v > 0)
|
||||
if (valid_dimension(v))
|
||||
columns = v;
|
||||
} else if (key == "rows") {
|
||||
int v = rows;
|
||||
try {
|
||||
v = std::stoi(val);
|
||||
} catch (...) {}
|
||||
if (v > 0)
|
||||
if (valid_dimension(v))
|
||||
rows = v;
|
||||
} else if (key == "font_size" || key == "fontsize") {
|
||||
float v = font_size;
|
||||
try {
|
||||
v = std::stof(val);
|
||||
} catch (...) {}
|
||||
if (v > 0.0f) {
|
||||
if (valid_font_size(v)) {
|
||||
font_size = v;
|
||||
}
|
||||
} else if (key == "font") {
|
||||
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") {
|
||||
theme = val;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-7
@@ -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
|
||||
|
||||
@@ -22,12 +24,18 @@ public:
|
||||
std::string background = "dark";
|
||||
|
||||
// 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; // default: enabled
|
||||
bool syntax = true;
|
||||
|
||||
// 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();
|
||||
|
||||
// Load from explicit path. Returns true if file existed and was parsed.
|
||||
bool LoadFromFile(const std::string &path);
|
||||
};
|
||||
// Load from explicit TOML path. Returns true if file existed and was parsed.
|
||||
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);
|
||||
};
|
||||
|
||||
-367
@@ -1,367 +0,0 @@
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <SDL.h>
|
||||
#include <SDL_opengl.h>
|
||||
#include <backends/imgui_impl_opengl3.h>
|
||||
#include <backends/imgui_impl_sdl2.h>
|
||||
|
||||
#include "GUIFrontend.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "GUITheme.h"
|
||||
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
||||
#include "fonts/FontRegistry.h"
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
#include "syntax/NullHighlighter.h"
|
||||
|
||||
|
||||
#ifndef KTE_FONT_SIZE
|
||||
#define KTE_FONT_SIZE 16.0f
|
||||
#endif
|
||||
|
||||
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
|
||||
|
||||
bool
|
||||
GUIFrontend::Init(Editor &ed)
|
||||
{
|
||||
(void) ed; // editor dimensions will be initialized during the first Step() frame
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
|
||||
GUIConfig cfg = GUIConfig::Load();
|
||||
|
||||
// GL attributes for core profile
|
||||
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_MAJOR_VERSION, 3);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
|
||||
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
||||
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
|
||||
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
|
||||
|
||||
// Compute desired window size from config
|
||||
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
||||
|
||||
if (cfg.fullscreen) {
|
||||
// "Fullscreen": fill the usable bounds of the primary display.
|
||||
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
width_ = usable.w;
|
||||
height_ = usable.h;
|
||||
}
|
||||
#if !defined(__APPLE__)
|
||||
// Non-macOS: desktop fullscreen uses the current display resolution.
|
||||
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
|
||||
#endif
|
||||
} else {
|
||||
// Windowed: width = columns * font_size, height = (rows * 2) * font_size
|
||||
int w = cfg.columns * static_cast<int>(cfg.font_size);
|
||||
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{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
w = std::min(w, usable.w);
|
||||
h = std::min(h, usable.h);
|
||||
}
|
||||
width_ = std::max(320, w);
|
||||
height_ = std::max(200, h);
|
||||
}
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||
window_ = SDL_CreateWindow(
|
||||
"kge - kyle's graphical editor " KTE_VERSION_STR,
|
||||
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
width_, height_,
|
||||
win_flags);
|
||||
if (!window_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SDL_EnableScreenSaver();
|
||||
|
||||
#if defined(__APPLE__)
|
||||
// macOS: when "fullscreen" is requested, position the window at the
|
||||
// top-left of the usable display area to mimic fullscreen while keeping
|
||||
// the system menu bar visible.
|
||||
if (cfg.fullscreen) {
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
SDL_SetWindowPosition(window_, usable.x, usable.y);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
gl_ctx_ = SDL_GL_CreateContext(window_);
|
||||
if (!gl_ctx_)
|
||||
return false;
|
||||
SDL_GL_MakeCurrent(window_, gl_ctx_);
|
||||
SDL_GL_SetSwapInterval(1); // vsync
|
||||
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
|
||||
// Set custom ini filename path to ~/.config/kte/imgui.ini
|
||||
if (const char *home = std::getenv("HOME")) {
|
||||
namespace fs = std::filesystem;
|
||||
fs::path config_dir = fs::path(home) / ".config" / "kte";
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::exists(config_dir)) {
|
||||
fs::create_directories(config_dir, ec);
|
||||
}
|
||||
|
||||
if (fs::exists(config_dir)) {
|
||||
static std::string ini_path = (config_dir / "imgui.ini").string();
|
||||
io.IniFilename = ini_path.c_str();
|
||||
}
|
||||
}
|
||||
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
|
||||
ImGui::StyleColorsDark();
|
||||
|
||||
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands.
|
||||
if (cfg.background == "light")
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
||||
else
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
||||
kte::ApplyThemeByName(cfg.theme);
|
||||
|
||||
// Apply default syntax highlighting preference from GUI config to the current buffer
|
||||
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_))
|
||||
return false;
|
||||
if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
|
||||
return false;
|
||||
|
||||
// Cache initial window size; logical rows/cols will be computed in Step() once a valid ImGui frame exists
|
||||
int w, h;
|
||||
SDL_GetWindowSize(window_, &w, &h);
|
||||
width_ = w;
|
||||
height_ = h;
|
||||
|
||||
#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) {
|
||||
SDL_SetWindowSize(window_, w - 1, h - 1);
|
||||
SDL_SetWindowSize(window_, w, h);
|
||||
// Update cached size in case backend reports immediately
|
||||
SDL_GetWindowSize(window_, &w, &h);
|
||||
width_ = w;
|
||||
height_ = h;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Install embedded fonts into registry and load configured font
|
||||
kte::Fonts::InstallDefaultFonts();
|
||||
// Initialize font atlas using configured font name and size; fallback to embedded default helper
|
||||
if (!kte::Fonts::FontRegistry::Instance().LoadFont(cfg.font, (float) cfg.font_size)) {
|
||||
LoadGuiFont_(nullptr, (float) cfg.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;
|
||||
float s = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) {
|
||||
kte::Fonts::FontRegistry::Instance().LoadFont(n, s);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GUIFrontend::Step(Editor &ed, bool &running)
|
||||
{
|
||||
SDL_Event e;
|
||||
while (SDL_PollEvent(&e)) {
|
||||
ImGui_ImplSDL2_ProcessEvent(&e);
|
||||
switch (e.type) {
|
||||
case SDL_QUIT:
|
||||
running = false;
|
||||
break;
|
||||
case SDL_WINDOWEVENT:
|
||||
if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
|
||||
width_ = e.window.data1;
|
||||
height_ = e.window.data2;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Map input to commands
|
||||
input_.ProcessSDLEvent(e);
|
||||
}
|
||||
|
||||
// Apply pending font change before starting a new frame
|
||||
{
|
||||
std::string fname;
|
||||
float fsize = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
|
||||
if (!fname.empty() && fsize > 0.0f) {
|
||||
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
|
||||
// Recreate backend font texture
|
||||
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
||||
ImGui_ImplOpenGL3_CreateFontsTexture();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start a new ImGui frame BEFORE processing commands so dimensions are correct
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplSDL2_NewFrame(window_);
|
||||
ImGui::NewFrame();
|
||||
|
||||
// Update editor logical rows/cols using current ImGui metrics and display size
|
||||
{
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
float line_h = ImGui::GetTextLineHeightWithSpacing();
|
||||
float ch_w = ImGui::CalcTextSize("M").x;
|
||||
if (line_h <= 0.0f)
|
||||
line_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 GUIRenderer
|
||||
const ImGuiStyle &style = ImGui::GetStyle();
|
||||
float pad_x = style.WindowPadding.x;
|
||||
float pad_y = style.WindowPadding.y;
|
||||
// Status bar reserves one frame height (with spacing) inside the window
|
||||
float status_h = ImGui::GetFrameHeightWithSpacing();
|
||||
|
||||
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
|
||||
float avail_h = std::max(0.0f, disp_h - 2.0f * pad_y - status_h);
|
||||
|
||||
// Visible content rows inside the scroll child
|
||||
auto content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h));
|
||||
// Editor::Rows includes the status line; add 1 back for it.
|
||||
std::size_t rows = std::max<std::size_t>(1, content_rows + 1);
|
||||
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
||||
|
||||
// Only update if changed to avoid churn
|
||||
if (rows != ed.Rows() || cols != ed.Cols()) {
|
||||
ed.SetDimensions(rows, cols);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
if (!input_.Poll(mi))
|
||||
break;
|
||||
if (mi.hasCommand) {
|
||||
// Track kill ring before and after to sync GUI clipboard when it changes
|
||||
const std::string before = ed.KillRingHead();
|
||||
Execute(ed, mi.id, mi.arg, mi.count);
|
||||
const std::string after = ed.KillRingHead();
|
||||
if (after != before && !after.empty()) {
|
||||
// Update the system clipboard to mirror the kill ring head in GUI
|
||||
SDL_SetClipboardText(after.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ed.QuitRequested()) {
|
||||
running = false;
|
||||
}
|
||||
|
||||
// No runtime font UI; always use embedded font.
|
||||
|
||||
// Draw editor UI
|
||||
renderer_.Draw(ed);
|
||||
|
||||
// Render
|
||||
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
|
||||
GUIFrontend::Shutdown()
|
||||
{
|
||||
ImGui_ImplOpenGL3_Shutdown();
|
||||
ImGui_ImplSDL2_Shutdown();
|
||||
ImGui::DestroyContext();
|
||||
|
||||
if (gl_ctx_) {
|
||||
SDL_GL_DeleteContext(gl_ctx_);
|
||||
gl_ctx_ = nullptr;
|
||||
}
|
||||
if (window_) {
|
||||
SDL_DestroyWindow(window_);
|
||||
window_ = nullptr;
|
||||
}
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
||||
{
|
||||
const ImGuiIO &io = ImGui::GetIO();
|
||||
io.Fonts->Clear();
|
||||
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
kte::Fonts::DefaultFontData,
|
||||
kte::Fonts::DefaultFontSize,
|
||||
size_px);
|
||||
if (!font) {
|
||||
font = io.Fonts->AddFontDefault();
|
||||
}
|
||||
(void) font;
|
||||
io.Fonts->Build();
|
||||
return true;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* GUIFrontend - couples GUIInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
|
||||
*/
|
||||
#pragma once
|
||||
#include "Frontend.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "GUIInputHandler.h"
|
||||
#include "GUIRenderer.h"
|
||||
|
||||
|
||||
struct SDL_Window;
|
||||
typedef void *SDL_GLContext;
|
||||
|
||||
class GUIFrontend final : public Frontend {
|
||||
public:
|
||||
GUIFrontend() = default;
|
||||
|
||||
~GUIFrontend() override = default;
|
||||
|
||||
bool Init(Editor &ed) override;
|
||||
|
||||
void Step(Editor &ed, bool &running) override;
|
||||
|
||||
void Shutdown() override;
|
||||
|
||||
private:
|
||||
static bool LoadGuiFont_(const char *path, float size_px);
|
||||
|
||||
GUIConfig config_{};
|
||||
GUIInputHandler input_{};
|
||||
GUIRenderer renderer_{};
|
||||
SDL_Window *window_ = nullptr;
|
||||
SDL_GLContext gl_ctx_ = nullptr;
|
||||
int width_ = 1280;
|
||||
int height_ = 800;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
* GUIRenderer - ImGui-based renderer for GUI mode
|
||||
*/
|
||||
#pragma once
|
||||
#include "Renderer.h"
|
||||
|
||||
class GUIRenderer final : public Renderer {
|
||||
public:
|
||||
GUIRenderer() = default;
|
||||
|
||||
~GUIRenderer() override = default;
|
||||
|
||||
void Draw(Editor &ed) override;
|
||||
};
|
||||
+196
-22
@@ -1,11 +1,17 @@
|
||||
// GUITheme.h — ImGui theming helpers and background mode
|
||||
// GUITheme.h — theming helpers and background mode
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
#include "Highlight.h"
|
||||
|
||||
#include <imgui.h>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
@@ -16,7 +22,7 @@ namespace kte {
|
||||
enum class BackgroundMode { Light, Dark };
|
||||
|
||||
// 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)
|
||||
enum class ThemeId {
|
||||
@@ -34,11 +40,13 @@ enum class ThemeId {
|
||||
Amber = 10,
|
||||
WeylandYutani = 11,
|
||||
Orbital = 12,
|
||||
Tufte = 13,
|
||||
Leuchtturm = 14,
|
||||
};
|
||||
|
||||
// Current theme tracking
|
||||
static inline auto gCurrentTheme = ThemeId::Nord;
|
||||
static inline std::size_t gCurrentThemeIndex = 0;
|
||||
inline auto gCurrentTheme = ThemeId::Nord;
|
||||
inline std::size_t gCurrentThemeIndex = 7; // Nord index
|
||||
|
||||
// Forward declarations for helpers used below
|
||||
static size_t ThemeIndexFromId(ThemeId id);
|
||||
@@ -76,11 +84,13 @@ BackgroundModeName()
|
||||
#include "themes/Everforest.h"
|
||||
#include "themes/KanagawaPaper.h"
|
||||
#include "themes/LCARS.h"
|
||||
#include "themes/Leuchtturm.h"
|
||||
#include "themes/OldBook.h"
|
||||
#include "themes/Amber.h"
|
||||
#include "themes/WeylandYutani.h"
|
||||
#include "themes/Zenburn.h"
|
||||
#include "themes/Orbital.h"
|
||||
#include "themes/Tufte.h"
|
||||
|
||||
|
||||
// Theme abstraction and registry (generalized theme system)
|
||||
@@ -113,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 {
|
||||
[[nodiscard]] const char *Name() const override
|
||||
{
|
||||
@@ -192,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 {
|
||||
[[nodiscard]] const char *Name() const override
|
||||
{
|
||||
@@ -361,18 +415,20 @@ ThemeRegistry()
|
||||
static std::vector<std::unique_ptr<Theme> > reg;
|
||||
if (reg.empty()) {
|
||||
// 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::EInkTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::EverforestTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::GruvboxTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::KanagawaPaperTheme>());
|
||||
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::OldBookTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::OrbitalTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::Plan9Theme>());
|
||||
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::ZenburnTheme>());
|
||||
}
|
||||
@@ -549,20 +605,24 @@ ThemeIndexFromId(const ThemeId id)
|
||||
return 4;
|
||||
case ThemeId::LCARS:
|
||||
return 5;
|
||||
case ThemeId::Nord:
|
||||
case ThemeId::Leuchtturm:
|
||||
return 6;
|
||||
case ThemeId::OldBook:
|
||||
case ThemeId::Nord:
|
||||
return 7;
|
||||
case ThemeId::Orbital:
|
||||
case ThemeId::OldBook:
|
||||
return 8;
|
||||
case ThemeId::Plan9:
|
||||
case ThemeId::Orbital:
|
||||
return 9;
|
||||
case ThemeId::Solarized:
|
||||
case ThemeId::Plan9:
|
||||
return 10;
|
||||
case ThemeId::WeylandYutani:
|
||||
case ThemeId::Solarized:
|
||||
return 11;
|
||||
case ThemeId::Zenburn:
|
||||
case ThemeId::Tufte:
|
||||
return 12;
|
||||
case ThemeId::WeylandYutani:
|
||||
return 13;
|
||||
case ThemeId::Zenburn:
|
||||
return 14;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -586,30 +646,144 @@ ThemeIdFromIndex(const size_t idx)
|
||||
case 5:
|
||||
return ThemeId::LCARS;
|
||||
case 6:
|
||||
return ThemeId::Nord;
|
||||
return ThemeId::Leuchtturm;
|
||||
case 7:
|
||||
return ThemeId::OldBook;
|
||||
return ThemeId::Nord;
|
||||
case 8:
|
||||
return ThemeId::Orbital;
|
||||
return ThemeId::OldBook;
|
||||
case 9:
|
||||
return ThemeId::Plan9;
|
||||
return ThemeId::Orbital;
|
||||
case 10:
|
||||
return ThemeId::Solarized;
|
||||
return ThemeId::Plan9;
|
||||
case 11:
|
||||
return ThemeId::WeylandYutani;
|
||||
return ThemeId::Solarized;
|
||||
case 12:
|
||||
return ThemeId::Tufte;
|
||||
case 13:
|
||||
return ThemeId::WeylandYutani;
|
||||
case 14:
|
||||
return ThemeId::Zenburn;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- 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
|
||||
SyntaxInk(const TokenKind k)
|
||||
{
|
||||
// Basic palettes for dark/light backgrounds; tuned for Nord-ish defaults
|
||||
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);
|
||||
switch (k) {
|
||||
case TokenKind::Keyword:
|
||||
@@ -644,4 +818,4 @@ SyntaxInk(const TokenKind k)
|
||||
return def;
|
||||
}
|
||||
}
|
||||
} // namespace kte
|
||||
} // namespace kte
|
||||
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
|
||||
|
||||
GapBuffer::GapBuffer() = default;
|
||||
|
||||
|
||||
GapBuffer::GapBuffer(std::size_t initialCapacity)
|
||||
: buffer_(nullptr), size_(0), capacity_(0)
|
||||
{
|
||||
if (initialCapacity > 0) {
|
||||
Reserve(initialCapacity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
GapBuffer::GapBuffer(const GapBuffer &other)
|
||||
: buffer_(nullptr), size_(0), capacity_(0)
|
||||
{
|
||||
if (other.capacity_ > 0) {
|
||||
Reserve(other.capacity_);
|
||||
if (other.size_ > 0) {
|
||||
std::memcpy(buffer_, other.buffer_, other.size_);
|
||||
size_ = other.size_;
|
||||
}
|
||||
setTerminator();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
GapBuffer &
|
||||
GapBuffer::operator=(const GapBuffer &other)
|
||||
{
|
||||
if (this == &other)
|
||||
return *this;
|
||||
if (other.capacity_ > capacity_) {
|
||||
Reserve(other.capacity_);
|
||||
}
|
||||
if (other.size_ > 0) {
|
||||
std::memcpy(buffer_, other.buffer_, other.size_);
|
||||
}
|
||||
size_ = other.size_;
|
||||
setTerminator();
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
GapBuffer::GapBuffer(GapBuffer &&other) noexcept
|
||||
: buffer_(other.buffer_), size_(other.size_), capacity_(other.capacity_)
|
||||
{
|
||||
other.buffer_ = nullptr;
|
||||
other.size_ = 0;
|
||||
other.capacity_ = 0;
|
||||
}
|
||||
|
||||
|
||||
GapBuffer &
|
||||
GapBuffer::operator=(GapBuffer &&other) noexcept
|
||||
{
|
||||
if (this == &other)
|
||||
return *this;
|
||||
delete[] buffer_;
|
||||
buffer_ = other.buffer_;
|
||||
size_ = other.size_;
|
||||
capacity_ = other.capacity_;
|
||||
other.buffer_ = nullptr;
|
||||
other.size_ = 0;
|
||||
other.capacity_ = 0;
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
GapBuffer::~GapBuffer()
|
||||
{
|
||||
delete[] buffer_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Reserve(const std::size_t newCapacity)
|
||||
{
|
||||
if (newCapacity <= capacity_) [[likely]]
|
||||
return;
|
||||
// Allocate space for terminator as well
|
||||
char *nb = new char[newCapacity + 1];
|
||||
if (size_ > 0 && buffer_) {
|
||||
std::memcpy(nb, buffer_, size_);
|
||||
}
|
||||
delete[] buffer_;
|
||||
buffer_ = nb;
|
||||
capacity_ = newCapacity;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::AppendChar(const char c)
|
||||
{
|
||||
ensureCapacityFor(1);
|
||||
buffer_[size_++] = c;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Append(const char *s, const std::size_t len)
|
||||
{
|
||||
if (!s || len == 0) [[unlikely]]
|
||||
return;
|
||||
ensureCapacityFor(len);
|
||||
std::memcpy(buffer_ + size_, s, len);
|
||||
size_ += len;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Append(const GapBuffer &other)
|
||||
{
|
||||
if (other.size_ == 0)
|
||||
return;
|
||||
Append(other.buffer_, other.size_);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::PrependChar(char c)
|
||||
{
|
||||
ensureCapacityFor(1);
|
||||
// shift right by 1
|
||||
if (size_ > 0) [[likely]] {
|
||||
std::memmove(buffer_ + 1, buffer_, size_);
|
||||
}
|
||||
buffer_[0] = c;
|
||||
++size_;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Prepend(const char *s, std::size_t len)
|
||||
{
|
||||
if (!s || len == 0) [[unlikely]]
|
||||
return;
|
||||
ensureCapacityFor(len);
|
||||
if (size_ > 0) [[likely]] {
|
||||
std::memmove(buffer_ + len, buffer_, size_);
|
||||
}
|
||||
std::memcpy(buffer_, s, len);
|
||||
size_ += len;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Prepend(const GapBuffer &other)
|
||||
{
|
||||
if (other.size_ == 0)
|
||||
return;
|
||||
Prepend(other.buffer_, other.size_);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Clear()
|
||||
{
|
||||
size_ = 0;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::ensureCapacityFor(std::size_t delta)
|
||||
{
|
||||
if (capacity_ - size_ >= delta) [[likely]]
|
||||
return;
|
||||
auto required = size_ + delta;
|
||||
Reserve(growCapacity(capacity_, required));
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
GapBuffer::growCapacity(std::size_t current, std::size_t required)
|
||||
{
|
||||
// geometric growth, at least required
|
||||
std::size_t newCap = current ? current : 8;
|
||||
while (newCap < required)
|
||||
newCap = newCap + (newCap >> 1); // 1.5x growth
|
||||
return newCap;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::setTerminator() const
|
||||
{
|
||||
if (!buffer_) {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer_[size_] = '\0';
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* GapBuffer.h - C++ replacement for abuf append/prepend buffer utilities
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
|
||||
|
||||
class GapBuffer {
|
||||
public:
|
||||
GapBuffer();
|
||||
|
||||
explicit GapBuffer(std::size_t initialCapacity);
|
||||
|
||||
GapBuffer(const GapBuffer &other);
|
||||
|
||||
GapBuffer &operator=(const GapBuffer &other);
|
||||
|
||||
GapBuffer(GapBuffer &&other) noexcept;
|
||||
|
||||
GapBuffer &operator=(GapBuffer &&other) noexcept;
|
||||
|
||||
~GapBuffer();
|
||||
|
||||
void Reserve(std::size_t newCapacity);
|
||||
|
||||
|
||||
void AppendChar(char c);
|
||||
|
||||
void Append(const char *s, std::size_t len);
|
||||
|
||||
void Append(const GapBuffer &other);
|
||||
|
||||
void PrependChar(char c);
|
||||
|
||||
void Prepend(const char *s, std::size_t len);
|
||||
|
||||
void Prepend(const GapBuffer &other);
|
||||
|
||||
// Content management
|
||||
void Clear();
|
||||
|
||||
// Accessors
|
||||
char *Data()
|
||||
{
|
||||
return buffer_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] const char *Data() const
|
||||
{
|
||||
return buffer_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t Size() const
|
||||
{
|
||||
return size_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t Capacity() const
|
||||
{
|
||||
return capacity_;
|
||||
}
|
||||
|
||||
private:
|
||||
void ensureCapacityFor(std::size_t delta);
|
||||
|
||||
static std::size_t growCapacity(std::size_t current, std::size_t required);
|
||||
|
||||
void setTerminator() const;
|
||||
|
||||
char *buffer_ = nullptr;
|
||||
std::size_t size_ = 0; // number of valid bytes (excluding terminator)
|
||||
std::size_t capacity_ = 0; // capacity of buffer_ excluding space for terminator
|
||||
};
|
||||
+35
-5
@@ -22,9 +22,12 @@ HelpText::Text()
|
||||
" C-k ' Toggle read-only\n"
|
||||
" C-k - Unindent region (mark required)\n"
|
||||
" C-k = Indent region (mark required)\n"
|
||||
" C-k / Toggle visual line mode\n"
|
||||
" C-k ; Command prompt (:\\ )\n"
|
||||
" C-k SPACE Toggle mark\n"
|
||||
" C-k C-d Kill entire line\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 a Mark start of file, jump to end\n"
|
||||
" C-k b Switch buffer\n"
|
||||
@@ -34,8 +37,11 @@ HelpText::Text()
|
||||
" C-k f Flush kill ring\n"
|
||||
" C-k g Jump to line\n"
|
||||
" C-k h Show this help\n"
|
||||
" C-k i New empty buffer\n"
|
||||
" C-k j Jump to mark\n"
|
||||
" C-k k Center viewport on cursor\n"
|
||||
" C-k l Reload buffer from disk\n"
|
||||
" C-k m Toggle edit mode (code/writing)\n"
|
||||
" C-k n Previous buffer\n"
|
||||
" C-k o Change working directory (prompt)\n"
|
||||
" C-k p Next buffer\n"
|
||||
@@ -59,6 +65,10 @@ HelpText::Text()
|
||||
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
|
||||
" ESC q Reflow paragraph\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"
|
||||
" C-a C-e Line start / end\n"
|
||||
" C-b C-f Move left / right\n"
|
||||
@@ -70,12 +80,32 @@ HelpText::Text()
|
||||
" C-t Regex search & replace\n"
|
||||
" C-h Search & replace\n"
|
||||
" C-l / C-g Refresh / Cancel\n"
|
||||
" C-u [digits] Universal argument (repeat count)\n"
|
||||
"\n"
|
||||
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
|
||||
"\n"
|
||||
"GUI appearance (command prompt):\n"
|
||||
" : theme NAME Set GUI theme (amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, plan9, solarized, weyland-yutani, zenburn)\n"
|
||||
" : background MODE Set background: light | dark (affects eink, gruvbox, old-book, solarized)\n"
|
||||
"Edit modes:\n"
|
||||
" code Monospace font (default for source files)\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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ public:
|
||||
// Project maintainers can customize the returned string below
|
||||
// (in HelpText.cc) without touching the help command logic.
|
||||
static std::string Text();
|
||||
};
|
||||
};
|
||||
|
||||
+43
-1
@@ -1,7 +1,10 @@
|
||||
// Highlight.h - core syntax highlighting types for kte
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace kte {
|
||||
@@ -34,4 +37,43 @@ struct LineHighlight {
|
||||
std::vector<HighlightSpan> spans;
|
||||
std::uint64_t version{0}; // buffer version used for this line
|
||||
};
|
||||
} // namespace kte
|
||||
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <SDL.h>
|
||||
#include <SDL_opengl.h>
|
||||
#include <backends/imgui_impl_opengl3.h>
|
||||
#include <backends/imgui_impl_sdl2.h>
|
||||
|
||||
#include "ImGuiFrontend.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "GUITheme.h"
|
||||
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
||||
#include "fonts/FontRegistry.h"
|
||||
#include "fonts/IosevkaExtended.h"
|
||||
|
||||
|
||||
#ifndef KTE_FONT_SIZE
|
||||
#define KTE_FONT_SIZE 16.0f
|
||||
#endif
|
||||
|
||||
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
|
||||
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
{
|
||||
(void) argc;
|
||||
(void) argv;
|
||||
|
||||
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
|
||||
config_ = GUIConfig::Load();
|
||||
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// GL attributes for core profile
|
||||
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_MAJOR_VERSION, 3);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
|
||||
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
||||
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
|
||||
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
|
||||
|
||||
// Compute desired window size from config
|
||||
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
||||
|
||||
int init_w = 1280, init_h = 800;
|
||||
if (config_.fullscreen) {
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
init_w = usable.w;
|
||||
init_h = usable.h;
|
||||
}
|
||||
#if !defined(__APPLE__)
|
||||
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
|
||||
#endif
|
||||
} else {
|
||||
int w = config_.columns * static_cast<int>(config_.font_size);
|
||||
int h = config_.rows * static_cast<int>(config_.font_size * 1.2);
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
w = std::min(w, usable.w);
|
||||
h = std::min(h, usable.h);
|
||||
}
|
||||
init_w = std::max(320, w);
|
||||
init_h = std::max(200, h);
|
||||
}
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||
SDL_Window *win = SDL_CreateWindow(
|
||||
"kge - kyle's graphical editor " KTE_VERSION_STR,
|
||||
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
init_w, init_h,
|
||||
win_flags);
|
||||
if (!win) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SDL_EnableScreenSaver();
|
||||
|
||||
#if defined(__APPLE__)
|
||||
if (config_.fullscreen) {
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
SDL_SetWindowPosition(win, usable.x, usable.y);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
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); // vsync
|
||||
|
||||
// Create primary ImGui context
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGuiContext *imgui_ctx = ImGui::CreateContext();
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
|
||||
// Set custom ini filename path to ~/.config/kte/imgui.ini
|
||||
if (const char *home = std::getenv("HOME")) {
|
||||
namespace fs = std::filesystem;
|
||||
fs::path config_dir = fs::path(home) / ".config" / "kte";
|
||||
std::error_code ec;
|
||||
if (!fs::exists(config_dir)) {
|
||||
fs::create_directories(config_dir, ec);
|
||||
}
|
||||
if (fs::exists(config_dir)) {
|
||||
static std::string ini_path = (config_dir / "imgui.ini").string();
|
||||
io.IniFilename = ini_path.c_str();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
apply_syntax_to_buffer(ed.CurrentBuffer(), config_);
|
||||
|
||||
if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx))
|
||||
return false;
|
||||
if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
|
||||
return false;
|
||||
|
||||
// Cache initial window size
|
||||
int w, h;
|
||||
SDL_GetWindowSize(win, &w, &h);
|
||||
init_w = w;
|
||||
init_h = h;
|
||||
|
||||
#if defined(__APPLE__)
|
||||
if (w > 1 && h > 1) {
|
||||
SDL_SetWindowSize(win, w - 1, h - 1);
|
||||
SDL_SetWindowSize(win, w, h);
|
||||
SDL_GetWindowSize(win, &w, &h);
|
||||
init_w = w;
|
||||
init_h = h;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Install embedded fonts
|
||||
kte::Fonts::InstallDefaultFonts();
|
||||
if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
|
||||
LoadGuiFont_(nullptr, (float) config_.font_size);
|
||||
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) config_.font_size);
|
||||
std::string n;
|
||||
float s = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) {
|
||||
kte::Fonts::FontRegistry::Instance().LoadFont(n, s);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
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;
|
||||
while (SDL_PollEvent(&e)) {
|
||||
// Determine which window this event belongs to
|
||||
Uint32 event_win_id = 0;
|
||||
switch (e.type) {
|
||||
case SDL_WINDOWEVENT:
|
||||
event_win_id = e.window.windowID;
|
||||
break;
|
||||
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;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!running)
|
||||
return;
|
||||
|
||||
// --- Apply pending font change (to all contexts) ---
|
||||
{
|
||||
std::string fname;
|
||||
float fsize = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
|
||||
if (!fname.empty() && fsize > 0.0f) {
|
||||
for (auto &ws : windows_) {
|
||||
if (!ws->alive || !ws->imgui_ctx)
|
||||
continue;
|
||||
ImGui::SetCurrentContext(ws->imgui_ctx);
|
||||
SDL_GL_MakeCurrent(ws->window, ws->gl_ctx);
|
||||
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
|
||||
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
||||
ImGui_ImplOpenGL3_CreateFontsTexture();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step each window ---
|
||||
// We iterate by index because OpenNewWindow_ may append to windows_.
|
||||
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
|
||||
WindowState &ws = *windows_[wi];
|
||||
if (!ws.alive)
|
||||
continue;
|
||||
|
||||
Editor &wed = (wi == 0) ? ed : ws.editor;
|
||||
|
||||
// Shared buffer list may have been modified by another window.
|
||||
wed.ValidateBufferIndex();
|
||||
|
||||
// Activate this window's GL and ImGui contexts
|
||||
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
|
||||
ImGui::SetCurrentContext(ws.imgui_ctx);
|
||||
|
||||
// Start a new ImGui frame
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplSDL2_NewFrame(ws.window);
|
||||
ImGui::NewFrame();
|
||||
|
||||
// Update editor dimensions
|
||||
{
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(ws.width);
|
||||
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(ws.height);
|
||||
update_editor_dimensions(wed, disp_w, disp_h);
|
||||
}
|
||||
|
||||
// Allow deferred opens
|
||||
wed.ProcessPendingOpens();
|
||||
|
||||
// Ensure newly opened buffers get syntax + edit mode detection
|
||||
apply_syntax_to_buffer(wed.CurrentBuffer(), config_);
|
||||
|
||||
// Drain input queue
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
if (!ws.input.Poll(mi))
|
||||
break;
|
||||
if (mi.hasCommand) {
|
||||
if (mi.id == CommandId::NewWindow) {
|
||||
// Open a new window; handled after this loop
|
||||
wed.SetNewWindowRequested(true);
|
||||
} 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);
|
||||
}
|
||||
|
||||
// Handle deferred new-window requests (must happen outside the render loop
|
||||
// to avoid corrupting an in-progress ImGui frame).
|
||||
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
|
||||
Editor &wed = (wi == 0) ? ed : windows_[wi]->editor;
|
||||
if (wed.NewWindowRequested()) {
|
||||
wed.SetNewWindowRequested(false);
|
||||
OpenNewWindow_(ed);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead secondary windows
|
||||
for (auto it = windows_.begin() + 1; it != windows_.end();) {
|
||||
if (!(*it)->alive) {
|
||||
DestroyWindowResources_(**it);
|
||||
it = windows_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore primary context
|
||||
if (!windows_.empty()) {
|
||||
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
|
||||
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GUIFrontend::Shutdown()
|
||||
{
|
||||
// Destroy all windows (secondary first, then primary)
|
||||
for (auto it = windows_.rbegin(); it != windows_.rend(); ++it) {
|
||||
DestroyWindowResources_(**it);
|
||||
}
|
||||
windows_.clear();
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
||||
{
|
||||
const ImGuiIO &io = ImGui::GetIO();
|
||||
io.Fonts->Clear();
|
||||
|
||||
ImFontConfig config;
|
||||
config.MergeMode = false;
|
||||
|
||||
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
kte::Fonts::DefaultFontData,
|
||||
kte::Fonts::DefaultFontSize,
|
||||
size_px,
|
||||
&config,
|
||||
io.Fonts->GetGlyphRangesDefault());
|
||||
|
||||
config.MergeMode = true;
|
||||
static const ImWchar extended_ranges[] = {
|
||||
0x0370, 0x03FF, // Greek and Coptic
|
||||
0x2200, 0x22FF, // Mathematical Operators
|
||||
0,
|
||||
};
|
||||
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedData,
|
||||
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedSize,
|
||||
size_px,
|
||||
&config,
|
||||
extended_ranges);
|
||||
|
||||
io.Fonts->Build();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
|
||||
*/
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "Frontend.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "ImGuiInputHandler.h"
|
||||
#include "ImGuiRenderer.h"
|
||||
#include "Editor.h"
|
||||
|
||||
|
||||
struct SDL_Window;
|
||||
struct ImGuiContext;
|
||||
typedef void *SDL_GLContext;
|
||||
|
||||
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:
|
||||
// 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);
|
||||
|
||||
GUIConfig config_{};
|
||||
// Primary window (index 0 in windows_); created during Init.
|
||||
std::vector<std::unique_ptr<WindowState> > windows_;
|
||||
};
|
||||
@@ -5,8 +5,23 @@
|
||||
#include <SDL.h>
|
||||
#include <imgui.h>
|
||||
|
||||
#include "GUIInputHandler.h"
|
||||
#include "ImGuiInputHandler.h"
|
||||
#include "KKeymap.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
|
||||
@@ -14,20 +29,17 @@ map_key(const SDL_Keycode key,
|
||||
const SDL_Keymod mod,
|
||||
bool &k_prefix,
|
||||
bool &esc_meta,
|
||||
// universal-argument state (by ref)
|
||||
bool &uarg_active,
|
||||
bool &uarg_collecting,
|
||||
bool &uarg_negative,
|
||||
bool &uarg_had_digits,
|
||||
int &uarg_value,
|
||||
std::string &uarg_text,
|
||||
MappedInput &out)
|
||||
bool &k_ctrl_pending,
|
||||
Editor *ed,
|
||||
MappedInput &out,
|
||||
bool &suppress_textinput_once)
|
||||
{
|
||||
// Ctrl handling
|
||||
const bool is_ctrl = (mod & KMOD_CTRL) != 0;
|
||||
const bool is_alt = (mod & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0;
|
||||
|
||||
// If previous key was ESC, interpret this as Meta via ESC keymap
|
||||
// If previous key was ESC, interpret this as Meta via ESC keymap.
|
||||
// Prefer KEYDOWN when we can derive a printable ASCII; otherwise defer to TEXTINPUT.
|
||||
if (esc_meta) {
|
||||
int ascii_key = 0;
|
||||
if (key == SDLK_BACKSPACE) {
|
||||
@@ -45,17 +57,18 @@ map_key(const SDL_Keycode key,
|
||||
ascii_key = '>';
|
||||
}
|
||||
if (ascii_key != 0) {
|
||||
esc_meta = false; // consume if we can decide on KEYDOWN
|
||||
ascii_key = KLowerAscii(ascii_key);
|
||||
CommandId id;
|
||||
if (KLookupEscCommand(ascii_key, id)) {
|
||||
// Only consume the ESC-meta prefix if we actually mapped a command
|
||||
esc_meta = false;
|
||||
out = {true, id, "", 0};
|
||||
out = {true, id, "", 0};
|
||||
return true;
|
||||
}
|
||||
// Known printable but unmapped ESC sequence: report invalid
|
||||
out = {true, CommandId::UnknownEscCommand, "", 0};
|
||||
return true;
|
||||
}
|
||||
// Unhandled meta chord at KEYDOWN: do not clear esc_meta here.
|
||||
// Leave it set so SDL_TEXTINPUT fallback can translate and suppress insertion.
|
||||
// No usable ASCII from KEYDOWN → keep esc_meta set and let TEXTINPUT handle it
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
@@ -65,43 +78,53 @@ map_key(const SDL_Keycode key,
|
||||
switch (key) {
|
||||
case SDLK_LEFT:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
return true;
|
||||
case SDLK_RIGHT:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
return true;
|
||||
case SDLK_UP:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
return true;
|
||||
case SDLK_DOWN:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
return true;
|
||||
case SDLK_HOME:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
return true;
|
||||
case SDLK_END:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
return true;
|
||||
case SDLK_PAGEUP:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
return true;
|
||||
case SDLK_PAGEDOWN:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
return true;
|
||||
case SDLK_DELETE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
return true;
|
||||
case SDLK_BACKSPACE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
return true;
|
||||
case SDLK_TAB:
|
||||
// Insert a literal tab character when not interpreting a k-prefix suffix.
|
||||
@@ -114,10 +137,17 @@ map_key(const SDL_Keycode key,
|
||||
break; // fall through so k-prefix handler can process
|
||||
case SDLK_RETURN:
|
||||
case SDLK_KP_ENTER:
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
if (mod & KMOD_SHIFT) {
|
||||
out = {true, CommandId::SmartNewline, "", 0};
|
||||
} else {
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
}
|
||||
return true;
|
||||
case SDLK_ESCAPE:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
esc_meta = true; // next key will be treated as Meta
|
||||
out.hasCommand = false; // no immediate command for bare ESC in GUI
|
||||
return true;
|
||||
@@ -127,7 +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 (k_prefix) {
|
||||
k_prefix = false;
|
||||
// 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;
|
||||
// Normalize to ASCII; preserve case for letters using Shift
|
||||
int ascii_key = 0;
|
||||
@@ -147,74 +194,75 @@ map_key(const SDL_Keycode key,
|
||||
ascii_key = static_cast<int>(key);
|
||||
}
|
||||
bool ctrl2 = (mod & KMOD_CTRL) != 0;
|
||||
// If user typed a literal 'C' (uppercase) or '^' as a control qualifier, keep k-prefix active
|
||||
// Do NOT treat lowercase 'c' as a qualifier; 'c' is a valid k-command (BufferClose).
|
||||
if (ascii_key == 'C' || ascii_key == '^') {
|
||||
k_ctrl_pending = true;
|
||||
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
|
||||
if (ed)
|
||||
ed->SetStatus("C-k C _");
|
||||
suppress_textinput_once = true;
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
// Otherwise, consume the k-prefix now for the actual suffix
|
||||
k_prefix = false;
|
||||
if (ascii_key != 0) {
|
||||
int lower = KLowerAscii(ascii_key);
|
||||
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
|
||||
bool pass_ctrl = ctrl2 && ctrl_suffix_supported;
|
||||
bool pass_ctrl = (ctrl2 || k_ctrl_pending) && ctrl_suffix_supported;
|
||||
k_ctrl_pending = false;
|
||||
CommandId id;
|
||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||
#if IMGUI_IH_DEBUG
|
||||
// Diagnostics for u/U
|
||||
if (lower == 'u') {
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
: '?';
|
||||
std::fprintf(stderr,
|
||||
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
std::fflush(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",
|
||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
}
|
||||
#endif
|
||||
if (mapped) {
|
||||
out = {true, id, "", 0};
|
||||
if (ed)
|
||||
ed->SetStatus(""); // clear "C-k _" hint after suffix
|
||||
return true;
|
||||
}
|
||||
int shown = KLowerAscii(ascii_key);
|
||||
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
|
||||
std::string arg(1, c);
|
||||
out = {true, CommandId::UnknownKCommand, arg, 0};
|
||||
if (ed)
|
||||
ed->SetStatus(""); // clear hint; handler will set unknown status
|
||||
return true;
|
||||
}
|
||||
out.hasCommand = false;
|
||||
// Non-printable/unmappable key as k-suffix (e.g., F-keys): report unknown and exit k-mode
|
||||
out = {true, CommandId::UnknownKCommand, std::string("?"), 0};
|
||||
if (ed)
|
||||
ed->SetStatus("");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_ctrl) {
|
||||
// Universal argument: C-u
|
||||
if (key == SDLK_u) {
|
||||
if (!uarg_active) {
|
||||
uarg_active = true;
|
||||
uarg_collecting = true;
|
||||
uarg_negative = false;
|
||||
uarg_had_digits = false;
|
||||
uarg_value = 4; // default
|
||||
uarg_text.clear();
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
|
||||
if (uarg_value <= 0)
|
||||
uarg_value = 4;
|
||||
else
|
||||
uarg_value *= 4; // repeated C-u multiplies by 4
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
} else {
|
||||
// End collection if already started with digits or '-'
|
||||
uarg_collecting = false;
|
||||
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
|
||||
uarg_value = 4;
|
||||
}
|
||||
if (ed)
|
||||
ed->UArgStart();
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
// Cancel universal arg on C-g as well (it maps to Refresh via ctrl map)
|
||||
if (key == SDLK_g) {
|
||||
uarg_active = false;
|
||||
uarg_collecting = false;
|
||||
uarg_negative = false;
|
||||
uarg_had_digits = false;
|
||||
uarg_value = 0;
|
||||
uarg_text.clear();
|
||||
if (ed)
|
||||
ed->UArgClear();
|
||||
// Also cancel any pending k-prefix qualifier
|
||||
k_ctrl_pending = false;
|
||||
k_prefix = false; // treat as cancel of prefix
|
||||
}
|
||||
if (key == SDLK_k || key == SDLK_KP_EQUALS) {
|
||||
k_prefix = true;
|
||||
@@ -258,29 +306,17 @@ map_key(const SDL_Keycode key,
|
||||
}
|
||||
}
|
||||
|
||||
// If collecting universal argument, allow digits/minus on KEYDOWN path too
|
||||
if (uarg_active && uarg_collecting) {
|
||||
// If collecting universal argument, allow digits on KEYDOWN path too
|
||||
if (ed && ed->UArg() != 0) {
|
||||
if ((key >= SDLK_0 && key <= SDLK_9) && !(mod & KMOD_SHIFT)) {
|
||||
int d = static_cast<int>(key - SDLK_0);
|
||||
if (!uarg_had_digits) {
|
||||
uarg_value = 0;
|
||||
uarg_had_digits = true;
|
||||
}
|
||||
if (uarg_value < 100000000) {
|
||||
uarg_value = uarg_value * 10 + d;
|
||||
}
|
||||
uarg_text.push_back(static_cast<char>('0' + d));
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
ed->UArgDigit(d);
|
||||
out.hasCommand = false;
|
||||
// We consumed a digit on KEYDOWN; SDL will often also emit TEXTINPUT for it.
|
||||
// Request suppression of the very next TEXTINPUT to avoid double-counting.
|
||||
suppress_textinput_once = true;
|
||||
return true;
|
||||
}
|
||||
if (key == SDLK_MINUS && !uarg_had_digits && !uarg_negative) {
|
||||
uarg_negative = true;
|
||||
uarg_text = "-";
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
}
|
||||
// Any other key will end collection; process it normally
|
||||
uarg_collecting = false;
|
||||
}
|
||||
|
||||
// k_prefix handled earlier
|
||||
@@ -290,41 +326,87 @@ map_key(const SDL_Keycode key,
|
||||
|
||||
|
||||
bool
|
||||
GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
{
|
||||
MappedInput mi;
|
||||
bool produced = false;
|
||||
switch (e.type) {
|
||||
case SDL_MOUSEWHEEL: {
|
||||
// Let ImGui handle mouse wheel when it wants to capture the mouse
|
||||
// (e.g., when hovering the editor child window with scrollbars).
|
||||
// This enables native vertical and horizontal scrolling behavior in GUI.
|
||||
if (ImGui::GetIO().WantCaptureMouse)
|
||||
return false;
|
||||
// Otherwise, fallback to mapping vertical wheel to editor scroll commands.
|
||||
int dy = e.wheel.y;
|
||||
// High-resolution trackpads can deliver fractional wheel deltas. Accumulate
|
||||
// precise values and emit one scroll step per whole unit.
|
||||
float dy = 0.0f;
|
||||
#if SDL_VERSION_ATLEAST(2, 0, 18)
|
||||
dy = e.wheel.preciseY;
|
||||
#else
|
||||
dy = static_cast<float>(e.wheel.y);
|
||||
#endif
|
||||
#ifdef SDL_MOUSEWHEEL_FLIPPED
|
||||
if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED)
|
||||
dy = -dy;
|
||||
#endif
|
||||
if (dy != 0) {
|
||||
int repeat = dy > 0 ? dy : -dy;
|
||||
CommandId id = dy > 0 ? CommandId::ScrollUp : CommandId::ScrollDown;
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
q_.push(MappedInput{true, id, std::string(), 0});
|
||||
if (dy != 0.0f) {
|
||||
wheel_accum_y_ += dy;
|
||||
float abs_accum = wheel_accum_y_ >= 0.0f ? wheel_accum_y_ : -wheel_accum_y_;
|
||||
int steps = static_cast<int>(abs_accum);
|
||||
if (steps > 0) {
|
||||
CommandId id = (wheel_accum_y_ > 0.0f) ? CommandId::ScrollUp : CommandId::ScrollDown;
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (int i = 0; i < steps; ++i) {
|
||||
q_.push(MappedInput{true, id, std::string(), 0});
|
||||
}
|
||||
// remove the whole steps, keep fractional remainder
|
||||
wheel_accum_y_ += (wheel_accum_y_ > 0.0f)
|
||||
? -static_cast<float>(steps)
|
||||
: static_cast<float>(steps);
|
||||
return true; // consumed
|
||||
}
|
||||
return true; // consumed
|
||||
}
|
||||
return false;
|
||||
}
|
||||
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
|
||||
const bool was_k_prefix = k_prefix_;
|
||||
const bool was_esc_meta = esc_meta_;
|
||||
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
|
||||
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)
|
||||
// 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)) {
|
||||
@@ -332,59 +414,43 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
if (clip) {
|
||||
std::string text(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::size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
for (const auto &cmd: to_prompt ? SplitPasteForPrompt(text) : SplitPasteIntoCommands(text))
|
||||
q_.push(cmd);
|
||||
// Suppress the corresponding TEXTINPUT that may follow
|
||||
suppress_text_input_once_ = true;
|
||||
return true; // consumed
|
||||
}
|
||||
}
|
||||
|
||||
produced = map_key(key, mods,
|
||||
k_prefix_, esc_meta_,
|
||||
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_,
|
||||
uarg_text_,
|
||||
mi);
|
||||
|
||||
// If we inserted a TAB on KEYDOWN, suppress any subsequent SDL_TEXTINPUT
|
||||
// for this keystroke to avoid double insertion on platforms that emit it.
|
||||
if (produced && mi.hasCommand && mi.id == CommandId::InsertText && mi.arg == "\t") {
|
||||
suppress_text_input_once_ = true;
|
||||
}
|
||||
|
||||
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
|
||||
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
|
||||
if (produced && mi.hasCommand && mi.id == CommandId::UArgStatus) {
|
||||
// Digits without shift, or a plain '-'
|
||||
const bool is_digit_key = (key >= SDLK_0 && key <= SDLK_9) && !(mods & KMOD_SHIFT);
|
||||
const bool is_minus_key = (key == SDLK_MINUS);
|
||||
if (uarg_active_ && uarg_collecting_ &&(is_digit_key || is_minus_key)) {
|
||||
{
|
||||
bool suppress_req = false;
|
||||
produced = map_key(key, mods,
|
||||
k_prefix_, esc_meta_,
|
||||
k_ctrl_pending_,
|
||||
ed_,
|
||||
mi,
|
||||
suppress_req);
|
||||
if (suppress_req) {
|
||||
// Prevent the corresponding TEXTINPUT from delivering the same digit again
|
||||
suppress_text_input_once_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Do NOT suppress SDL_TEXTINPUT after inserting a TAB. Most platforms
|
||||
// do not emit TEXTINPUT for Tab, and suppressing here would incorrectly
|
||||
// eat the next character typed if no TEXTINPUT follows the Tab press.
|
||||
|
||||
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
|
||||
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
|
||||
// Additional suppression handled above when KEYDOWN consumed a uarg digit
|
||||
|
||||
// Suppress the immediate following SDL_TEXTINPUT when a printable KEYDOWN was used as a
|
||||
// k-prefix suffix or Meta (Alt/ESC) chord, regardless of whether a command was produced.
|
||||
const bool is_alt = (mods & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0;
|
||||
@@ -404,7 +470,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
}
|
||||
// Alt/Meta + letter can also generate TEXTINPUT on some platforms
|
||||
const bool is_meta_symbol = (
|
||||
key == SDLK_COMMA || key == SDLK_PERIOD || key == SDLK_LESS || key == SDLK_GREATER);
|
||||
key == SDLK_COMMA || key == SDLK_PERIOD || key == SDLK_LESS || key ==
|
||||
SDLK_GREATER);
|
||||
if (is_alt && ((key >= SDLK_a && key <= SDLK_z) || is_meta_symbol)) {
|
||||
should_suppress = true;
|
||||
}
|
||||
@@ -428,35 +495,26 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
break;
|
||||
}
|
||||
|
||||
// If universal argument collection is active, consume digit/minus TEXTINPUT
|
||||
if (uarg_active_ && uarg_collecting_) {
|
||||
// If editor universal argument is active, consume digit TEXTINPUT
|
||||
if (ed_ &&ed_
|
||||
|
||||
|
||||
|
||||
->
|
||||
UArg() != 0
|
||||
)
|
||||
{
|
||||
const char *txt = e.text.text;
|
||||
if (txt && *txt) {
|
||||
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
||||
if (c0 >= '0' && c0 <= '9') {
|
||||
int d = c0 - '0';
|
||||
if (!uarg_had_digits_) {
|
||||
uarg_value_ = 0;
|
||||
uarg_had_digits_ = true;
|
||||
}
|
||||
if (uarg_value_ < 100000000) {
|
||||
uarg_value_ = uarg_value_ * 10 + d;
|
||||
}
|
||||
uarg_text_.push_back(static_cast<char>(c0));
|
||||
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
|
||||
produced = true; // consumed and enqueued status update
|
||||
break;
|
||||
}
|
||||
if (c0 == '-' && !uarg_had_digits_ && !uarg_negative_) {
|
||||
uarg_negative_ = true;
|
||||
uarg_text_ = "-";
|
||||
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
|
||||
produced = true;
|
||||
ed_->UArgDigit(d);
|
||||
produced = true; // consumed to update status
|
||||
break;
|
||||
}
|
||||
}
|
||||
// End collection and allow this TEXTINPUT to be processed normally below
|
||||
uarg_collecting_ = false;
|
||||
// Non-digit ends collection; allow processing normally below
|
||||
}
|
||||
|
||||
// If we are still in k-prefix and KEYDOWN path didn't handle the suffix,
|
||||
@@ -472,20 +530,36 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
ascii_key = static_cast<int>(c0);
|
||||
}
|
||||
if (ascii_key != 0) {
|
||||
// Qualifier via TEXTINPUT: uppercase 'C' or '^' only
|
||||
if (ascii_key == 'C' || ascii_key == '^') {
|
||||
k_ctrl_pending_ = true;
|
||||
if (ed_)
|
||||
ed_->SetStatus("C-k C _");
|
||||
// Keep k-prefix active; do not emit a command
|
||||
k_prefix_ = true;
|
||||
produced = true;
|
||||
break;
|
||||
}
|
||||
// Map via k-prefix table; do not pass Ctrl for TEXTINPUT case
|
||||
CommandId id;
|
||||
bool mapped = KLookupKCommand(ascii_key, false, id);
|
||||
bool pass_ctrl = k_ctrl_pending_;
|
||||
k_ctrl_pending_ = false;
|
||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||
#if IMGUI_IH_DEBUG
|
||||
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
||||
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",
|
||||
ascii_key, disp, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
std::fflush(stderr);
|
||||
{
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
: '?';
|
||||
IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||
ascii_key, disp, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
}
|
||||
#endif
|
||||
if (mapped) {
|
||||
mi = {true, id, "", 0};
|
||||
mi = {true, id, "", 0};
|
||||
if (ed_)
|
||||
ed_->SetStatus(""); // clear "C-k _" hint after suffix
|
||||
produced = true;
|
||||
break; // handled; do not insert text
|
||||
} else {
|
||||
@@ -495,13 +569,18 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
? static_cast<char>(shown)
|
||||
: '?';
|
||||
std::string arg(1, c);
|
||||
mi = {true, CommandId::UnknownKCommand, arg, 0};
|
||||
mi = {true, CommandId::UnknownKCommand, arg, 0};
|
||||
if (ed_)
|
||||
ed_->SetStatus("");
|
||||
produced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Consume even if no usable ascii was found
|
||||
// If no usable ASCII was found, still report an unknown k-command and exit k-mode
|
||||
mi = {true, CommandId::UnknownKCommand, std::string("?"), 0};
|
||||
if (ed_)
|
||||
ed_->SetStatus("");
|
||||
produced = true;
|
||||
break;
|
||||
}
|
||||
@@ -541,7 +620,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we get here, swallow the TEXTINPUT (do not insert stray char)
|
||||
// If we get here, unmapped ESC sequence via TEXTINPUT: report invalid
|
||||
mi = {true, CommandId::UnknownEscCommand, "", 0};
|
||||
produced = true;
|
||||
break;
|
||||
}
|
||||
@@ -571,31 +651,6 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
}
|
||||
|
||||
if (produced && mi.hasCommand) {
|
||||
// Attach universal-argument count if present, then clear the state
|
||||
if (uarg_active_ &&mi
|
||||
|
||||
.
|
||||
id != CommandId::UArgStatus
|
||||
)
|
||||
{
|
||||
int count = 0;
|
||||
if (!uarg_had_digits_ && !uarg_negative_) {
|
||||
// No explicit digits: use current value (default 4 or 4^n)
|
||||
count = (uarg_value_ > 0) ? uarg_value_ : 4;
|
||||
} else {
|
||||
count = uarg_value_;
|
||||
if (uarg_negative_)
|
||||
count = -count;
|
||||
}
|
||||
mi.count = count;
|
||||
// Clear universal-argument state after applying it
|
||||
uarg_active_ = false;
|
||||
uarg_collecting_ = false;
|
||||
uarg_negative_ = false;
|
||||
uarg_had_digits_ = false;
|
||||
uarg_value_ = 0;
|
||||
uarg_text_.clear();
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(mi);
|
||||
}
|
||||
@@ -604,7 +659,7 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
|
||||
|
||||
bool
|
||||
GUIInputHandler::Poll(MappedInput &out)
|
||||
ImGuiInputHandler::Poll(MappedInput &out)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (q_.empty())
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* GUIInputHandler - ImGui/SDL2-based input mapping for GUI mode
|
||||
* ImGuiInputHandler - ImGui/SDL2-based input mapping for GUI mode
|
||||
*/
|
||||
#pragma once
|
||||
#include <mutex>
|
||||
@@ -10,11 +10,18 @@
|
||||
|
||||
union SDL_Event; // fwd decl to avoid including SDL here (SDL defines SDL_Event as a union)
|
||||
|
||||
class GUIInputHandler final : public InputHandler {
|
||||
class ImGuiInputHandler final : public InputHandler {
|
||||
public:
|
||||
GUIInputHandler() = default;
|
||||
ImGuiInputHandler() = default;
|
||||
|
||||
~ImGuiInputHandler() override = default;
|
||||
|
||||
|
||||
void Attach(Editor *ed) override
|
||||
{
|
||||
ed_ = ed;
|
||||
}
|
||||
|
||||
~GUIInputHandler() override = default;
|
||||
|
||||
// Translate an SDL event to editor command and enqueue if applicable.
|
||||
// Returns true if it produced a mapped command or consumed input.
|
||||
@@ -25,18 +32,18 @@ public:
|
||||
private:
|
||||
std::mutex mu_;
|
||||
std::queue<MappedInput> q_;
|
||||
bool k_prefix_ = false;
|
||||
bool k_prefix_ = false;
|
||||
bool k_ctrl_pending_ = false; // if true, next k-suffix is treated as Ctrl- (qualifier via literal 'C' or '^')
|
||||
// Treat ESC as a Meta prefix: next key is looked up via ESC keymap
|
||||
bool esc_meta_ = false;
|
||||
// When a printable keydown generated a non-text command, suppress the very next SDL_TEXTINPUT
|
||||
// event produced by SDL for the same keystroke to avoid inserting stray characters.
|
||||
bool suppress_text_input_once_ = false;
|
||||
|
||||
// Universal argument (C-u) state for GUI
|
||||
bool uarg_active_ = false; // an argument is pending for the next command
|
||||
bool uarg_collecting_ = false; // collecting digits / '-' right now
|
||||
bool uarg_negative_ = false; // whether a leading '-' was supplied
|
||||
bool uarg_had_digits_ = false; // whether any digits were supplied
|
||||
int uarg_value_ = 0; // current absolute value (>=0)
|
||||
std::string uarg_text_; // raw digits/minus typed for status display
|
||||
};
|
||||
Editor *ed_ = nullptr; // attached editor for editor-owned uarg handling
|
||||
|
||||
// Accumulators for high-resolution (trackpad) scrolling. We emit one scroll
|
||||
// command per whole step and keep the fractional remainder.
|
||||
float wheel_accum_y_ = 0.0f;
|
||||
float wheel_accum_x_ = 0.0f; // reserved for future horizontal scrolling
|
||||
};
|
||||
+442
-295
@@ -7,9 +7,10 @@
|
||||
#include <string>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <regex>
|
||||
|
||||
#include "GUIRenderer.h"
|
||||
#include "ImGuiRenderer.h"
|
||||
#include "SearchHighlight.h"
|
||||
#include "TermWidth.h"
|
||||
#include "Highlight.h"
|
||||
#include "GUITheme.h"
|
||||
#include "Buffer.h"
|
||||
@@ -30,7 +31,7 @@
|
||||
|
||||
|
||||
void
|
||||
GUIRenderer::Draw(Editor &ed)
|
||||
ImGuiRenderer::Draw(Editor &ed)
|
||||
{
|
||||
// Make the editor window occupy the entire GUI container/viewport
|
||||
ImGuiViewport *vp = ImGui::GetMainViewport();
|
||||
@@ -66,7 +67,9 @@ GUIRenderer::Draw(Editor &ed)
|
||||
if (!buf) {
|
||||
ImGui::TextUnformatted("[no buffer]");
|
||||
} 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 cx = buf->Curx();
|
||||
const float line_h = ImGui::GetTextLineHeight();
|
||||
@@ -76,258 +79,289 @@ GUIRenderer::Draw(Editor &ed)
|
||||
// Two-way sync between Buffer::Rowoffs and ImGui scroll position:
|
||||
// - 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.
|
||||
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_coloffs = static_cast<long>(buf->Coloffs());
|
||||
|
||||
// Detect programmatic change (e.g., page_down command changed rowoffs)
|
||||
// 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;
|
||||
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
|
||||
}
|
||||
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) {
|
||||
float target_x = static_cast<float>(buf_coloffs) * space_w;
|
||||
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
||||
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
|
||||
}
|
||||
// Horizontal scroll is handled purely in pixel space (see
|
||||
// cursor-visibility block after the line loop) so we don't
|
||||
// convert the character-based coloffs to an ImGui scroll here.
|
||||
|
||||
// Reserve space for status bar at bottom
|
||||
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false,
|
||||
// Reserve space for status bar at bottom.
|
||||
// We calculate a height that is an exact multiple of the line height
|
||||
// to avoid partial lines and "scroll past end" jitter.
|
||||
float total_avail_h = ImGui::GetContentRegionAvail().y;
|
||||
float wanted_bar_h = ImGui::GetFrameHeight();
|
||||
float child_h_plan = std::max(0.0f, std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h);
|
||||
float real_bar_h = total_avail_h - child_h_plan;
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
||||
ImGui::BeginChild("scroll", ImVec2(0, child_h_plan), false,
|
||||
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
|
||||
// Get child window position and scroll for click handling
|
||||
ImVec2 child_window_pos = ImGui::GetWindowPos();
|
||||
float scroll_y = ImGui::GetScrollY();
|
||||
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
|
||||
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_left = static_cast<long>(scroll_x / space_w);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// If user scrolled (not programmatic), update buffer offsets accordingly
|
||||
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y && !forced_scroll) {
|
||||
// If user scrolled vertically (not programmatic), update buffer row offset
|
||||
if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
|
||||
mbuf->Coloffs());
|
||||
}
|
||||
}
|
||||
if (prev_scroll_x >= 0.0f && scroll_x != prev_scroll_x && !forced_scroll) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetOffsets(mbuf->Rowoffs(),
|
||||
static_cast<std::size_t>(std::max(0L, scroll_left)));
|
||||
}
|
||||
}
|
||||
// Horizontal scroll is pixel-based and managed by the cursor
|
||||
// visibility block below; we don't sync it back to coloffs.
|
||||
|
||||
// Update trackers for next frame
|
||||
prev_scroll_y = scroll_y;
|
||||
prev_scroll_x = scroll_x;
|
||||
prev_scroll_y_ = scroll_y;
|
||||
prev_scroll_x_ = scroll_x;
|
||||
}
|
||||
prev_buf_rowoffs = buf_rowoffs;
|
||||
prev_buf_coloffs = buf_coloffs;
|
||||
prev_buf_rowoffs_ = buf_rowoffs;
|
||||
prev_buf_coloffs_ = buf_coloffs;
|
||||
|
||||
// Synchronize cursor and scrolling.
|
||||
// Ensure the cursor is visible even on the first frame or when it didn't move.
|
||||
// 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.
|
||||
{
|
||||
// Compute visible row range using the child window height
|
||||
float child_h = ImGui::GetWindowHeight();
|
||||
long first_row = static_cast<long>(scroll_y / row_h);
|
||||
long vis_rows = static_cast<long>(child_h / row_h);
|
||||
if (vis_rows < 1)
|
||||
vis_rows = 1;
|
||||
long last_row = first_row + vis_rows - 1;
|
||||
|
||||
long cyr = static_cast<long>(cy);
|
||||
if (cyr < first_row || cyr > last_row) {
|
||||
float target = (static_cast<float>(cyr) - std::max(0L, vis_rows / 2)) * row_h;
|
||||
float max_y = ImGui::GetScrollMaxY();
|
||||
if (target < 0.f)
|
||||
target = 0.f;
|
||||
if (max_y >= 0.f && target > max_y)
|
||||
target = max_y;
|
||||
ImGui::SetScrollY(target);
|
||||
// refresh local variables
|
||||
scroll_y = ImGui::GetScrollY();
|
||||
first_row = static_cast<long>(scroll_y / row_h);
|
||||
last_row = first_row + vis_rows - 1;
|
||||
}
|
||||
|
||||
// Horizontal scroll: ensure cursor column is visible
|
||||
float child_w = ImGui::GetWindowWidth();
|
||||
long vis_cols = static_cast<long>(child_w / space_w);
|
||||
if (vis_cols < 1)
|
||||
vis_cols = 1;
|
||||
long first_col = static_cast<long>(scroll_x / space_w);
|
||||
long last_col = first_col + vis_cols - 1;
|
||||
|
||||
// Compute cursor's rendered X position (accounting for tabs)
|
||||
std::size_t cursor_rx = 0;
|
||||
if (cy < lines.size()) {
|
||||
std::string cur_line = static_cast<std::string>(lines[cy]);
|
||||
const std::size_t tabw = 8;
|
||||
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
|
||||
if (cur_line[i] == '\t') {
|
||||
cursor_rx += tabw - (cursor_rx % tabw);
|
||||
} else {
|
||||
cursor_rx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
long cxr = static_cast<long>(cursor_rx);
|
||||
if (cxr < first_col || cxr > last_col) {
|
||||
float target_x = static_cast<float>(cxr) * space_w;
|
||||
// Center horizontally if possible
|
||||
target_x -= (child_w / 2.0f);
|
||||
if (target_x < 0.f)
|
||||
target_x = 0.f;
|
||||
float max_x = ImGui::GetScrollMaxX();
|
||||
if (max_x >= 0.f && target_x > max_x)
|
||||
target_x = max_x;
|
||||
ImGui::SetScrollX(target_x);
|
||||
scroll_x = ImGui::GetScrollX();
|
||||
}
|
||||
// Phase 3: prefetch visible viewport highlights and warm around in background
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
int fr = static_cast<int>(std::max(0L, first_row));
|
||||
int rc = static_cast<int>(std::max(1L, vis_rows));
|
||||
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
||||
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();
|
||||
}
|
||||
// Cache current horizontal offset in rendered columns for click handling
|
||||
const std::size_t coloffs_now = buf->Coloffs();
|
||||
|
||||
// Handle mouse click before rendering to avoid dependent on drawn items
|
||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
// Search matches: the pattern is compiled once per frame (compiling it
|
||||
// per line per frame was a large source of idle CPU on macOS).
|
||||
const kte::SearchHighlight search_hl(ed);
|
||||
const bool search_mode = search_hl.Active();
|
||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||
|
||||
// Compute the visible row range and skip rendering work for off-screen
|
||||
// lines. ImGui clips drawing, but string allocation, tab expansion,
|
||||
// syntax-highlight lookups, and width measurement are not free.
|
||||
const std::size_t total_rows = nlines;
|
||||
std::size_t first_vis = 0;
|
||||
std::size_t last_vis = total_rows; // exclusive
|
||||
if (row_h > 0.0f && total_rows > 0) {
|
||||
const long margin = 4; // render a few extra rows above/below for smoother scrolling
|
||||
long fv = static_cast<long>(std::floor(scroll_y / row_h)) - margin;
|
||||
long lv = static_cast<long>(
|
||||
std::ceil((scroll_y + child_h_plan) / row_h)) + margin;
|
||||
if (fv < 0) fv = 0;
|
||||
if (lv < 0) lv = 0;
|
||||
if (static_cast<std::size_t>(fv) > total_rows)
|
||||
fv = static_cast<long>(total_rows);
|
||||
if (static_cast<std::size_t>(lv) > total_rows)
|
||||
lv = static_cast<long>(total_rows);
|
||||
first_vis = static_cast<std::size_t>(fv);
|
||||
last_vis = static_cast<std::size_t>(lv);
|
||||
}
|
||||
|
||||
// Mark selection state (mark -> cursor), in source coordinates
|
||||
bool sel_active = false;
|
||||
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
|
||||
if (buf->MarkSet()) {
|
||||
sel_sy = buf->MarkCury();
|
||||
sel_sx = buf->MarkCurx();
|
||||
sel_ey = buf->Cury();
|
||||
sel_ex = buf->Curx();
|
||||
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
|
||||
std::swap(sel_sy, sel_ey);
|
||||
std::swap(sel_sx, sel_ex);
|
||||
}
|
||||
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
|
||||
}
|
||||
// Visual-line selection: full-line highlight range
|
||||
const bool vsel_active = buf->VisualLineActive();
|
||||
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
||||
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
||||
|
||||
// (mouse_selecting__ is a member variable)
|
||||
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
|
||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||
// Compute content-relative position accounting for scroll
|
||||
// mp.y - child_window_pos.y gives us pixels from top of child window
|
||||
// Adding scroll_y gives us pixels from top of content (buffer row 0)
|
||||
// Convert mouse pos to buffer row
|
||||
float content_y = (mp.y - child_window_pos.y) + scroll_y;
|
||||
long by_l = static_cast<long>(content_y / row_h);
|
||||
if (by_l < 0)
|
||||
by_l = 0;
|
||||
|
||||
// Convert to buffer row
|
||||
std::size_t by = static_cast<std::size_t>(by_l);
|
||||
if (by >= lines.size()) {
|
||||
if (!lines.empty())
|
||||
by = lines.size() - 1;
|
||||
else
|
||||
by = 0;
|
||||
}
|
||||
if (by >= nlines)
|
||||
by = (nlines == 0) ? 0 : (nlines - 1);
|
||||
|
||||
// Compute click X position relative to left edge of child window (in pixels)
|
||||
// This gives us the visual offset from the start of displayed content
|
||||
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;
|
||||
if (visual_x < 0.0f)
|
||||
visual_x = 0.0f;
|
||||
// Add scroll offset in pixels
|
||||
visual_x += scroll_x;
|
||||
|
||||
// Convert visual pixel offset to rendered column, then add coloffs_now
|
||||
// to get the absolute rendered column in the buffer
|
||||
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
|
||||
|
||||
// Empty buffer guard: if there are no lines yet, just move to 0:0
|
||||
if (lines.empty()) {
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
|
||||
} else {
|
||||
// Convert rendered column (clicked_rx) to source column accounting for tabs
|
||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
||||
const std::size_t tabw = 8;
|
||||
|
||||
// Iterate through source columns, computing rendered position, to find closest match
|
||||
std::size_t rx = 0; // rendered column position
|
||||
std::size_t best_col = 0;
|
||||
float best_dist = std::numeric_limits<float>::infinity();
|
||||
float clicked_rx_f = static_cast<float>(clicked_rx);
|
||||
|
||||
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
|
||||
// Check current position
|
||||
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
|
||||
if (dist < best_dist) {
|
||||
best_dist = dist;
|
||||
best_col = i;
|
||||
}
|
||||
|
||||
// Advance to next position if not at end
|
||||
if (i < line_clicked.size()) {
|
||||
if (line_clicked[i] == '\t') {
|
||||
rx += (tabw - (rx % tabw));
|
||||
} else {
|
||||
rx += 1;
|
||||
}
|
||||
}
|
||||
// Find the source column whose expanded position is closest
|
||||
// to the click pixel, using actual text measurement.
|
||||
std::size_t best_col = 0;
|
||||
float best_dist = std::numeric_limits<float>::infinity();
|
||||
for (std::size_t ci = 0; ci <= line_clicked.size(); ++ci) {
|
||||
std::size_t exp_col = src_to_exp[ci];
|
||||
float px = 0.0f;
|
||||
if (exp_col > 0 && !click_expanded.empty()) {
|
||||
std::size_t end = std::min(click_expanded.size(), exp_col);
|
||||
px = ImGui::CalcTextSize(click_expanded.c_str(),
|
||||
click_expanded.c_str() + end).x;
|
||||
}
|
||||
float dist = std::fabs(visual_x - px);
|
||||
if (dist < best_dist) {
|
||||
best_dist = dist;
|
||||
best_col = ci;
|
||||
}
|
||||
}
|
||||
return {by, best_col};
|
||||
};
|
||||
|
||||
// Dispatch absolute buffer coordinates (row:col)
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
// Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag
|
||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
mouse_selecting_ = true;
|
||||
auto [by, bx] = mouse_pos_to_buf();
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
|
||||
// Only set mark on double click.
|
||||
// Dragging will also set the mark if not already set (handled below).
|
||||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetMark(bx, by);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
||||
// Capture the screen position before drawing the line
|
||||
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
||||
std::string line = static_cast<std::string>(lines[i]);
|
||||
|
||||
// Expand tabs to spaces with width=8 and apply horizontal scroll offset
|
||||
const std::size_t tabw = 8;
|
||||
std::string expanded;
|
||||
expanded.reserve(line.size() + 16);
|
||||
std::size_t rx_abs_draw = 0; // rendered column for drawing
|
||||
// Compute search highlight ranges for this line in source indices
|
||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||
if (search_mode) {
|
||||
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
|
||||
if (ed.PromptActive() && (
|
||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
||||
try {
|
||||
std::regex rx(ed.SearchQuery());
|
||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
const auto &m = *it;
|
||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
||||
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
||||
hl_src_ranges.emplace_back(sx, ex);
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status line already shows the error
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
std::size_t pos = 0;
|
||||
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
|
||||
hl_src_ranges.emplace_back(pos, pos + q.size());
|
||||
pos += q.size();
|
||||
if (mouse_selecting_ && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||
auto [by, bx] = mouse_pos_to_buf();
|
||||
// If we are dragging (mouse moved while down), ensure mark is set to start selection
|
||||
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
if (!mbuf->MarkSet()) {
|
||||
// We'd need to convert click_pos to buf coords, but it's complex here.
|
||||
// Setting it to where the cursor was *before* we started moving it
|
||||
// in this frame is a good approximation, or just using current.
|
||||
mbuf->SetMark(mbuf->Curx(), mbuf->Cury());
|
||||
}
|
||||
}
|
||||
}
|
||||
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
|
||||
std::size_t rx = 0;
|
||||
std::size_t s = 0;
|
||||
while (s < upto_src_exclusive && s < line.size()) {
|
||||
if (line[s] == '\t')
|
||||
rx += (tabw - (rx % tabw));
|
||||
else
|
||||
rx += 1;
|
||||
++s;
|
||||
}
|
||||
return rx;
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
}
|
||||
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
||||
mouse_selecting_ = false;
|
||||
}
|
||||
// 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
|
||||
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
||||
std::string line = buf->GetLineString(i);
|
||||
|
||||
// Expand tabs to spaces with width=8, recording each source
|
||||
// byte's rendered column (rx_of[line.size()] is the line's width).
|
||||
const std::size_t tabw = kte::kTabWidth;
|
||||
std::string expanded;
|
||||
expanded.reserve(line.size() + 16);
|
||||
std::vector<std::size_t> rx_of(line.size() + 1);
|
||||
for (std::size_t src = 0; src < line.size(); ++src) {
|
||||
rx_of[src] = expanded.size();
|
||||
if (line[src] == '\t')
|
||||
expanded.append(tabw - (expanded.size() % tabw), ' ');
|
||||
else
|
||||
expanded.push_back(line[src]);
|
||||
}
|
||||
rx_of[line.size()] = expanded.size();
|
||||
auto src_to_rx = [&](std::size_t src) -> std::size_t {
|
||||
return rx_of[std::min(src, line.size())];
|
||||
};
|
||||
|
||||
// Rendered column -> pixel offset from the start of the line.
|
||||
// Measuring from column 0 for every span was quadratic in line
|
||||
// length; measure on from the nearest earlier position already
|
||||
// measured (only character boundaries are kept, so a multi-byte
|
||||
// character is never measured in pieces).
|
||||
std::vector<std::pair<std::size_t, float> > px_marks{{0, 0.0f}}; // unrounded
|
||||
auto rx_to_px = [&](std::size_t rx_col) -> float {
|
||||
const std::size_t end = std::min(expanded.size(), rx_col);
|
||||
auto it = std::upper_bound(px_marks.begin(), px_marks.end(), end,
|
||||
[](std::size_t v, const std::pair<std::size_t, float> &m) {
|
||||
return v < m.first;
|
||||
});
|
||||
--it; // px_marks[0] is column 0
|
||||
float px = it->second;
|
||||
if (it->first != end) {
|
||||
px += ImGui::GetFont()->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f,
|
||||
expanded.c_str() + it->first,
|
||||
expanded.c_str() + end).x;
|
||||
if (end == expanded.size() || (static_cast<unsigned char>(expanded[end]) & 0xC0) != 0x80)
|
||||
px_marks.insert(it + 1, {end, px});
|
||||
}
|
||||
// Rounded up as ImGui::CalcTextSize does (sums of rounded
|
||||
// pieces would drift).
|
||||
return end == 0 ? 0.0f : static_cast<float>(static_cast<int>(px + 0.99999f));
|
||||
};
|
||||
|
||||
// Search highlight ranges for this line in source indices
|
||||
search_hl.Ranges(line, hl_src_ranges);
|
||||
// Draw background highlights (under text)
|
||||
if (search_mode && !hl_src_ranges.empty()) {
|
||||
// Current match emphasis
|
||||
@@ -338,13 +372,8 @@ GUIRenderer::Draw(Editor &ed)
|
||||
std::size_t sx = rg.first, ex = rg.second;
|
||||
std::size_t rx_start = src_to_rx(sx);
|
||||
std::size_t rx_end = src_to_rx(ex);
|
||||
// Apply horizontal scroll offset
|
||||
if (rx_end <= coloffs_now)
|
||||
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,
|
||||
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y);
|
||||
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||
line_pos.y + line_h);
|
||||
// Choose color: current match stronger
|
||||
bool is_current = has_current && sx == cur_x && ex == cur_end;
|
||||
@@ -354,50 +383,77 @@ GUIRenderer::Draw(Editor &ed)
|
||||
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;
|
||||
|
||||
// Draw selection background (over search highlight; under text)
|
||||
if (sel_active) {
|
||||
bool line_has = false;
|
||||
std::size_t sx = 0, ex = 0;
|
||||
if (i < sel_sy || i > sel_ey) {
|
||||
line_has = false;
|
||||
} else if (sel_sy == sel_ey) {
|
||||
sx = sel_sx;
|
||||
ex = sel_ex;
|
||||
line_has = ex > sx;
|
||||
} else if (i == sel_sy) {
|
||||
sx = sel_sx;
|
||||
ex = line.size();
|
||||
line_has = ex > sx;
|
||||
} else if (i == sel_ey) {
|
||||
sx = 0;
|
||||
ex = std::min(sel_ex, line.size());
|
||||
line_has = ex > sx;
|
||||
} else {
|
||||
expanded.push_back(c);
|
||||
rx_abs_draw += 1;
|
||||
sx = 0;
|
||||
ex = line.size();
|
||||
line_has = ex > sx;
|
||||
}
|
||||
if (line_has) {
|
||||
std::size_t rx_start = src_to_rx(sx);
|
||||
std::size_t rx_end = src_to_rx(ex);
|
||||
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
||||
line_pos.y);
|
||||
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||
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) {
|
||||
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
|
||||
const std::size_t spot_sx = std::min(buf->Curx(), line.size());
|
||||
const std::size_t rx_start = src_to_rx(spot_sx);
|
||||
std::size_t rx_end = rx_start;
|
||||
if (spot_sx < line.size()) {
|
||||
rx_end = src_to_rx(spot_sx + 1);
|
||||
} else {
|
||||
// EOL spot: draw a 1-cell highlight just past the last character.
|
||||
rx_end = rx_start + 1;
|
||||
}
|
||||
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
||||
line_pos.y);
|
||||
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||
line_pos.y + line_h);
|
||||
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
// Draw syntax-colored runs (text above background highlights)
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
|
||||
*buf, static_cast<int>(i), buf->Version());
|
||||
// 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: lh.spans) {
|
||||
std::size_t rx_s = src_to_rx_full(
|
||||
static_cast<std::size_t>(std::max(0, sp.col_start)));
|
||||
std::size_t rx_e = src_to_rx_full(
|
||||
static_cast<std::size_t>(std::max(sp.col_start, sp.col_end)));
|
||||
if (rx_e <= coloffs_now)
|
||||
continue;
|
||||
// Clamp rx_s/rx_e to the visible portion
|
||||
std::size_t draw_start = (rx_s > coloffs_now) ? rx_s : coloffs_now;
|
||||
std::size_t draw_end = rx_e;
|
||||
std::vector<kte::HighlightSpan> spans; // clamped, ordered, whole characters
|
||||
kte::SanitizeSpans(line, lh.spans, spans);
|
||||
|
||||
for (const auto &sp: spans) {
|
||||
std::size_t rx_s = src_to_rx(static_cast<std::size_t>(sp.col_start));
|
||||
std::size_t rx_e = src_to_rx(static_cast<std::size_t>(sp.col_end));
|
||||
std::size_t draw_start = rx_s;
|
||||
if (draw_start >= expanded.size())
|
||||
continue;
|
||||
draw_end = std::min<std::size_t>(draw_end, expanded.size());
|
||||
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
|
||||
if (draw_end <= draw_start)
|
||||
continue;
|
||||
// Screen position is relative to coloffs_now
|
||||
std::size_t screen_x = draw_start - coloffs_now;
|
||||
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
|
||||
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(screen_x) * space_w,
|
||||
ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start),
|
||||
line_pos.y);
|
||||
ImGui::GetWindowDrawList()->AddText(
|
||||
p, col, expanded.c_str() + draw_start, expanded.c_str() + draw_end);
|
||||
@@ -406,54 +462,152 @@ GUIRenderer::Draw(Editor &ed)
|
||||
// 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));
|
||||
} else {
|
||||
// No syntax: draw as one run, accounting for horizontal scroll offset
|
||||
if (coloffs_now < expanded.size()) {
|
||||
// No syntax: draw the full line; ImGui scroll handles clipping.
|
||||
if (!expanded.empty()) {
|
||||
ImVec2 p = ImVec2(line_pos.x, line_pos.y);
|
||||
ImGui::GetWindowDrawList()->AddText(
|
||||
p, ImGui::GetColorU32(ImGuiCol_Text),
|
||||
expanded.c_str() + coloffs_now);
|
||||
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));
|
||||
expanded.c_str());
|
||||
}
|
||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||
}
|
||||
|
||||
// Draw a visible cursor indicator on the current line
|
||||
if (i == cy) {
|
||||
// Compute rendered X (rx) from source column with tab expansion
|
||||
std::size_t rx_abs = 0;
|
||||
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;
|
||||
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(rx_viewport) * space_w, line_pos.y);
|
||||
std::size_t rx_abs = src_to_rx(cx);
|
||||
float cursor_px = rx_to_px(rx_abs);
|
||||
ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y);
|
||||
ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h);
|
||||
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
|
||||
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.
|
||||
{
|
||||
float child_h_actual = ImGui::GetWindowHeight();
|
||||
float child_w_actual = ImGui::GetWindowWidth();
|
||||
float scroll_y_now = ImGui::GetScrollY();
|
||||
float scroll_x_now = ImGui::GetScrollX();
|
||||
|
||||
long first_row = static_cast<long>(scroll_y_now / row_h);
|
||||
long vis_rows = static_cast<long>(std::round(child_h_actual / row_h));
|
||||
if (vis_rows < 1)
|
||||
vis_rows = 1;
|
||||
long last_row = first_row + vis_rows - 1;
|
||||
|
||||
long cyr = static_cast<long>(cy);
|
||||
if (cyr < first_row) {
|
||||
float target = static_cast<float>(cyr) * row_h;
|
||||
if (target < 0.f)
|
||||
target = 0.f;
|
||||
float max_y = ImGui::GetScrollMaxY();
|
||||
if (max_y >= 0.f && target > max_y)
|
||||
target = max_y;
|
||||
ImGui::SetScrollY(target);
|
||||
first_row = static_cast<long>(target / row_h);
|
||||
last_row = first_row + vis_rows - 1;
|
||||
} else if (cyr > last_row) {
|
||||
long new_first = cyr - vis_rows + 1;
|
||||
if (new_first < 0)
|
||||
new_first = 0;
|
||||
float target = static_cast<float>(new_first) * row_h;
|
||||
float max_y = ImGui::GetScrollMaxY();
|
||||
if (target < 0.f)
|
||||
target = 0.f;
|
||||
if (max_y >= 0.f && target > max_y)
|
||||
target = max_y;
|
||||
ImGui::SetScrollY(target);
|
||||
first_row = static_cast<long>(target / row_h);
|
||||
last_row = first_row + vis_rows - 1;
|
||||
}
|
||||
|
||||
// Horizontal scroll: ensure cursor is visible (pixel-based for proportional fonts)
|
||||
float cursor_px_abs = 0.0f;
|
||||
if (cy < nlines) {
|
||||
std::string cur_line = buf->GetLineString(cy);
|
||||
const std::size_t tabw = kte::kTabWidth;
|
||||
// Expand tabs for cursor line to measure pixel position
|
||||
std::string cur_expanded;
|
||||
cur_expanded.reserve(cur_line.size() + 16);
|
||||
std::size_t cur_rx = 0;
|
||||
for (std::size_t ci = 0; ci < cur_line.size(); ++ci) {
|
||||
if (cur_line[ci] == '\t') {
|
||||
std::size_t adv = tabw - (cur_rx % tabw);
|
||||
cur_expanded.append(adv, ' ');
|
||||
cur_rx += adv;
|
||||
} else {
|
||||
cur_expanded.push_back(cur_line[ci]);
|
||||
cur_rx += 1;
|
||||
}
|
||||
}
|
||||
// Compute rendered column of cursor
|
||||
std::size_t cursor_rx = 0;
|
||||
for (std::size_t ci = 0; ci < cx && ci < cur_line.size(); ++ci) {
|
||||
if (cur_line[ci] == '\t')
|
||||
cursor_rx += tabw - (cursor_rx % tabw);
|
||||
else
|
||||
cursor_rx += 1;
|
||||
}
|
||||
std::size_t exp_end = std::min(cur_expanded.size(), cursor_rx);
|
||||
if (exp_end > 0)
|
||||
cursor_px_abs = ImGui::CalcTextSize(cur_expanded.c_str(),
|
||||
cur_expanded.c_str() + exp_end).x;
|
||||
}
|
||||
if (cursor_px_abs < scroll_x_now || cursor_px_abs > scroll_x_now + child_w_actual) {
|
||||
float target_x = cursor_px_abs - (child_w_actual / 2.0f);
|
||||
if (target_x < 0.f)
|
||||
target_x = 0.f;
|
||||
float max_x = ImGui::GetScrollMaxX();
|
||||
if (max_x >= 0.f && target_x > max_x)
|
||||
target_x = max_x;
|
||||
ImGui::SetScrollX(target_x);
|
||||
}
|
||||
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
int fr = static_cast<int>(std::max(0L, first_row));
|
||||
int rc = static_cast<int>(std::max(1L, vis_rows));
|
||||
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleVar(2); // WindowPadding, ItemSpacing
|
||||
|
||||
// Status bar spanning full width
|
||||
ImGui::Separator();
|
||||
|
||||
// Compute full content width and draw a filled background rectangle
|
||||
// Status bar area starting right after the scroll child
|
||||
ImVec2 win_pos = ImGui::GetWindowPos();
|
||||
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
|
||||
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
|
||||
float x0 = win_pos.x + cr_min.x;
|
||||
float x1 = win_pos.x + cr_max.x;
|
||||
ImVec2 cursor = ImGui::GetCursorScreenPos();
|
||||
float bar_h = ImGui::GetFrameHeight();
|
||||
ImVec2 p0(x0, cursor.y);
|
||||
ImVec2 p1(x1, cursor.y + bar_h);
|
||||
ImVec2 win_sz = ImGui::GetWindowSize();
|
||||
float x0 = win_pos.x;
|
||||
float x1 = win_pos.x + win_sz.x;
|
||||
float y0 = ImGui::GetCursorScreenPos().y;
|
||||
float bar_h = real_bar_h;
|
||||
|
||||
ImVec2 p0(x0, y0);
|
||||
ImVec2 p1(x1, y0 + bar_h);
|
||||
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
|
||||
|
||||
// If a prompt is active, replace the entire status bar with the prompt text
|
||||
if (ed.PromptActive()) {
|
||||
std::string label = ed.PromptLabel();
|
||||
@@ -508,7 +662,7 @@ GUIRenderer::Draw(Editor &ed)
|
||||
(size_t) std::max<size_t>(
|
||||
1, (size_t) (tail.size() / 4)))
|
||||
: 1;
|
||||
start += skip;
|
||||
start += skip;
|
||||
std::string candidate = tail.substr(start);
|
||||
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
|
||||
if (cand_sz.x <= avail_px) {
|
||||
@@ -539,11 +693,9 @@ GUIRenderer::Draw(Editor &ed)
|
||||
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
|
||||
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(final_msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
} else {
|
||||
// Build left text
|
||||
std::string left;
|
||||
@@ -566,11 +718,11 @@ GUIRenderer::Draw(Editor &ed)
|
||||
std::size_t total = ed.BufferCount();
|
||||
if (total > 0) {
|
||||
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
}
|
||||
}
|
||||
left += fname;
|
||||
@@ -578,10 +730,10 @@ GUIRenderer::Draw(Editor &ed)
|
||||
left += " *";
|
||||
// Append total line count as "<n>L"
|
||||
{
|
||||
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
unsigned long lcount = static_cast<unsigned long>(buf->Nrows());
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
}
|
||||
|
||||
// Build right text (cursor/mark)
|
||||
@@ -619,20 +771,21 @@ GUIRenderer::Draw(Editor &ed)
|
||||
float max_left = std::max(0.0f, right_x - left_x - pad);
|
||||
if (max_left < left_sz.x && max_left > 10.0f) {
|
||||
// Render a clipped left using a child region
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::PushClipRect(ImVec2(left_x, y0), ImVec2(right_x - pad, y0 + bar_h),
|
||||
true);
|
||||
ImGui::TextUnformatted(left.c_str());
|
||||
ImGui::PopClipRect();
|
||||
}
|
||||
} else {
|
||||
// Draw left normally
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(left.c_str());
|
||||
}
|
||||
|
||||
// Draw right
|
||||
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
|
||||
p0.y + (bar_h - right_sz.y) * 0.5f));
|
||||
y0 + (bar_h - right_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(right.c_str());
|
||||
|
||||
// Draw middle message centered in remaining space
|
||||
@@ -644,14 +797,12 @@ GUIRenderer::Draw(Editor &ed)
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
|
||||
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
|
||||
// Clip to middle region
|
||||
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::PushClipRect(ImVec2(mid_left, y0), ImVec2(mid_right, y0 + bar_h), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(msg_x, y0 + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
}
|
||||
}
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,12 +938,8 @@ GUIRenderer::Draw(Editor &ed)
|
||||
ed.SetFilePickerDir(e.path.string());
|
||||
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
// Open file on single click
|
||||
std::string err;
|
||||
if (!ed.OpenFile(e.path.string(), err)) {
|
||||
ed.SetStatus(std::string("open: ") + err);
|
||||
} else {
|
||||
ed.SetStatus(std::string("Opened: ") + e.name);
|
||||
}
|
||||
ed.RequestOpenFile(e.path.string());
|
||||
(void) ed.ProcessPendingOpens();
|
||||
ed.SetFilePickerVisible(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include "Renderer.h"
|
||||
|
||||
struct ImFont;
|
||||
class Buffer;
|
||||
|
||||
class ImGuiRenderer final : public Renderer {
|
||||
public:
|
||||
ImGuiRenderer() = default;
|
||||
|
||||
~ImGuiRenderer() override = default;
|
||||
|
||||
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;
|
||||
};
|
||||
+7
-1
@@ -6,6 +6,8 @@
|
||||
|
||||
#include "Command.h"
|
||||
|
||||
class Editor; // fwd decl
|
||||
|
||||
|
||||
// Result of translating raw input into an editor command.
|
||||
struct MappedInput {
|
||||
@@ -19,7 +21,11 @@ class InputHandler {
|
||||
public:
|
||||
virtual ~InputHandler() = default;
|
||||
|
||||
// Optional: attach current Editor so handlers can consult editor state (e.g., universal argument)
|
||||
// Default implementation does nothing.
|
||||
virtual void Attach(Editor *) {}
|
||||
|
||||
// Poll for input and translate it to a command. Non-blocking.
|
||||
// Returns true if a command is available in 'out'. Returns false if no input.
|
||||
virtual bool Poll(MappedInput &out) = 0;
|
||||
};
|
||||
};
|
||||
|
||||
+32
-6
@@ -1,6 +1,4 @@
|
||||
#include <iostream>
|
||||
#include <ncurses.h>
|
||||
#include <ostream>
|
||||
|
||||
#include "KKeymap.h"
|
||||
|
||||
@@ -17,6 +15,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case 'd':
|
||||
out = CommandId::KillLine;
|
||||
return true;
|
||||
case 's':
|
||||
out = CommandId::Save;
|
||||
return true;
|
||||
case 'q':
|
||||
out = CommandId::QuitNow;
|
||||
return true;
|
||||
@@ -37,11 +38,29 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
||||
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) {
|
||||
case 'a':
|
||||
out = CommandId::MarkAllAndJumpEnd;
|
||||
return true;
|
||||
case ' ': // C-k SPACE
|
||||
out = CommandId::ToggleMark;
|
||||
return true;
|
||||
case 'i':
|
||||
out = CommandId::BufferNew; // C-k i new empty buffer
|
||||
return true;
|
||||
case 'k':
|
||||
out = CommandId::CenterOnCursor; // C-k k center current line
|
||||
return true;
|
||||
case 'b':
|
||||
out = CommandId::BufferSwitchStart;
|
||||
return true;
|
||||
@@ -54,9 +73,6 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case 'e':
|
||||
out = CommandId::OpenFileStart;
|
||||
return true;
|
||||
case 'E':
|
||||
std::cerr << "E is not a valid command" << std::endl;
|
||||
return false;
|
||||
case 'f':
|
||||
out = CommandId::FlushKillRing;
|
||||
return true;
|
||||
@@ -72,6 +88,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case 'l':
|
||||
out = CommandId::ReloadBuffer;
|
||||
return true;
|
||||
case 'm':
|
||||
out = CommandId::ToggleEditMode;
|
||||
return true;
|
||||
case 'n':
|
||||
out = CommandId::BufferPrev;
|
||||
return true;
|
||||
@@ -108,6 +127,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case '=':
|
||||
out = CommandId::IndentRegion;
|
||||
return true;
|
||||
case '/':
|
||||
out = CommandId::VisualLineModeToggle;
|
||||
return true;
|
||||
case ';':
|
||||
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
|
||||
return true;
|
||||
@@ -211,8 +233,12 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
|
||||
case 'q':
|
||||
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
|
||||
return true;
|
||||
case '\n':
|
||||
case '\r':
|
||||
out = CommandId::SmartNewline; // Shift+Enter (some terminals send this as Alt+Enter sequences)
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,4 @@ KLowerAscii(const int key)
|
||||
if (key >= 'A' && key <= 'Z')
|
||||
return key + ('a' - 'A');
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+806
-11
@@ -1,5 +1,8 @@
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
#include <limits>
|
||||
#include <ostream>
|
||||
|
||||
#include "PieceTable.h"
|
||||
|
||||
@@ -7,10 +10,16 @@
|
||||
PieceTable::PieceTable() = default;
|
||||
|
||||
|
||||
PieceTable::PieceTable(const std::size_t initialCapacity)
|
||||
PieceTable::PieceTable(const std::size_t initialCapacity,
|
||||
const std::size_t piece_limit,
|
||||
const std::size_t small_piece_threshold,
|
||||
const std::size_t max_consolidation_bytes)
|
||||
{
|
||||
add_.reserve(initialCapacity);
|
||||
materialized_.reserve(initialCapacity);
|
||||
piece_limit_ = piece_limit;
|
||||
small_piece_threshold_ = small_piece_threshold;
|
||||
max_consolidation_bytes_ = max_consolidation_bytes;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +29,13 @@ PieceTable::PieceTable(const PieceTable &other)
|
||||
pieces_(other.pieces_),
|
||||
materialized_(other.materialized_),
|
||||
dirty_(other.dirty_),
|
||||
total_size_(other.total_size_) {}
|
||||
total_size_(other.total_size_)
|
||||
{
|
||||
version_ = other.version_;
|
||||
// caches are per-instance, mark invalid
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
PieceTable &
|
||||
@@ -34,6 +49,12 @@ PieceTable::operator=(const PieceTable &other)
|
||||
materialized_ = other.materialized_;
|
||||
dirty_ = other.dirty_;
|
||||
total_size_ = other.total_size_;
|
||||
version_ = other.version_;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
last_deleted_ = {};
|
||||
piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
|
||||
InvalidateLineIndex();
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -48,6 +69,11 @@ PieceTable::PieceTable(PieceTable &&other) noexcept
|
||||
{
|
||||
other.dirty_ = true;
|
||||
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_;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +90,15 @@ PieceTable::operator=(PieceTable &&other) noexcept
|
||||
total_size_ = other.total_size_;
|
||||
other.dirty_ = true;
|
||||
other.total_size_ = 0;
|
||||
other.InvalidateLineIndex(); // its index described the moved-away text
|
||||
version_ = other.version_;
|
||||
range_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;
|
||||
}
|
||||
|
||||
@@ -71,14 +106,21 @@ PieceTable::operator=(PieceTable &&other) noexcept
|
||||
PieceTable::~PieceTable() = default;
|
||||
|
||||
|
||||
// Setter to allow tuning consolidation heuristics
|
||||
void
|
||||
PieceTable::Reserve(const std::size_t newCapacity)
|
||||
PieceTable::SetConsolidationParams(const std::size_t piece_limit,
|
||||
const std::size_t small_piece_threshold,
|
||||
const std::size_t max_consolidation_bytes)
|
||||
{
|
||||
add_.reserve(newCapacity);
|
||||
materialized_.reserve(newCapacity);
|
||||
piece_limit_ = piece_limit;
|
||||
small_piece_threshold_ = small_piece_threshold;
|
||||
max_consolidation_bytes_ = max_consolidation_bytes;
|
||||
}
|
||||
|
||||
|
||||
// (removed helper) — we'll invalidate caches inline inside mutating methods
|
||||
|
||||
|
||||
void
|
||||
PieceTable::AppendChar(char c)
|
||||
{
|
||||
@@ -147,10 +189,68 @@ void
|
||||
PieceTable::Clear()
|
||||
{
|
||||
pieces_.clear();
|
||||
std::string().swap(original_);
|
||||
add_.clear();
|
||||
materialized_.clear();
|
||||
total_size_ = 0;
|
||||
dirty_ = true;
|
||||
line_index_.clear();
|
||||
line_index_dirty_ = true;
|
||||
version_++;
|
||||
range_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);
|
||||
}
|
||||
|
||||
|
||||
@@ -168,9 +268,13 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
|
||||
std::size_t expectStart = last.start + last.len;
|
||||
|
||||
if (expectStart == start) {
|
||||
last.len += len;
|
||||
last.len += len;
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -178,7 +282,11 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
|
||||
|
||||
pieces_.push_back(Piece{src, start, len});
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -194,15 +302,23 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
|
||||
Piece &first = pieces_.front();
|
||||
if (first.src == src && start + len == first.start) {
|
||||
first.start = start;
|
||||
first.len += len;
|
||||
first.len += len;
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
}
|
||||
pieces_.insert(pieces_.begin(), Piece{src, start, len});
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -225,3 +341,682 @@ PieceTable::materialize() const
|
||||
// Ensure there is a null terminator present via std::string invariants
|
||||
dirty_ = false;
|
||||
}
|
||||
|
||||
|
||||
// ===== New Phase 1 implementation =====
|
||||
|
||||
std::pair<std::size_t, std::size_t>
|
||||
PieceTable::locate(const std::size_t byte_offset) const
|
||||
{
|
||||
if (byte_offset >= total_size_) {
|
||||
return {pieces_.size(), 0};
|
||||
}
|
||||
// Rebuilt once per modification: an edited buffer's lookups (two per
|
||||
// rendered line per frame) used to walk the piece list each time.
|
||||
if (piece_ends_version_ != version_ || piece_ends_.size() != pieces_.size()) {
|
||||
piece_ends_.resize(pieces_.size());
|
||||
std::size_t end = 0;
|
||||
for (std::size_t i = 0; i < pieces_.size(); ++i)
|
||||
piece_ends_[i] = end += pieces_[i].len;
|
||||
piece_ends_version_ = version_;
|
||||
}
|
||||
const auto it = std::upper_bound(piece_ends_.begin(), piece_ends_.end(), byte_offset);
|
||||
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)};
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::coalesceNeighbors(std::size_t index)
|
||||
{
|
||||
if (pieces_.empty())
|
||||
return;
|
||||
if (index >= pieces_.size())
|
||||
index = pieces_.size() - 1;
|
||||
// Merge repeatedly with previous while contiguous and same source
|
||||
while (index > 0) {
|
||||
auto &prev = pieces_[index - 1];
|
||||
auto &curr = pieces_[index];
|
||||
if (prev.src == curr.src && prev.start + prev.len == curr.start) {
|
||||
prev.len += curr.len;
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
index -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Merge repeatedly with next while contiguous and same source
|
||||
while (index + 1 < pieces_.size()) {
|
||||
auto &curr = pieces_[index];
|
||||
auto &next = pieces_[index + 1];
|
||||
if (curr.src == next.src && curr.start + curr.len == next.start) {
|
||||
curr.len += next.len;
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(index + 1));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::InvalidateLineIndex() const
|
||||
{
|
||||
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
|
||||
PieceTable::RebuildLineIndex() const
|
||||
{
|
||||
|
||||
if (!line_index_dirty_) {
|
||||
return;
|
||||
}
|
||||
line_index_.clear();
|
||||
line_index_.push_back(0);
|
||||
line_shift_from_ = std::numeric_limits<std::size_t>::max();
|
||||
line_shift_by_ = 0;
|
||||
|
||||
std::size_t pos = 0;
|
||||
for (const auto &pc: pieces_) {
|
||||
const std::string &src = pc.src == Source::Original ? original_ : add_;
|
||||
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
|
||||
|
||||
const char *end = base + pc.len;
|
||||
// The next line starts after each newline.
|
||||
for (const char *p = base; (p = static_cast<const char *>(std::memchr(p, '\n', end - p))) != nullptr; ++p)
|
||||
line_index_.push_back(pos + static_cast<std::size_t>(p - base) + 1);
|
||||
|
||||
pos += pc.len;
|
||||
}
|
||||
|
||||
line_index_dirty_ = false;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
|
||||
{
|
||||
if (len == 0) {
|
||||
return;
|
||||
}
|
||||
if (byte_offset > total_size_) {
|
||||
byte_offset = total_size_;
|
||||
}
|
||||
|
||||
last_deleted_ = {};
|
||||
const std::size_t add_start = add_.size();
|
||||
add_.append(text, len);
|
||||
|
||||
if (pieces_.empty()) {
|
||||
pieces_.push_back(Piece{Source::Add, add_start, len});
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
lineIndexOnInsert(byte_offset, add_.data() + add_start, len);
|
||||
maybeConsolidate();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
|
||||
auto [idx, inner] = locate(byte_offset);
|
||||
if (idx == pieces_.size()) {
|
||||
// insert at end
|
||||
pieces_.push_back(Piece{Source::Add, add_start, len});
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
lineIndexOnInsert(byte_offset, add_.data() + add_start, len);
|
||||
coalesceNeighbors(pieces_.size() - 1);
|
||||
maybeConsolidate();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
|
||||
Piece target = pieces_[idx];
|
||||
// Build replacement sequence: left, inserted, right
|
||||
std::vector<Piece> repl;
|
||||
repl.reserve(3);
|
||||
if (inner > 0) {
|
||||
repl.push_back(Piece{target.src, target.start, inner});
|
||||
}
|
||||
repl.push_back(Piece{Source::Add, add_start, len});
|
||||
const std::size_t right_len = target.len - inner;
|
||||
if (right_len > 0) {
|
||||
repl.push_back(Piece{target.src, target.start + inner, right_len});
|
||||
}
|
||||
|
||||
// Replace target with repl
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx));
|
||||
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx), repl.begin(), repl.end());
|
||||
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
lineIndexOnInsert(byte_offset, add_.data() + add_start, len);
|
||||
// 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);
|
||||
coalesceNeighbors(ins_index);
|
||||
maybeConsolidate();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::Delete(std::size_t byte_offset, std::size_t len)
|
||||
{
|
||||
if (len == 0) {
|
||||
return;
|
||||
}
|
||||
if (byte_offset >= total_size_) {
|
||||
return;
|
||||
}
|
||||
if (byte_offset + len > total_size_) {
|
||||
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);
|
||||
std::size_t remaining = len;
|
||||
|
||||
while (remaining > 0 && idx < pieces_.size()) {
|
||||
Piece &pc = pieces_[idx];
|
||||
std::size_t available = pc.len - inner; // bytes we can remove from this piece starting at inner
|
||||
std::size_t take = std::min(available, remaining);
|
||||
|
||||
// Compute lengths for left and right remnants
|
||||
std::size_t left_len = inner;
|
||||
std::size_t right_len = pc.len - inner - take;
|
||||
Source src = pc.src;
|
||||
std::size_t start = pc.start;
|
||||
|
||||
// Replace current piece with up to two remnants
|
||||
if (left_len > 0 && right_len > 0) {
|
||||
pc.len = left_len; // keep left in place
|
||||
Piece right{src, start + inner + take, right_len};
|
||||
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx + 1), right);
|
||||
idx += 1; // move to right for next iteration decision
|
||||
} else if (left_len > 0) {
|
||||
pc.len = left_len;
|
||||
// no insertion; idx now points to left; move to next piece
|
||||
} else if (right_len > 0) {
|
||||
pc.start = start + inner + take;
|
||||
pc.len = right_len;
|
||||
} else {
|
||||
// entire piece removed
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx));
|
||||
// stay at same idx for next piece
|
||||
inner = 0;
|
||||
remaining -= take;
|
||||
continue;
|
||||
}
|
||||
|
||||
// After modifying current idx, next deletion continues at beginning of the next logical region
|
||||
inner = 0;
|
||||
remaining -= take;
|
||||
if (remaining == 0)
|
||||
break;
|
||||
// Move to next piece
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
total_size_ -= len;
|
||||
dirty_ = true;
|
||||
lineIndexOnDelete(byte_offset, len);
|
||||
if (idx < pieces_.size())
|
||||
coalesceNeighbors(idx);
|
||||
if (idx > 0)
|
||||
coalesceNeighbors(idx - 1);
|
||||
maybeConsolidate();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
// ===== 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 =====
|
||||
|
||||
void
|
||||
PieceTable::appendPieceDataTo(std::string &out, const Piece &p) const
|
||||
{
|
||||
if (p.len == 0)
|
||||
return;
|
||||
const std::string &src = p.src == Source::Original ? original_ : add_;
|
||||
out.append(src.data() + static_cast<std::ptrdiff_t>(p.start), p.len);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::consolidateRange(std::size_t start_idx, std::size_t end_idx)
|
||||
{
|
||||
if (start_idx >= end_idx || start_idx >= pieces_.size())
|
||||
return;
|
||||
end_idx = std::min(end_idx, pieces_.size());
|
||||
std::size_t total = 0;
|
||||
for (std::size_t i = start_idx; i < end_idx; ++i)
|
||||
total += pieces_[i].len;
|
||||
if (total == 0)
|
||||
return;
|
||||
|
||||
const std::size_t add_start = add_.size();
|
||||
std::string tmp;
|
||||
tmp.reserve(std::min<std::size_t>(total, max_consolidation_bytes_));
|
||||
for (std::size_t i = start_idx; i < end_idx; ++i)
|
||||
appendPieceDataTo(tmp, pieces_[i]);
|
||||
add_.append(tmp);
|
||||
|
||||
// Replace [start_idx, end_idx) with single Add piece
|
||||
Piece consolidated{Source::Add, add_start, tmp.size()};
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx),
|
||||
pieces_.begin() + static_cast<std::ptrdiff_t>(end_idx));
|
||||
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx), consolidated);
|
||||
|
||||
// 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;
|
||||
coalesceNeighbors(start_idx);
|
||||
// Layout changed; invalidate caches/version
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::maybeConsolidate()
|
||||
{
|
||||
if (pieces_.size() <= piece_limit_)
|
||||
return;
|
||||
|
||||
// Find the first run of small pieces to consolidate
|
||||
std::size_t n = pieces_.size();
|
||||
std::size_t best_start = n, best_end = n;
|
||||
std::size_t i = 0;
|
||||
while (i < n) {
|
||||
// Skip large pieces quickly
|
||||
if (pieces_[i].len > small_piece_threshold_) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
std::size_t j = i;
|
||||
std::size_t bytes = 0;
|
||||
while (j < n) {
|
||||
const auto &p = pieces_[j];
|
||||
if (p.len > small_piece_threshold_)
|
||||
break;
|
||||
if (bytes + p.len > max_consolidation_bytes_)
|
||||
break;
|
||||
bytes += p.len;
|
||||
j++;
|
||||
}
|
||||
if (j - i >= 2 && bytes > 0) {
|
||||
// consolidate runs of at least 2 pieces
|
||||
best_start = i;
|
||||
best_end = j;
|
||||
break; // do one run per call; subsequent ops can repeat if still over limit
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
|
||||
if (best_start < best_end) {
|
||||
consolidateRange(best_start, best_end);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
PieceTable::LineCount() const
|
||||
{
|
||||
RebuildLineIndex();
|
||||
return line_index_.empty() ? 0 : line_index_.size();
|
||||
}
|
||||
|
||||
|
||||
std::pair<std::size_t, std::size_t>
|
||||
PieceTable::GetLineRange(std::size_t line_num) const
|
||||
{
|
||||
RebuildLineIndex();
|
||||
if (line_index_.empty())
|
||||
return {0, 0};
|
||||
if (line_num >= line_index_.size())
|
||||
return {0, 0};
|
||||
std::size_t start = lineStart(line_num);
|
||||
std::size_t end = (line_num + 1 < line_index_.size()) ? lineStart(line_num + 1) : total_size_;
|
||||
return {start, end};
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
PieceTable::GetLine(std::size_t line_num) const
|
||||
{
|
||||
auto [start, end] = GetLineRange(line_num);
|
||||
if (end < start)
|
||||
return std::string();
|
||||
// Every line but the last ends at a newline (each newline starts an
|
||||
// index entry); leave it out.
|
||||
if (end > start && line_num + 1 < line_index_.size())
|
||||
end -= 1;
|
||||
return GetRange(start, end - start);
|
||||
}
|
||||
|
||||
|
||||
std::pair<std::size_t, std::size_t>
|
||||
PieceTable::ByteOffsetToLineCol(std::size_t byte_offset) const
|
||||
{
|
||||
if (byte_offset > total_size_)
|
||||
byte_offset = total_size_;
|
||||
RebuildLineIndex();
|
||||
if (line_index_.empty())
|
||||
return {0, 0};
|
||||
const std::size_t ub = lineUpperBound(byte_offset);
|
||||
std::size_t row = ub == 0 ? 0 : ub - 1;
|
||||
std::size_t col = byte_offset - lineStart(row);
|
||||
return {row, col};
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
PieceTable::LineColToByteOffset(std::size_t row, std::size_t col) const
|
||||
{
|
||||
RebuildLineIndex();
|
||||
if (line_index_.empty())
|
||||
return 0;
|
||||
if (row >= line_index_.size())
|
||||
return total_size_;
|
||||
std::size_t start = lineStart(row);
|
||||
std::size_t end = (row + 1 < line_index_.size()) ? lineStart(row + 1) : total_size_;
|
||||
// Clamp col to the line's length without its newline (every line but
|
||||
// the last has one).
|
||||
if (end > start && row + 1 < line_index_.size())
|
||||
end -= 1;
|
||||
std::size_t target = start + std::min(col, end - start);
|
||||
return target;
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
||||
{
|
||||
if (byte_offset >= total_size_ || len == 0)
|
||||
return std::string();
|
||||
if (byte_offset + len > total_size_)
|
||||
len = total_size_ - byte_offset;
|
||||
|
||||
// Fast path: return cached value if version/offset/len match
|
||||
{
|
||||
if (range_cache_.valid && range_cache_.version == version_ &&
|
||||
range_cache_.off == byte_offset && range_cache_.len == len) {
|
||||
return range_cache_.data;
|
||||
}
|
||||
}
|
||||
|
||||
std::string out;
|
||||
out.reserve(len);
|
||||
if (!dirty_) {
|
||||
// Already materialized; slice directly
|
||||
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
|
||||
} else {
|
||||
// Assemble substring directly from pieces without full materialization
|
||||
auto [idx, inner] = locate(byte_offset);
|
||||
std::size_t remaining = len;
|
||||
while (remaining > 0 && idx < pieces_.size()) {
|
||||
const auto &p = pieces_[idx];
|
||||
const std::string &src = (p.src == Source::Original) ? original_ : add_;
|
||||
std::size_t take = std::min<std::size_t>(p.len - inner, remaining);
|
||||
if (take > 0) {
|
||||
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start + inner);
|
||||
out.append(base, take);
|
||||
remaining -= take;
|
||||
inner = 0;
|
||||
idx += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
{
|
||||
range_cache_.valid = true;
|
||||
range_cache_.version = version_;
|
||||
range_cache_.off = byte_offset;
|
||||
range_cache_.len = len;
|
||||
range_cache_.data = out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
PieceTable::Find(const std::string &needle, std::size_t start) const
|
||||
{
|
||||
if (needle.empty())
|
||||
return start <= total_size_ ? start : std::numeric_limits<std::size_t>::max();
|
||||
if (start > total_size_)
|
||||
return std::numeric_limits<std::size_t>::max();
|
||||
{
|
||||
if (find_cache_.valid &&
|
||||
find_cache_.version == version_ &&
|
||||
find_cache_.needle == needle &&
|
||||
find_cache_.start == start) {
|
||||
return find_cache_.result;
|
||||
}
|
||||
}
|
||||
|
||||
materialize();
|
||||
std::size_t pos;
|
||||
{
|
||||
pos = materialized_.find(needle, start);
|
||||
if (pos == std::string::npos)
|
||||
pos = std::numeric_limits<std::size_t>::max();
|
||||
// Update cache
|
||||
find_cache_.valid = true;
|
||||
find_cache_.version = version_;
|
||||
find_cache_.needle = needle;
|
||||
find_cache_.start = start;
|
||||
find_cache_.result = pos;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::WriteToStream(std::ostream &out) const
|
||||
{
|
||||
// Stream the content piece-by-piece without forcing full materialization
|
||||
// No lock needed for original_ and add_ if they are not being modified.
|
||||
// Since this is a const method and kte's piece table isn't modified by multiple threads
|
||||
// (only queried), we just iterate pieces_.
|
||||
for (const auto &p: pieces_) {
|
||||
if (p.len == 0)
|
||||
continue;
|
||||
const std::string &src = (p.src == Source::Original) ? original_ : add_;
|
||||
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start);
|
||||
out.write(base, static_cast<std::streamsize>(p.len));
|
||||
}
|
||||
}
|
||||
|
||||
+223
-16
@@ -1,17 +1,63 @@
|
||||
/*
|
||||
* PieceTable.h - Alternative to GapBuffer using a piece table representation
|
||||
*
|
||||
* PieceTable is kte's core text storage data structure. It provides efficient
|
||||
* insert/delete operations without copying the entire buffer by maintaining a
|
||||
* sequence of "pieces" that reference ranges in two underlying buffers:
|
||||
* - original_: A loaded file's content (AdoptOriginal), never modified
|
||||
* - add_: All text added during editing
|
||||
*
|
||||
* Key advantages:
|
||||
* - O(1) append/prepend operations (common case)
|
||||
* - O(n) insert/delete at arbitrary positions (n = number of pieces, not bytes)
|
||||
* - Efficient undo: just restore the piece list
|
||||
* - Memory efficient: no gap buffer waste
|
||||
*
|
||||
* Performance characteristics:
|
||||
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
|
||||
* - Materialization (Data() call) is O(total_size) but cached until next edit;
|
||||
* a single-piece table (an unedited file) is viewed in place, not copied
|
||||
* - Line index is lazily rebuilt on first line-based query after edits
|
||||
* - Range and Find operations use lightweight caches for repeated queries
|
||||
*
|
||||
* API evolution:
|
||||
* 1. Legacy API (GapBuffer compatibility):
|
||||
* - Append/Prepend: Build content sequentially
|
||||
* - Data(): Materialize entire buffer
|
||||
*
|
||||
* 2. New buffer-wide API (Phase 1):
|
||||
* - Insert/Delete: Edit at arbitrary byte offsets
|
||||
* - Line-based queries: LineCount, GetLine, GetLineRange
|
||||
* - Position conversion: ByteOffsetToLineCol, LineColToByteOffset
|
||||
* - Efficient extraction: GetRange, Find, WriteToStream
|
||||
*
|
||||
* Implementation notes:
|
||||
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
|
||||
* - Not thread-safe: even reads update caches. Buffers are used from the
|
||||
* main thread only (the swap writer thread gets copies of the bytes).
|
||||
* - Version tracking invalidates caches on mutations
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <ostream>
|
||||
#include <vector>
|
||||
#include <limits>
|
||||
#include <string_view>
|
||||
|
||||
#include "TextSpan.h"
|
||||
|
||||
|
||||
class PieceTable {
|
||||
public:
|
||||
PieceTable();
|
||||
|
||||
explicit PieceTable(std::size_t initialCapacity);
|
||||
// Advanced constructor allowing configuration of consolidation heuristics
|
||||
PieceTable(std::size_t initialCapacity,
|
||||
std::size_t piece_limit,
|
||||
std::size_t small_piece_threshold,
|
||||
std::size_t max_consolidation_bytes);
|
||||
|
||||
PieceTable(const PieceTable &other);
|
||||
|
||||
@@ -23,9 +69,6 @@ public:
|
||||
|
||||
~PieceTable();
|
||||
|
||||
// Public API mirrors GapBuffer
|
||||
void Reserve(std::size_t newCapacity);
|
||||
|
||||
void AppendChar(char c);
|
||||
|
||||
void Append(const char *s, std::size_t len);
|
||||
@@ -41,18 +84,40 @@ public:
|
||||
// Content management
|
||||
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
|
||||
char *Data()
|
||||
// The whole text, contiguous; nullptr when empty. Valid until the next
|
||||
// modification.
|
||||
[[nodiscard]] const char *Data() const
|
||||
{
|
||||
materialize();
|
||||
return materialized_.empty() ? nullptr : materialized_.data();
|
||||
const std::string_view v = ContentView();
|
||||
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();
|
||||
return materialized_.empty() ? nullptr : materialized_.data();
|
||||
for (const Piece &p: pieces_) {
|
||||
if (p.len > 0)
|
||||
fn((p.src == Source::Original ? original_ : add_).data() + p.start, p.len);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,12 +127,67 @@ public:
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t Capacity() const
|
||||
// ===== New buffer-wide API (Phase 1) =====
|
||||
// Byte-based editing operations
|
||||
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
||||
|
||||
void Delete(std::size_t byte_offset, std::size_t len);
|
||||
|
||||
// Line-based queries
|
||||
[[nodiscard]] std::size_t LineCount() const; // number of logical lines
|
||||
[[nodiscard]] std::string GetLine(std::size_t line_num) const;
|
||||
|
||||
[[nodiscard]] std::pair<std::size_t, std::size_t> GetLineRange(std::size_t line_num) const; // [start,end)
|
||||
|
||||
// Position conversion
|
||||
[[nodiscard]] std::pair<std::size_t, std::size_t> ByteOffsetToLineCol(std::size_t byte_offset) const;
|
||||
|
||||
[[nodiscard]] std::size_t LineColToByteOffset(std::size_t row, std::size_t col) const;
|
||||
|
||||
// Substring extraction
|
||||
[[nodiscard]] std::string GetRange(std::size_t byte_offset, std::size_t len) const;
|
||||
|
||||
// Simple search utility; returns byte offset or npos
|
||||
[[nodiscard]] std::size_t Find(const std::string &needle, std::size_t start = 0) const;
|
||||
|
||||
// Stream out content without materializing the entire buffer
|
||||
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
|
||||
{
|
||||
// Capacity for piece table isn't directly meaningful; report materialized capacity
|
||||
return materialized_.capacity();
|
||||
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
|
||||
void SetConsolidationParams(std::size_t piece_limit,
|
||||
std::size_t small_piece_threshold,
|
||||
std::size_t max_consolidation_bytes);
|
||||
|
||||
private:
|
||||
enum class Source : unsigned char { Original, Add };
|
||||
|
||||
@@ -83,12 +203,99 @@ private:
|
||||
|
||||
void materialize() const;
|
||||
|
||||
// Helper: locate piece index and inner offset for a global byte offset
|
||||
[[nodiscard]] std::pair<std::size_t, std::size_t> locate(std::size_t byte_offset) const;
|
||||
|
||||
// Helper: try to coalesce neighboring pieces around index
|
||||
void coalesceNeighbors(std::size_t index);
|
||||
|
||||
// Consolidation helpers and heuristics
|
||||
void maybeConsolidate();
|
||||
|
||||
void consolidateRange(std::size_t start_idx, std::size_t end_idx);
|
||||
|
||||
void appendPieceDataTo(std::string &out, const Piece &p) const;
|
||||
|
||||
// Line index support (rebuilt lazily on demand)
|
||||
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;
|
||||
|
||||
// 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::vector<Piece> pieces_;
|
||||
|
||||
mutable std::string materialized_;
|
||||
mutable bool dirty_ = true;
|
||||
std::size_t total_size_ = 0;
|
||||
mutable bool dirty_ = true;
|
||||
// Monotonic content version. Increment on any mutation that affects content layout
|
||||
mutable std::uint64_t version_ = 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)
|
||||
mutable std::vector<std::size_t> line_index_;
|
||||
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
|
||||
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
|
||||
std::size_t small_piece_threshold_ = 64; // bytes
|
||||
std::size_t max_consolidation_bytes_ = 4096; // cap per consolidation run
|
||||
|
||||
// Lightweight caches to avoid redundant work when callers query the same range repeatedly
|
||||
struct RangeCache {
|
||||
bool valid = false;
|
||||
std::uint64_t version = 0;
|
||||
std::size_t off = 0;
|
||||
std::size_t len = 0;
|
||||
std::string data;
|
||||
};
|
||||
|
||||
struct FindCache {
|
||||
bool valid = false;
|
||||
std::uint64_t version = 0;
|
||||
std::string needle;
|
||||
std::size_t start = 0;
|
||||
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 FindCache find_cache_;
|
||||
};
|
||||
@@ -32,27 +32,27 @@ Project Goals
|
||||
|
||||
Keybindings
|
||||
-----------
|
||||
kte maintains ke’s command model while internals evolve. Highlights (subject to refinement):
|
||||
kte maintains ke’s command model while internals evolve. Highlights (
|
||||
subject to refinement):
|
||||
|
||||
- K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or
|
||||
`C-g`.
|
||||
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
|
||||
`C-k q` (quit with confirm), `C-k C-q` (quit immediately).
|
||||
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-k
|
||||
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u`
|
||||
(universal argument).
|
||||
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
|
||||
region), `C-y` (yank), `C-u` (universal argument).
|
||||
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
||||
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
|
||||
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
|
||||
(close), `C-k C-r` (reload).
|
||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g`
|
||||
(goto line).
|
||||
(close), `C-k l` (reload).
|
||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
|
||||
|
||||
See `ke.md` for the canonical ke reference retained for now.
|
||||
|
||||
Build and Run
|
||||
-------------
|
||||
Prerequisites: C++17 compiler, CMake, and ncurses development headers/libs.
|
||||
Prerequisites: C++20 compiler, CMake, and ncurses development
|
||||
headers/libs.
|
||||
|
||||
Dependencies by platform
|
||||
------------------------
|
||||
@@ -62,30 +62,38 @@ Dependencies by platform
|
||||
- `brew install ncurses`
|
||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||
- `brew install sdl2 freetype`
|
||||
- OpenGL is provided by the system framework on macOS; no package needed.
|
||||
- OpenGL is provided by the system framework on macOS; no
|
||||
package needed.
|
||||
|
||||
- Debian/Ubuntu
|
||||
- Terminal (default):
|
||||
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||
- `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
|
||||
- The `mesa-common-dev` package provides OpenGL headers/libs (`libGL`).
|
||||
-
|
||||
`sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
|
||||
- The `mesa-common-dev` package provides OpenGL headers/libs (
|
||||
`libGL`).
|
||||
|
||||
- NixOS/Nix
|
||||
- Terminal (default):
|
||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
||||
- With flakes/devshell (example `flake.nix` inputs not provided): include
|
||||
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell.
|
||||
- Ad-hoc shell:
|
||||
`nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
||||
- With flakes/devshell (example `flake.nix` inputs not provided):
|
||||
include
|
||||
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your
|
||||
devShell.
|
||||
|
||||
Notes
|
||||
-----
|
||||
|
||||
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable it by
|
||||
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable
|
||||
it by
|
||||
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
|
||||
installed for your platform.
|
||||
- If you previously configured with GUI ON and want to disable it, reconfigure
|
||||
- If you previously configured with GUI ON and want to disable it,
|
||||
reconfigure
|
||||
the build directory with `-DBUILD_GUI=OFF`.
|
||||
|
||||
Example build:
|
||||
@@ -113,7 +121,8 @@ built as `kge`) or request the GUI from `kte`:
|
||||
GUI build example
|
||||
-----------------
|
||||
|
||||
To build with the optional GUI (after installing the GUI dependencies listed above):
|
||||
To build with the optional GUI (after installing the GUI dependencies
|
||||
listed above):
|
||||
|
||||
```
|
||||
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON
|
||||
|
||||
+2501
File diff suppressed because it is too large
Load Diff
@@ -8,5 +8,6 @@ ROADMAP / TODO:
|
||||
- [x] When the filename is longer than the message window, scoot left to
|
||||
keep it in view
|
||||
- [x] Syntax highlighting
|
||||
- [ ] Swap files (crash recovery). See `docs/plans/swap-files.md`
|
||||
- [ ] The undo system should actually work
|
||||
- [ ] LSP integration
|
||||
|
||||
+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
|
||||
+1
-1
@@ -10,4 +10,4 @@ public:
|
||||
virtual ~Renderer() = default;
|
||||
|
||||
virtual void Draw(Editor &ed) = 0;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,366 @@
|
||||
// Swap.h - swap journal (crash recovery) writer/manager for kte
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
|
||||
#include "SwapRecorder.h"
|
||||
#include "ErrorRecovery.h"
|
||||
|
||||
class Buffer;
|
||||
|
||||
namespace kte {
|
||||
// Minimal record types for stage 1
|
||||
enum class SwapRecType : std::uint8_t {
|
||||
INS = 1,
|
||||
DEL = 2,
|
||||
// SPLIT and JOIN are no longer written; replay still reads them.
|
||||
SPLIT = 3,
|
||||
JOIN = 4,
|
||||
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,
|
||||
};
|
||||
|
||||
struct SwapConfig {
|
||||
// Grouping and durability knobs (stage 1 defaults)
|
||||
unsigned flush_interval_ms{200}; // group small writes
|
||||
unsigned fsync_interval_ms{1000}; // at most once per second
|
||||
|
||||
// Checkpoint/compaction knobs (stage 2 defaults)
|
||||
// A checkpoint is a full snapshot of the buffer content written as a CHKPT record.
|
||||
// Compaction rewrites the swap file to contain just the latest checkpoint.
|
||||
std::size_t checkpoint_bytes{1024 * 1024}; // request checkpoint after this many queued edit-bytes
|
||||
unsigned checkpoint_interval_ms{60000}; // request checkpoint at least this often while editing
|
||||
std::size_t compact_bytes{8 * 1024 * 1024}; // compact on checkpoint once journal grows beyond this
|
||||
|
||||
// Cleanup / retention (best-effort)
|
||||
bool prune_on_startup{true};
|
||||
unsigned prune_max_age_days{30};
|
||||
std::size_t prune_max_files{2048};
|
||||
};
|
||||
|
||||
// SwapManager manages sidecar swap files and a single background writer thread.
|
||||
class SwapManager final {
|
||||
public:
|
||||
SwapManager();
|
||||
|
||||
~SwapManager();
|
||||
|
||||
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
|
||||
void Attach(Buffer *buf);
|
||||
|
||||
// Detach and close journal.
|
||||
// If remove_file is true, the swap file is deleted after closing.
|
||||
// Intended for clean shutdown/close flows.
|
||||
void Detach(Buffer *buf, bool remove_file = false);
|
||||
|
||||
// Reset (truncate-by-delete) the journal for a buffer after a clean save.
|
||||
// Best-effort: closes the current fd, deletes the swap file, and resumes recording.
|
||||
void ResetJournal(Buffer &buf);
|
||||
|
||||
// Best-effort pruning of old swap files under the swap directory.
|
||||
// Never touches non-`.swp` files.
|
||||
void PruneSwapDir();
|
||||
|
||||
// Block until all currently queued records have been written.
|
||||
// If buf is non-null, flushes all records (stage 1) but is primarily intended
|
||||
// for tests and shutdown.
|
||||
void Flush(Buffer *buf = nullptr);
|
||||
|
||||
// Request a full-content checkpoint record for one buffer (or all buffers if buf is null).
|
||||
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
||||
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)
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
cfg_ = cfg;
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
|
||||
// Obtain a per-buffer recorder adapter that emits records for that buffer.
|
||||
// The returned pointer is owned by the SwapManager and remains valid until
|
||||
// Detach(buf) or SwapManager destruction.
|
||||
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)
|
||||
void NotifyFilenameChanged(Buffer &buf);
|
||||
|
||||
// Replay a swap journal into an already-open buffer.
|
||||
// On success, the buffer content reflects all valid journal records.
|
||||
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
|
||||
// state results from applying records up to the failure point; callers should
|
||||
// treat this as a recovery failure and surface `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.
|
||||
// Returns empty string if filename is empty.
|
||||
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.
|
||||
// (Avoid duplicating naming rules in unit 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)
|
||||
{
|
||||
return ComputeSidecarPath(buf);
|
||||
}
|
||||
#endif
|
||||
|
||||
// RAII guard to suspend recording for internal operations
|
||||
class SuspendGuard {
|
||||
public:
|
||||
SuspendGuard(SwapManager &m, Buffer *b);
|
||||
|
||||
~SuspendGuard();
|
||||
|
||||
private:
|
||||
SwapManager &m_;
|
||||
Buffer *buf_;
|
||||
bool prev_;
|
||||
};
|
||||
|
||||
// Per-buffer toggle
|
||||
void SetSuspended(Buffer &buf, bool on);
|
||||
|
||||
// Error reporting for background thread
|
||||
struct SwapError {
|
||||
std::uint64_t timestamp_ns{0};
|
||||
std::string message;
|
||||
std::string buffer_name; // filename or "<unnamed>"
|
||||
};
|
||||
|
||||
// Query error state (thread-safe)
|
||||
bool HasErrors() const;
|
||||
|
||||
std::string GetLastError() const;
|
||||
|
||||
std::size_t GetErrorCount() const;
|
||||
|
||||
private:
|
||||
class BufferRecorder final : public SwapRecorder {
|
||||
public:
|
||||
BufferRecorder(SwapManager &m, Buffer &b) : m_(m), buf_(b) {}
|
||||
|
||||
void OnInsert(int row, int col, std::string_view bytes) override;
|
||||
|
||||
void OnDelete(int row, int col, std::size_t len) override;
|
||||
|
||||
private:
|
||||
SwapManager &m_;
|
||||
Buffer &buf_;
|
||||
};
|
||||
|
||||
void RecordInsert(Buffer &buf, int row, int col, std::string_view text);
|
||||
|
||||
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
|
||||
|
||||
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
||||
|
||||
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
||||
|
||||
struct JournalCtx {
|
||||
std::string path;
|
||||
int fd{-1};
|
||||
bool header_ok{false};
|
||||
bool suspended{false};
|
||||
std::uint64_t last_flush_ns{0};
|
||||
std::uint64_t last_fsync_ns{0};
|
||||
std::uint64_t last_chkpt_ns{0};
|
||||
std::uint64_t edit_bytes_since_chkpt{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 {
|
||||
Buffer *buf{nullptr};
|
||||
SwapRecType type{SwapRecType::INS};
|
||||
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};
|
||||
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
|
||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||
|
||||
static std::string ComputeSidecarPathForFilename(const std::string &filename);
|
||||
|
||||
static std::uint64_t now_ns();
|
||||
|
||||
static bool ensure_parent_dir(const std::string &path);
|
||||
|
||||
static std::string SwapDirRoot();
|
||||
|
||||
static bool write_header(int fd, const JournalCtx &ctx);
|
||||
|
||||
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err, bool *locked_out = nullptr);
|
||||
|
||||
static void close_ctx(JournalCtx &ctx);
|
||||
|
||||
static bool compact_to_checkpoint(JournalCtx &ctx, const RecordFrames &chkpt_record,
|
||||
std::string &err);
|
||||
|
||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||
|
||||
// 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_le64(std::uint8_t dst[8], std::uint64_t v);
|
||||
|
||||
static void put_u24_le(std::uint8_t dst[3], std::uint32_t v);
|
||||
|
||||
void enqueue(Pending &&p);
|
||||
|
||||
void writer_loop();
|
||||
|
||||
void process_one(const Pending &p);
|
||||
|
||||
// Error reporting helper (called from writer thread)
|
||||
void report_error(const std::string &message, Buffer *buf = nullptr);
|
||||
|
||||
// State
|
||||
SwapConfig cfg_{};
|
||||
std::unordered_map<Buffer *, JournalCtx> journals_;
|
||||
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
||||
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::vector<Pending> queue_;
|
||||
std::uint64_t next_seq_{0};
|
||||
std::uint64_t last_processed_{0};
|
||||
std::uint64_t inflight_{0};
|
||||
std::atomic<bool> running_{false};
|
||||
std::thread worker_;
|
||||
|
||||
// Error tracking (protected by mtx_)
|
||||
std::deque<SwapError> errors_; // bounded to max 100 entries
|
||||
std::deque<std::string> user_notices_; // see TakeUserNotice(); guarded by mtx_
|
||||
|
||||
// Queue a message for the user (mtx_ must be held).
|
||||
void notify_user_locked_(std::string msg);
|
||||
std::size_t total_error_count_{0};
|
||||
|
||||
// Circuit breaker for swap operations (protected by mtx_)
|
||||
CircuitBreaker circuit_breaker_;
|
||||
};
|
||||
} // namespace kte
|
||||
@@ -0,0 +1,19 @@
|
||||
// SwapRecorder.h - minimal swap journal recording interface for Buffer mutations
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string_view>
|
||||
|
||||
namespace kte {
|
||||
// SwapRecorder is a tiny, non-blocking callback interface.
|
||||
// Implementations must return quickly; Buffer calls these hooks after a
|
||||
// mutation succeeds.
|
||||
class SwapRecorder {
|
||||
public:
|
||||
virtual ~SwapRecorder() = default;
|
||||
|
||||
virtual void OnInsert(int row, int col, std::string_view bytes) = 0;
|
||||
|
||||
virtual void OnDelete(int row, int col, std::size_t len) = 0;
|
||||
};
|
||||
} // namespace kte
|
||||
@@ -0,0 +1,89 @@
|
||||
#include "SyscallWrappers.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace kte {
|
||||
namespace syscall {
|
||||
int
|
||||
Open(const char *path, int flags, mode_t mode)
|
||||
{
|
||||
int fd;
|
||||
do {
|
||||
fd = ::open(path, flags, mode);
|
||||
} while (fd == -1 && errno == EINTR);
|
||||
return fd;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Close(int fd)
|
||||
{
|
||||
// Do not retry on EINTR: on Linux (and most systems) the descriptor is
|
||||
// released even when close() is interrupted, so a retry could close an
|
||||
// fd that another thread (e.g. the swap writer) has just opened.
|
||||
const int ret = ::close(fd);
|
||||
if (ret == -1 && errno == EINTR)
|
||||
return 0;
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Fsync(int fd)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::fsync(fd);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Fstat(int fd, struct stat *buf)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::fstat(fd, buf);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Ftruncate(int fd, off_t length)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::ftruncate(fd, length);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Fchmod(int fd, mode_t mode)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::fchmod(fd, mode);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Mkstemp(char *template_str)
|
||||
{
|
||||
int fd;
|
||||
do {
|
||||
fd = ::mkstemp(template_str);
|
||||
} while (fd == -1 && errno == EINTR);
|
||||
return fd;
|
||||
}
|
||||
} // namespace syscall
|
||||
} // namespace kte
|
||||
@@ -0,0 +1,51 @@
|
||||
// SyscallWrappers.h - EINTR-safe syscall wrappers for kte
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <sys/stat.h>
|
||||
|
||||
namespace kte {
|
||||
namespace syscall {
|
||||
// EINTR-safe wrapper for open(2).
|
||||
// Returns file descriptor on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Open(const char *path, int flags, mode_t mode = 0);
|
||||
|
||||
// Wrapper for close(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// EINTR is treated as success and never retried: the descriptor has already
|
||||
// been released on Linux, and retrying could close an unrelated, reused fd.
|
||||
int Close(int fd);
|
||||
|
||||
// EINTR-safe wrapper for fsync(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Fsync(int fd);
|
||||
|
||||
// EINTR-safe wrapper for fstat(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Fstat(int fd, struct stat *buf);
|
||||
|
||||
// EINTR-safe wrapper for fchmod(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Fchmod(int fd, mode_t mode);
|
||||
|
||||
// EINTR-safe wrapper for ftruncate(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Ftruncate(int fd, off_t length);
|
||||
|
||||
// EINTR-safe wrapper for mkstemp(3).
|
||||
// Returns file descriptor on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
// Note: template_str must be a mutable buffer ending in "XXXXXX".
|
||||
int Mkstemp(char *template_str);
|
||||
|
||||
// Note: rename(2) and unlink(2) are not wrapped because they operate on
|
||||
// filesystem metadata and typically complete atomically without EINTR.
|
||||
// If interrupted, they either succeed or fail without partial state.
|
||||
} // namespace syscall
|
||||
} // namespace kte
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// TermWidth.h - terminal cell width of a decoded character
|
||||
//
|
||||
// Shared by TerminalRenderer (drawing and cursor placement) and the command
|
||||
// layer (horizontal scrolling, mouse column mapping) so they always agree.
|
||||
// Tabs are handled by the callers, which know the current column.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cwchar>
|
||||
#include <string_view>
|
||||
|
||||
namespace kte {
|
||||
// Tab stops every kTabWidth columns, in every frontend and in the command
|
||||
// layer's horizontal scrolling (they must agree, or the cursor and the
|
||||
// scroll offset are misplaced on lines with tabs).
|
||||
constexpr std::size_t kTabWidth = 8;
|
||||
|
||||
|
||||
// C0 controls and DEL are drawn as caret notation ("^M", "^?"): two cells.
|
||||
// ncurses would otherwise act on them (carriage return, backspace) and
|
||||
// corrupt the line.
|
||||
inline bool
|
||||
IsCaretControl(wchar_t wch)
|
||||
{
|
||||
return (wch >= 0 && wch < 0x20) || wch == 0x7f;
|
||||
}
|
||||
|
||||
|
||||
// C1 controls (U+0080..U+009F, also what a stray byte in that range decodes
|
||||
// to) are drawn as '?': sent raw, U+009B is a terminal CSI.
|
||||
inline bool
|
||||
IsC1Control(wchar_t wch)
|
||||
{
|
||||
return wch >= 0x80 && wch <= 0x9f;
|
||||
}
|
||||
|
||||
|
||||
inline int
|
||||
CellWidth(wchar_t wch)
|
||||
{
|
||||
if (IsCaretControl(wch))
|
||||
return 2;
|
||||
if (IsC1Control(wch))
|
||||
return 1;
|
||||
const int w = wcwidth(wch);
|
||||
return w < 0 ? 1 : w;
|
||||
}
|
||||
|
||||
|
||||
// Decode the character at line[i]: its byte length and display width in
|
||||
// cells (a tab reaches the next multiple of tabw from column col). Measures
|
||||
// the way TerminalRenderer draws: an invalid byte or NUL is one byte, measured
|
||||
// as the byte value.
|
||||
inline void
|
||||
MeasureChar(std::string_view line, std::size_t i, std::size_t col, std::size_t &len, std::size_t &width,
|
||||
std::size_t tabw = kTabWidth)
|
||||
{
|
||||
std::mbstate_t state{};
|
||||
wchar_t wch = 0;
|
||||
const auto rc = std::mbrtowc(&wch, line.data() + i, line.size() - i, &state);
|
||||
if (rc == static_cast<std::size_t>(-1) || rc == static_cast<std::size_t>(-2) || rc == 0) {
|
||||
wch = static_cast<unsigned char>(line[i]);
|
||||
len = 1;
|
||||
} else {
|
||||
len = rc;
|
||||
}
|
||||
if (wch == L'\t')
|
||||
width = tabw - (col % tabw);
|
||||
else
|
||||
width = static_cast<std::size_t>(CellWidth(wch));
|
||||
}
|
||||
} // namespace kte
|
||||
+86
-14
@@ -1,3 +1,6 @@
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <ncurses.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
@@ -8,8 +11,16 @@
|
||||
|
||||
|
||||
bool
|
||||
TerminalFrontend::Init(Editor &ed)
|
||||
TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
{
|
||||
(void) argc;
|
||||
(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)
|
||||
{
|
||||
struct termios tio{};
|
||||
@@ -41,20 +52,41 @@ TerminalFrontend::Init(Editor &ed)
|
||||
// Enable 8-bit meta key sequences (Alt/ESC-prefix handling in terminals)
|
||||
meta(stdscr, TRUE);
|
||||
// Make ESC key sequences resolve quickly so ESC+<key> works as meta
|
||||
#ifdef set_escdelay
|
||||
set_escdelay(50);
|
||||
// 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);
|
||||
#endif
|
||||
nodelay(stdscr, TRUE);
|
||||
// Make getch() block briefly instead of busy-looping; reduces CPU when idle
|
||||
// Equivalent to nodelay(FALSE) with a small timeout.
|
||||
timeout(16); // ~16ms (about 60Hz)
|
||||
curs_set(1);
|
||||
// Enable mouse support if available
|
||||
mouseinterval(0);
|
||||
mousemask(ALL_MOUSE_EVENTS, nullptr);
|
||||
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, nullptr);
|
||||
|
||||
int r = 0, c = 0;
|
||||
getmaxyx(stdscr, r, c);
|
||||
prev_r_ = r;
|
||||
prev_c_ = c;
|
||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||
// Attach editor to input handler for editor-owned features (e.g., universal argument)
|
||||
input_.Attach(&ed);
|
||||
|
||||
// Ignore SIGINT (Ctrl-C) so it doesn't terminate the TUI.
|
||||
// We'll restore the previous handler on Shutdown().
|
||||
{
|
||||
struct sigaction sa{};
|
||||
sa.sa_handler = SIG_IGN;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = 0;
|
||||
struct sigaction old{};
|
||||
if (sigaction(SIGINT, &sa, &old) == 0) {
|
||||
old_sigint_ = old;
|
||||
have_old_sigint_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -65,29 +97,64 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
||||
// Handle resize and keep editor dimensions synced
|
||||
int r, c;
|
||||
getmaxyx(stdscr, r, c);
|
||||
bool changed = first_frame_;
|
||||
if (r != prev_r_ || c != prev_c_) {
|
||||
resizeterm(r, c);
|
||||
clear();
|
||||
prev_r_ = r;
|
||||
prev_c_ = c;
|
||||
changed = true;
|
||||
}
|
||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||
|
||||
MappedInput mi;
|
||||
if (input_.Poll(mi)) {
|
||||
if (mi.hasCommand) {
|
||||
Execute(ed, mi.id, mi.arg, mi.count);
|
||||
}
|
||||
} else {
|
||||
// Avoid busy loop
|
||||
usleep(1000);
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
// Its return value only reports an opened buffer; a deferred open that
|
||||
// starts a prompt or sets a status must be drawn too.
|
||||
{
|
||||
const bool prompt_before = ed.PromptActive();
|
||||
const std::string status_before = ed.Status();
|
||||
const std::time_t status_time_before = ed.StatusTime();
|
||||
if (ed.ProcessPendingOpens())
|
||||
changed = true;
|
||||
if (ed.PromptActive() != prompt_before || ed.Status() != status_before ||
|
||||
ed.StatusTime() != status_time_before)
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Handle all pending input before drawing: one key per frame made a
|
||||
// pasted block take a frame (and a full redraw) per character. The first
|
||||
// read waits up to the configured timeout; the rest do not wait.
|
||||
MappedInput mi;
|
||||
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()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,5 +166,10 @@ TerminalFrontend::Shutdown()
|
||||
(void) tcsetattr(STDIN_FILENO, TCSANOW, &orig_tio_);
|
||||
have_orig_tio_ = false;
|
||||
}
|
||||
// Restore previous SIGINT handler
|
||||
if (have_old_sigint_) {
|
||||
(void) sigaction(SIGINT, &old_sigint_, nullptr);
|
||||
have_old_sigint_ = false;
|
||||
}
|
||||
endwin();
|
||||
}
|
||||
|
||||
+15
-2
@@ -2,7 +2,9 @@
|
||||
* TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle
|
||||
*/
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <termios.h>
|
||||
#include <signal.h>
|
||||
|
||||
#include "Frontend.h"
|
||||
#include "TerminalInputHandler.h"
|
||||
@@ -15,7 +17,12 @@ public:
|
||||
|
||||
~TerminalFrontend() override = default;
|
||||
|
||||
bool Init(Editor &ed) override;
|
||||
// Configurable ESC key delay (ms) for ncurses' set_escdelay().
|
||||
// Controls how long ncurses waits to distinguish ESC vs. meta sequences.
|
||||
// Adjust if your terminal needs a different threshold.
|
||||
static constexpr int kEscDelayMs = 50;
|
||||
|
||||
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||
|
||||
void Step(Editor &ed, bool &running) override;
|
||||
|
||||
@@ -26,7 +33,13 @@ private:
|
||||
TerminalRenderer renderer_{};
|
||||
int prev_r_ = 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
|
||||
bool have_orig_tio_ = false;
|
||||
struct termios orig_tio_{};
|
||||
};
|
||||
// Saved SIGINT handler to restore on shutdown
|
||||
bool have_old_sigint_ = false;
|
||||
struct sigaction old_sigint_{};
|
||||
};
|
||||
|
||||
+268
-144
@@ -1,8 +1,12 @@
|
||||
#include <cstdio>
|
||||
#include <climits>
|
||||
#include <cwchar>
|
||||
#include <ncurses.h>
|
||||
|
||||
#include "TerminalInputHandler.h"
|
||||
#include "KKeymap.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
namespace {
|
||||
constexpr int
|
||||
@@ -10,6 +14,22 @@ CTRL(char c)
|
||||
{
|
||||
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;
|
||||
@@ -19,22 +39,39 @@ TerminalInputHandler::~TerminalInputHandler() = default;
|
||||
|
||||
static bool
|
||||
map_key_to_command(const int ch,
|
||||
const bool is_keycode,
|
||||
bool &k_prefix,
|
||||
bool &esc_meta,
|
||||
// universal-argument state (by ref)
|
||||
bool &uarg_active,
|
||||
bool &uarg_collecting,
|
||||
bool &uarg_negative,
|
||||
bool &uarg_had_digits,
|
||||
int &uarg_value,
|
||||
std::string &uarg_text,
|
||||
bool &k_ctrl_pending,
|
||||
bool &mouse_selecting,
|
||||
Editor *ed,
|
||||
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).
|
||||
switch (ch) {
|
||||
case KEY_MOUSE: {
|
||||
//
|
||||
// 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:
|
||||
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
return true;
|
||||
case KEY_MOUSE: {
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
MEVENT ev{};
|
||||
if (getmouse(&ev) == OK) {
|
||||
// Mouse wheel → scroll viewport without moving cursor
|
||||
@@ -51,12 +88,40 @@ map_key_to_command(const int ch,
|
||||
}
|
||||
#endif
|
||||
// React to left button click/press
|
||||
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
|
||||
if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED |
|
||||
REPORT_MOUSE_POSITION))) {
|
||||
char buf[64];
|
||||
// Use screen coordinates; command handler will translate via offsets
|
||||
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
|
||||
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
|
||||
return true;
|
||||
const bool pressed = (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) != 0;
|
||||
const bool released = (ev.bstate & BUTTON1_RELEASED) != 0;
|
||||
const bool moved = (ev.bstate & REPORT_MOUSE_POSITION) != 0;
|
||||
if (pressed) {
|
||||
mouse_selecting = true;
|
||||
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||
// We don't set the mark on simple click anymore in ncurses either,
|
||||
// to be consistent. ncurses doesn't easily support double-click
|
||||
// or drag-threshold in a platform-independent way here,
|
||||
// but we can at least only set mark on MOVED.
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
if (mouse_selecting && moved) {
|
||||
if (Buffer *b = ed->CurrentBuffer()) {
|
||||
if (!b->MarkSet()) {
|
||||
// Set mark at CURRENT cursor position (which is where we were before this move)
|
||||
b->SetMark(b->Curx(), b->Cury());
|
||||
}
|
||||
}
|
||||
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
if (released) {
|
||||
mouse_selecting = false;
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No actionable mouse event
|
||||
@@ -65,43 +130,54 @@ map_key_to_command(const int ch,
|
||||
}
|
||||
case KEY_LEFT:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
return true;
|
||||
case KEY_RIGHT:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
return true;
|
||||
case KEY_UP:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
return true;
|
||||
case KEY_DOWN:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
return true;
|
||||
case KEY_HOME:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
return true;
|
||||
case KEY_END:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
return true;
|
||||
case KEY_PPAGE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
return true;
|
||||
case KEY_NPAGE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
return true;
|
||||
case KEY_DC:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
return true;
|
||||
case KEY_RESIZE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::Refresh, "", 0};
|
||||
// TerminalFrontend::Step picks up the new size before the next draw.
|
||||
// Refresh is the C-g cancel handler, so issuing it here would clear the
|
||||
// mark and cancel open prompts on every window resize.
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
@@ -111,67 +187,73 @@ map_key_to_command(const int ch,
|
||||
if (ch == 27) {
|
||||
// ESC
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
esc_meta = true; // next key will be considered meta-modified
|
||||
out.hasCommand = false; // no command yet
|
||||
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
|
||||
if (ch == CTRL('K')) {
|
||||
// C-k prefix
|
||||
k_prefix = true;
|
||||
out = {true, CommandId::KPrefix, "", 0};
|
||||
k_prefix = true;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::KPrefix, "", 0};
|
||||
return true;
|
||||
}
|
||||
if (ch == CTRL('G')) {
|
||||
// cancel
|
||||
k_prefix = false;
|
||||
esc_meta = false;
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
esc_meta = false;
|
||||
// cancel universal argument as well
|
||||
uarg_active = false;
|
||||
uarg_collecting = false;
|
||||
uarg_negative = false;
|
||||
uarg_had_digits = false;
|
||||
uarg_value = 0;
|
||||
uarg_text.clear();
|
||||
if (ed)
|
||||
ed->UArgClear();
|
||||
out = {true, CommandId::Refresh, "", 0};
|
||||
return true;
|
||||
}
|
||||
// Universal argument: C-u
|
||||
if (ch == CTRL('U')) {
|
||||
// Start or extend universal argument
|
||||
if (!uarg_active) {
|
||||
uarg_active = true;
|
||||
uarg_collecting = true;
|
||||
uarg_negative = false;
|
||||
uarg_had_digits = false;
|
||||
uarg_value = 4; // default
|
||||
// Reset collected text and emit status update
|
||||
uarg_text.clear();
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
|
||||
// Bare repeated C-u multiplies by 4
|
||||
if (uarg_value <= 0)
|
||||
uarg_value = 4;
|
||||
else
|
||||
uarg_value *= 4;
|
||||
// Keep showing status (no digits yet)
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
} else {
|
||||
// If digits or '-' have been entered, C-u ends the argument (ready for next command)
|
||||
uarg_collecting = false;
|
||||
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
|
||||
uarg_value = 4;
|
||||
}
|
||||
// No command produced by C-u itself
|
||||
out.hasCommand = false;
|
||||
if (ed)
|
||||
ed->UArgStart();
|
||||
out.hasCommand = false; // C-u itself doesn't issue a command
|
||||
return true;
|
||||
}
|
||||
// Tab (note: terminals encode Tab and C-i as the same code 9)
|
||||
if (ch == '\t') {
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out.hasCommand = true;
|
||||
out.id = CommandId::InsertText;
|
||||
out.arg = "\t";
|
||||
@@ -182,22 +264,40 @@ map_key_to_command(const int ch,
|
||||
// IMPORTANT: if we're in k-prefix, the very next key must be interpreted
|
||||
// via the C-k keymap first, even if it's a Control chord like C-d.
|
||||
if (k_prefix) {
|
||||
k_prefix = false; // consume the prefix for this one key
|
||||
// In k-prefix: allow a control qualifier via literal 'C' or '^'
|
||||
// Detect Control keycodes first
|
||||
bool ctrl = false;
|
||||
int ascii_key = ch;
|
||||
if (ch >= 1 && ch <= 26) {
|
||||
ctrl = true;
|
||||
ascii_key = 'a' + (ch - 1);
|
||||
}
|
||||
// If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending
|
||||
// Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose).
|
||||
if (ascii_key == 'C' || ascii_key == '^') {
|
||||
k_ctrl_pending = true;
|
||||
if (ed)
|
||||
ed->SetStatus("C-k C _");
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
// For actual suffix, consume the k-prefix
|
||||
k_prefix = false;
|
||||
// Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings
|
||||
CommandId id;
|
||||
if (KLookupKCommand(ascii_key, ctrl, id)) {
|
||||
bool pass_ctrl = (ctrl || k_ctrl_pending);
|
||||
k_ctrl_pending = false;
|
||||
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
|
||||
out = {true, id, "", 0};
|
||||
if (ed)
|
||||
ed->SetStatus(""); // clear "C-k _" hint after suffix
|
||||
} else {
|
||||
int shown = KLowerAscii(ascii_key);
|
||||
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
|
||||
std::string arg(1, c);
|
||||
out = {true, CommandId::UnknownKCommand, arg, 0};
|
||||
if (ed)
|
||||
ed->SetStatus(""); // clear hint; handler will set unknown status
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -213,75 +313,43 @@ map_key_to_command(const int ch,
|
||||
|
||||
// Enter
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
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 (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 meta key: no command
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
return true;
|
||||
}
|
||||
|
||||
// k_prefix handled earlier
|
||||
|
||||
// If collecting universal arg, handle digits and optional leading '-'
|
||||
if (uarg_active && uarg_collecting) {
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
int d = ch - '0';
|
||||
if (!uarg_had_digits) {
|
||||
// First digit overrides any 4^n default
|
||||
uarg_value = 0;
|
||||
uarg_had_digits = true;
|
||||
}
|
||||
if (uarg_value < 100000000) {
|
||||
// avoid overflow
|
||||
uarg_value = uarg_value * 10 + d;
|
||||
}
|
||||
// Update raw text and status to reflect collected digits
|
||||
uarg_text.push_back(static_cast<char>(ch));
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
}
|
||||
if (ch == '-' && !uarg_had_digits && !uarg_negative) {
|
||||
uarg_negative = true;
|
||||
// Show leading minus in status
|
||||
uarg_text = "-";
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
}
|
||||
// Any other key will be processed as a command; fall through to mapping below
|
||||
// but mark collection finished so we apply the argument to that command
|
||||
uarg_collecting = false;
|
||||
// If universal argument is active at editor level and we get a digit, feed it
|
||||
if (ed && ed->UArg() != 0 && ch >= '0' && ch <= '9') {
|
||||
ed->UArgDigit(ch - '0');
|
||||
out.hasCommand = false; // keep collecting, no command yet
|
||||
return true;
|
||||
}
|
||||
|
||||
// Printable ASCII
|
||||
if (ch >= 0x20 && ch <= 0x7E) {
|
||||
// Printable character: ASCII, or (for a regular decoded wide character,
|
||||
// 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.id = CommandId::InsertText;
|
||||
out.arg.assign(1, static_cast<char>(ch));
|
||||
out.count = 0;
|
||||
out.arg = mb;
|
||||
out.count = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -290,39 +358,95 @@ 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
|
||||
TerminalInputHandler::decode_(MappedInput &out)
|
||||
{
|
||||
int ch = getch();
|
||||
if (ch == ERR) {
|
||||
int ch = 0;
|
||||
bool is_keycode = false;
|
||||
if (!read_key(ch, is_keycode))
|
||||
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(
|
||||
ch,
|
||||
ch, is_keycode,
|
||||
k_prefix_, esc_meta_,
|
||||
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_, uarg_text_,
|
||||
k_ctrl_pending_,
|
||||
mouse_selecting_,
|
||||
ed_,
|
||||
out);
|
||||
if (!consumed)
|
||||
return false;
|
||||
// If a command was produced and a universal argument is active, attach it and clear state
|
||||
if (out.hasCommand && uarg_active_ && out.id != CommandId::UArgStatus) {
|
||||
int count = 0;
|
||||
if (!uarg_had_digits_ && !uarg_negative_) {
|
||||
// No explicit digits: use current value (default 4 or 4^n)
|
||||
count = (uarg_value_ > 0) ? uarg_value_ : 4;
|
||||
} else {
|
||||
count = uarg_value_;
|
||||
if (uarg_negative_)
|
||||
count = -count;
|
||||
}
|
||||
out.count = count;
|
||||
// Clear state
|
||||
uarg_active_ = false;
|
||||
uarg_collecting_ = false;
|
||||
uarg_negative_ = false;
|
||||
uarg_had_digits_ = false;
|
||||
uarg_value_ = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+23
-8
@@ -11,21 +11,36 @@ public:
|
||||
|
||||
~TerminalInputHandler() override;
|
||||
|
||||
|
||||
void Attach(Editor *ed) override
|
||||
{
|
||||
ed_ = ed;
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
bool decode_(MappedInput &out);
|
||||
|
||||
// ke-style prefix state
|
||||
bool k_prefix_ = false; // true after C-k until next key or ESC
|
||||
// Optional control qualifier inside k-prefix (e.g., user typed literal 'C' or '^')
|
||||
bool k_ctrl_pending_ = false;
|
||||
// Simple meta (ESC) state for ESC sequences like ESC b/f
|
||||
bool esc_meta_ = false;
|
||||
|
||||
// Universal argument (C-u) state
|
||||
bool uarg_active_ = false; // an argument is pending for the next command
|
||||
bool uarg_collecting_ = false; // collecting digits / '-' right now
|
||||
bool uarg_negative_ = false; // whether a leading '-' was supplied
|
||||
bool uarg_had_digits_ = false; // whether any digits were supplied
|
||||
int uarg_value_ = 0; // current absolute value (>=0)
|
||||
std::string uarg_text_; // raw digits/minus typed for status display
|
||||
};
|
||||
// Mouse drag selection state
|
||||
bool mouse_selecting_ = false;
|
||||
|
||||
Editor *ed_ = nullptr; // attached editor for uarg handling
|
||||
};
|
||||
|
||||
+300
-150
@@ -1,12 +1,16 @@
|
||||
#include <clocale>
|
||||
#define _XOPEN_SOURCE_EXTENDED 1
|
||||
#include <cwchar>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <cstdlib>
|
||||
#include <ncurses.h>
|
||||
#include <regex>
|
||||
#include <string>
|
||||
|
||||
#include "TerminalRenderer.h"
|
||||
#include "SearchHighlight.h"
|
||||
#include "TermWidth.h"
|
||||
#include "Buffer.h"
|
||||
#include "Editor.h"
|
||||
#include "Highlight.h"
|
||||
@@ -39,11 +43,13 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
|
||||
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
|
||||
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 coloffs = buf->Coloffs();
|
||||
|
||||
const int tabw = 8;
|
||||
const int tabw = static_cast<int>(kte::kTabWidth);
|
||||
// Phase 3: prefetch visible viewport highlights (current terminal area)
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
int fr = static_cast<int>(rowoffs);
|
||||
@@ -51,41 +57,18 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
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) {
|
||||
move(r, 0);
|
||||
std::size_t li = rowoffs + static_cast<std::size_t>(r);
|
||||
std::size_t render_col = 0;
|
||||
std::size_t src_i = 0;
|
||||
// Compute matches for this line if search highlighting is active
|
||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
|
||||
if (search_mode && li < lines.size()) {
|
||||
std::string sline = static_cast<std::string>(lines[li]);
|
||||
// If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges
|
||||
if (ed.PromptActive() && (
|
||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
||||
try {
|
||||
std::regex rx(ed.SearchQuery());
|
||||
for (auto it = std::sregex_iterator(sline.begin(), sline.end(), rx);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
const auto &m = *it;
|
||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
||||
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
||||
ranges.emplace_back(sx, ex);
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status shows error
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
std::size_t pos = 0;
|
||||
while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) {
|
||||
ranges.emplace_back(pos, pos + q.size());
|
||||
pos += q.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per-row copy: GetLineView() would materialize the whole buffer
|
||||
// after every edit.
|
||||
const std::string line = li < nlines ? buf->GetLineString(li) : std::string();
|
||||
search_hl.Ranges(line, ranges);
|
||||
auto is_src_in_hl = [&](std::size_t si) -> bool {
|
||||
if (ranges.empty())
|
||||
return false;
|
||||
@@ -104,63 +87,185 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
|
||||
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
|
||||
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||
bool hl_on = false;
|
||||
bool cur_on = false;
|
||||
int written = 0;
|
||||
if (li < lines.size()) {
|
||||
std::string line = static_cast<std::string>(lines[li]);
|
||||
src_i = 0;
|
||||
render_col = 0;
|
||||
// Syntax highlighting: fetch per-line spans
|
||||
const kte::LineHighlight *lh_ptr = nullptr;
|
||||
|
||||
// Mark selection (mark -> cursor), in source coordinates
|
||||
bool sel_active = false;
|
||||
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
|
||||
if (buf->MarkSet()) {
|
||||
sel_sy = buf->MarkCury();
|
||||
sel_sx = buf->MarkCurx();
|
||||
sel_ey = buf->Cury();
|
||||
sel_ex = buf->Curx();
|
||||
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
|
||||
std::swap(sel_sy, sel_ey);
|
||||
std::swap(sel_sx, sel_ex);
|
||||
}
|
||||
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
|
||||
}
|
||||
// Visual-line selection: full-line selection range
|
||||
const bool vsel_active = buf->VisualLineActive();
|
||||
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
||||
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
||||
auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool {
|
||||
if (!sel_active)
|
||||
return false;
|
||||
if (y < sel_sy || y > sel_ey)
|
||||
return false;
|
||||
if (sel_sy == sel_ey)
|
||||
return sx >= sel_sx && sx < sel_ex;
|
||||
if (y == sel_sy)
|
||||
return sx >= sel_sx;
|
||||
if (y == sel_ey)
|
||||
return sx < sel_ex;
|
||||
return true;
|
||||
};
|
||||
int written = 0;
|
||||
if (li < nlines) {
|
||||
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
|
||||
const std::size_t vsel_spot_src = vsel_on_line
|
||||
? std::min(buf->Curx(), line.size())
|
||||
: 0;
|
||||
const bool vsel_spot_is_eol = vsel_on_line && vsel_spot_src == line.size();
|
||||
std::size_t vsel_line_rx = 0;
|
||||
if (vsel_spot_is_eol) {
|
||||
// Compute the rendered (column) width of the line so we can highlight a
|
||||
// single cell at EOL when the spot falls beyond the last character.
|
||||
std::size_t rc = 0;
|
||||
std::size_t si = 0;
|
||||
while (si < line.size()) {
|
||||
wchar_t wch = L' ';
|
||||
int wch_len = 1;
|
||||
std::mbstate_t state = std::mbstate_t();
|
||||
size_t res = std::mbrtowc(&wch, &line[si], line.size() - si, &state);
|
||||
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||
wch = static_cast<unsigned char>(line[si]);
|
||||
wch_len = 1;
|
||||
} else if (res == 0) {
|
||||
wch = L'\0';
|
||||
wch_len = 1;
|
||||
} else {
|
||||
wch_len = static_cast<int>(res);
|
||||
}
|
||||
if (wch == L'\t') {
|
||||
constexpr std::size_t tab_width = kte::kTabWidth;
|
||||
const std::size_t next_tab = tab_width - (rc % tab_width);
|
||||
rc += next_tab;
|
||||
} else {
|
||||
rc += static_cast<std::size_t>(kte::CellWidth(wch));
|
||||
}
|
||||
si += static_cast<std::size_t>(wch_len);
|
||||
}
|
||||
vsel_line_rx = rc;
|
||||
}
|
||||
src_i = 0;
|
||||
render_col = 0;
|
||||
// Syntax highlighting: fetch per-line spans (sanitized copy)
|
||||
std::vector<kte::HighlightSpan> sane_spans;
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
||||
HasHighlighter()) {
|
||||
lh_ptr = &buf->Highlighter()->GetLine(
|
||||
const kte::LineHighlight &lh_val = buf->Highlighter()->GetLine(
|
||||
*buf, static_cast<int>(li), buf->Version());
|
||||
kte::SanitizeSpans(line, lh_val.spans, sane_spans);
|
||||
}
|
||||
// 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 {
|
||||
if (!lh_ptr)
|
||||
return kte::TokenKind::Default;
|
||||
for (const auto &sp: lh_ptr->spans) {
|
||||
if (static_cast<int>(src_index) >= sp.col_start && static_cast<int>(
|
||||
src_index) < sp.col_end)
|
||||
return sp.kind;
|
||||
}
|
||||
const int si = static_cast<int>(src_index);
|
||||
if (si < last_token_si)
|
||||
span_i = 0; // asked about an earlier byte: restart
|
||||
last_token_si = si;
|
||||
while (span_i < sane_spans.size() && sane_spans[span_i].col_end <= si)
|
||||
++span_i;
|
||||
if (span_i < sane_spans.size() && sane_spans[span_i].col_start <= si)
|
||||
return sane_spans[span_i].kind;
|
||||
return kte::TokenKind::Default;
|
||||
};
|
||||
auto apply_token_attr = [&](kte::TokenKind k) {
|
||||
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
|
||||
attrset(A_NORMAL);
|
||||
auto token_attr = [&](kte::TokenKind k) -> attr_t {
|
||||
switch (k) {
|
||||
case kte::TokenKind::Keyword:
|
||||
case kte::TokenKind::Type:
|
||||
case kte::TokenKind::Constant:
|
||||
case kte::TokenKind::Function:
|
||||
attron(A_BOLD);
|
||||
break;
|
||||
return A_BOLD;
|
||||
case kte::TokenKind::Comment:
|
||||
attron(A_DIM);
|
||||
break;
|
||||
return A_DIM;
|
||||
case kte::TokenKind::String:
|
||||
case kte::TokenKind::Char:
|
||||
case kte::TokenKind::Number:
|
||||
// standout a bit using A_UNDERLINE if available
|
||||
attron(A_UNDERLINE);
|
||||
break;
|
||||
return A_UNDERLINE;
|
||||
default:
|
||||
break;
|
||||
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) {
|
||||
char ch = ' ';
|
||||
bool from_src = false;
|
||||
wchar_t wch = L' ';
|
||||
int wch_len = 1;
|
||||
int disp_w = 1;
|
||||
|
||||
if (src_i < line.size()) {
|
||||
unsigned char c = static_cast<unsigned char>(line[src_i]);
|
||||
if (c == '\t') {
|
||||
// Decode UTF-8
|
||||
std::mbstate_t state = std::mbstate_t();
|
||||
size_t res = std::mbrtowc(
|
||||
&wch, &line[src_i], line.size() - src_i, &state);
|
||||
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||
// Invalid or incomplete; treat as single byte
|
||||
wch = static_cast<unsigned char>(line[src_i]);
|
||||
wch_len = 1;
|
||||
} else if (res == 0) {
|
||||
wch = L'\0';
|
||||
wch_len = 1;
|
||||
} else {
|
||||
wch_len = static_cast<int>(res);
|
||||
}
|
||||
|
||||
if (wch == L'\t') {
|
||||
std::size_t next_tab = tabw - (render_col % tabw);
|
||||
if (render_col + next_tab <= coloffs) {
|
||||
render_col += next_tab;
|
||||
++src_i;
|
||||
src_i += wch_len;
|
||||
continue;
|
||||
}
|
||||
// Emit spaces for tab
|
||||
@@ -169,111 +274,156 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
std::size_t to_skip = std::min<std::size_t>(
|
||||
next_tab, coloffs - render_col);
|
||||
render_col += to_skip;
|
||||
next_tab -= to_skip;
|
||||
next_tab -= to_skip;
|
||||
}
|
||||
// Now render visible spaces
|
||||
while (next_tab > 0 && written < cols) {
|
||||
bool in_hl = search_mode && is_src_in_hl(src_i);
|
||||
bool in_mark = is_src_in_mark_sel(li, src_i);
|
||||
bool in_vsel =
|
||||
vsel_on_line && !vsel_spot_is_eol && src_i ==
|
||||
vsel_spot_src;
|
||||
bool in_sel = in_mark || in_vsel;
|
||||
bool in_hl = search_hl.Active() && is_src_in_hl(src_i);
|
||||
bool in_cur =
|
||||
has_current && li == cur_my && src_i >= cur_mx
|
||||
&& src_i < cur_mend;
|
||||
// Toggle highlight attributes
|
||||
int attr = 0;
|
||||
if (in_hl)
|
||||
attr |= A_STANDOUT;
|
||||
if (in_cur)
|
||||
attr |= A_BOLD;
|
||||
if ((attr & A_STANDOUT) && !hl_on) {
|
||||
attron(A_STANDOUT);
|
||||
hl_on = true;
|
||||
}
|
||||
if (!(attr & A_STANDOUT) && hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
}
|
||||
if ((attr & A_BOLD) && !cur_on) {
|
||||
attron(A_BOLD);
|
||||
cur_on = true;
|
||||
}
|
||||
if (!(attr & A_BOLD) && cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
// Apply syntax attribute only if not in search highlight
|
||||
if (!in_hl) {
|
||||
apply_token_attr(token_at(src_i));
|
||||
&&
|
||||
src_i < cur_mend;
|
||||
attr_t a = A_NORMAL;
|
||||
a |= token_attr(token_at(src_i));
|
||||
if (in_sel) {
|
||||
a |= A_REVERSE;
|
||||
} else {
|
||||
if (in_hl)
|
||||
a |= A_STANDOUT;
|
||||
if (in_cur)
|
||||
a |= A_BOLD;
|
||||
}
|
||||
attrset(a);
|
||||
addch(' ');
|
||||
++written;
|
||||
++render_col;
|
||||
--next_tab;
|
||||
}
|
||||
++src_i;
|
||||
src_i += wch_len;
|
||||
continue;
|
||||
} else {
|
||||
// normal char
|
||||
// normal char (controls are drawn as ^X or '?')
|
||||
disp_w = kte::CellWidth(wch);
|
||||
|
||||
if (render_col < coloffs) {
|
||||
++render_col;
|
||||
++src_i;
|
||||
render_col += disp_w;
|
||||
src_i += wch_len;
|
||||
continue;
|
||||
}
|
||||
ch = static_cast<char>(c);
|
||||
from_src = true;
|
||||
}
|
||||
} else {
|
||||
// beyond EOL, fill spaces
|
||||
ch = ' ';
|
||||
wch = L' ';
|
||||
wch_len = 1;
|
||||
disp_w = 1;
|
||||
from_src = false;
|
||||
}
|
||||
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
||||
bool in_cur =
|
||||
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i <
|
||||
cur_mend;
|
||||
if (in_hl && !hl_on) {
|
||||
attron(A_STANDOUT);
|
||||
hl_on = true;
|
||||
|
||||
if (written + disp_w > cols) {
|
||||
// would overflow, just break
|
||||
break;
|
||||
}
|
||||
if (!in_hl && hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
|
||||
bool in_mark = from_src && is_src_in_mark_sel(li, src_i);
|
||||
bool in_vsel = false;
|
||||
if (vsel_on_line) {
|
||||
if (from_src) {
|
||||
in_vsel = !vsel_spot_is_eol && src_i == vsel_spot_src;
|
||||
} else {
|
||||
in_vsel = vsel_spot_is_eol && render_col == vsel_line_rx;
|
||||
}
|
||||
}
|
||||
if (in_cur && !cur_on) {
|
||||
attron(A_BOLD);
|
||||
cur_on = true;
|
||||
}
|
||||
if (!in_cur && cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
if (!in_hl && from_src) {
|
||||
apply_token_attr(token_at(src_i));
|
||||
}
|
||||
addch(static_cast<unsigned char>(ch));
|
||||
++written;
|
||||
++render_col;
|
||||
bool in_sel = in_mark || in_vsel;
|
||||
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 &&
|
||||
src_i < cur_mend;
|
||||
attr_t a = A_NORMAL;
|
||||
if (from_src)
|
||||
++src_i;
|
||||
a |= token_attr(token_at(src_i));
|
||||
if (in_sel) {
|
||||
a |= A_REVERSE;
|
||||
} else {
|
||||
if (in_hl)
|
||||
a |= A_STANDOUT;
|
||||
if (in_cur)
|
||||
a |= A_BOLD;
|
||||
}
|
||||
attrset(a);
|
||||
|
||||
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;
|
||||
wchar_t warr[2] = {wch, L'\0'};
|
||||
setcchar(&cch, warr, 0, 0, nullptr);
|
||||
add_wch(&cch);
|
||||
} else {
|
||||
addch(' ');
|
||||
}
|
||||
|
||||
written += disp_w;
|
||||
render_col += disp_w;
|
||||
if (from_src)
|
||||
src_i += wch_len;
|
||||
if (src_i >= line.size() && written >= cols)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
}
|
||||
if (cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
attrset(A_NORMAL);
|
||||
clrtoeol();
|
||||
}
|
||||
|
||||
// Place terminal cursor at logical position accounting for tabs and coloffs
|
||||
std::size_t cy = buf->Cury();
|
||||
std::size_t rx = buf->Rx(); // render x computed by command layer
|
||||
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
|
||||
int cur_x = static_cast<int>(rx) - static_cast<int>(buf->Coloffs());
|
||||
// Place terminal cursor at logical position accounting for tabs and coloffs.
|
||||
// Recompute the rendered X using the same logic as the drawing loop to avoid
|
||||
// any drift between the command-layer computation and the terminal renderer.
|
||||
std::size_t cy = buf->Cury();
|
||||
std::size_t cx = buf->Curx();
|
||||
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
|
||||
std::size_t rx_recomputed = 0;
|
||||
if (cy < nlines && cursor_cache_.valid && cursor_cache_.buf_id == buf->Id() &&
|
||||
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 render_col_cur = 0;
|
||||
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
|
||||
std::mbstate_t state = std::mbstate_t();
|
||||
wchar_t wch;
|
||||
size_t res = std::mbrtowc(
|
||||
&wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur,
|
||||
&state);
|
||||
|
||||
// Same decoding and widths as the drawing loop above.
|
||||
std::size_t len = res;
|
||||
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||
wch = static_cast<unsigned char>(line_for_cursor[src_i_cur]);
|
||||
len = 1;
|
||||
} else if (res == 0) {
|
||||
wch = L'\0';
|
||||
len = 1;
|
||||
}
|
||||
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;
|
||||
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());
|
||||
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
|
||||
// remember where to leave the terminal cursor after status is drawn
|
||||
saved_cur_y = cur_y;
|
||||
@@ -358,9 +508,9 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
{
|
||||
const char *app = "kte";
|
||||
left.reserve(256);
|
||||
left += app;
|
||||
left += " ";
|
||||
left += KTE_VERSION_STR; // already includes leading 'v'
|
||||
left += app;
|
||||
left += " ";
|
||||
left += KTE_VERSION_STR; // already includes leading 'v'
|
||||
const Buffer *b = buf;
|
||||
std::string fname;
|
||||
if (b) {
|
||||
@@ -381,11 +531,11 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
std::size_t total = ed.BufferCount();
|
||||
if (total > 0) {
|
||||
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // human-friendly 1-based
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
}
|
||||
}
|
||||
left += fname;
|
||||
@@ -396,10 +546,10 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
left += " [RO]";
|
||||
// Append total line count as "<n>L"
|
||||
if (b) {
|
||||
unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
unsigned long lcount = static_cast<unsigned long>(b->Nrows());
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,4 +620,4 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
}
|
||||
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -2,6 +2,9 @@
|
||||
* TerminalRenderer - ncurses-based renderer for terminal mode
|
||||
*/
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include "Renderer.h"
|
||||
|
||||
|
||||
@@ -12,4 +15,30 @@ public:
|
||||
~TerminalRenderer() 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_;
|
||||
};
|
||||
|
||||
+6
-1
@@ -4,8 +4,10 @@
|
||||
|
||||
|
||||
bool
|
||||
TestFrontend::Init(Editor &ed)
|
||||
TestFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
{
|
||||
(void) argc;
|
||||
(void) argv;
|
||||
ed.SetDimensions(24, 80);
|
||||
return true;
|
||||
}
|
||||
@@ -14,6 +16,9 @@ TestFrontend::Init(Editor &ed)
|
||||
void
|
||||
TestFrontend::Step(Editor &ed, bool &running)
|
||||
{
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
MappedInput mi;
|
||||
if (input_.Poll(mi)) {
|
||||
if (mi.hasCommand) {
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ public:
|
||||
|
||||
~TestFrontend() override = default;
|
||||
|
||||
bool Init(Editor &ed) override;
|
||||
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||
|
||||
void Step(Editor &ed, bool &running) override;
|
||||
|
||||
@@ -34,4 +34,4 @@ public:
|
||||
private:
|
||||
TestInputHandler input_{};
|
||||
TestRenderer renderer_{};
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -27,4 +27,4 @@ public:
|
||||
|
||||
private:
|
||||
std::queue<MappedInput> queue_;
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -29,4 +29,4 @@ public:
|
||||
|
||||
private:
|
||||
std::size_t draw_count_ = 0;
|
||||
};
|
||||
};
|
||||
|
||||
+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};
|
||||
};
|
||||
+39
-2
@@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "TextSpan.h"
|
||||
|
||||
|
||||
enum class UndoType : std::uint8_t {
|
||||
@@ -9,13 +12,47 @@ enum class UndoType : std::uint8_t {
|
||||
Paste,
|
||||
Newline,
|
||||
DeleteRow,
|
||||
InsertRow,
|
||||
// Inverse of Newline: forward = join_lines(row) [removes the newline at the
|
||||
// end of `row`], backward = split_line(row, col) [recreates the original
|
||||
// two lines]. Used by backspace-at-col-0 and delete-at-eol, whose forward
|
||||
// action is a join, not a split — Newline's apply() semantics are the
|
||||
// wrong direction for those.
|
||||
JoinLines,
|
||||
};
|
||||
|
||||
struct UndoNode {
|
||||
UndoType type{};
|
||||
int row{};
|
||||
int col{};
|
||||
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
||||
std::string text;
|
||||
UndoNode *child = nullptr; // next in current timeline
|
||||
UndoNode *next = nullptr; // redo branch
|
||||
// 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 *child = nullptr; // next in current timeline
|
||||
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,61 +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->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->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_;
|
||||
};
|
||||
+526
-44
@@ -1,112 +1,414 @@
|
||||
#include "UndoSystem.h"
|
||||
#include "Buffer.h"
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
|
||||
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
||||
: buf_(&owner), tree_(tree) {}
|
||||
|
||||
|
||||
std::uint64_t
|
||||
UndoSystem::BeginGroup()
|
||||
{
|
||||
// Ensure any pending typed run is sealed so the group is a distinct undo step.
|
||||
commit();
|
||||
if (active_group_id_ == 0)
|
||||
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_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::EndGroup()
|
||||
{
|
||||
commit();
|
||||
if (group_depth_ > 0)
|
||||
--group_depth_;
|
||||
if (group_depth_ == 0) {
|
||||
active_group_id_ = 0;
|
||||
maybe_prune();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::Begin(UndoType type)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
(void) type;
|
||||
if (!buf_)
|
||||
return;
|
||||
const int row = static_cast<int>(buf_->Cury());
|
||||
const int col = static_cast<int>(buf_->Curx());
|
||||
|
||||
// Some operations should always be standalone undo steps.
|
||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
||||
UndoType::InsertRow || type == UndoType::JoinLines);
|
||||
if (always_standalone) {
|
||||
commit();
|
||||
}
|
||||
|
||||
if (tree_.pending) {
|
||||
if (tree_.pending->type == type) {
|
||||
// Typed-run coalescing rules.
|
||||
switch (type) {
|
||||
case UndoType::Insert:
|
||||
case UndoType::Paste: {
|
||||
// Cursor must be at the end of the pending insert.
|
||||
if (tree_.pending->row == row
|
||||
&& col == tree_.pending->col + static_cast<int>(tree_.pending->text.size())) {
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case UndoType::Delete: {
|
||||
if (tree_.pending->row == row) {
|
||||
// Two common delete shapes:
|
||||
// 1) backspace-run: cursor moves left each time (so new col is pending.col - 1)
|
||||
// 2) delete-run: cursor stays, always deleting at the same col
|
||||
if (col == tree_.pending->col) {
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
return;
|
||||
}
|
||||
if (col + 1 == tree_.pending->col) {
|
||||
// Extend a backspace run to the left; update the start column now.
|
||||
tree_.pending->col = col;
|
||||
pending_mode_ = PendingAppendMode::Prepend;
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case UndoType::Newline:
|
||||
case UndoType::DeleteRow:
|
||||
case UndoType::InsertRow:
|
||||
case UndoType::JoinLines:
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Can't coalesce: seal the previous pending step.
|
||||
commit();
|
||||
}
|
||||
|
||||
// Start a new pending node.
|
||||
tree_.pending = new UndoNode{};
|
||||
tree_.pending->type = type;
|
||||
tree_.pending->row = row;
|
||||
tree_.pending->col = col;
|
||||
tree_.pending->group_id = active_group_id_;
|
||||
tree_.pending->text.clear();
|
||||
tree_.pending->parent = nullptr;
|
||||
tree_.pending->child = nullptr;
|
||||
tree_.pending->next = nullptr;
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::Append(char ch)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
(void) ch;
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
||||
} else {
|
||||
tree_.pending->text.push_back(ch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::Append(std::string_view text)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
(void) text;
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
if (text.empty())
|
||||
return;
|
||||
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||
tree_.pending->text.insert(0, text.data(), text.size());
|
||||
} else {
|
||||
tree_.pending->text.append(text.data(), text.size());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::commit()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
|
||||
// Drop empty text batches for text-based operations.
|
||||
if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
|
||||
|| tree_.pending->type == UndoType::Paste)
|
||||
&& tree_.pending->text.empty()) {
|
||||
delete tree_.pending;
|
||||
tree_.pending = nullptr;
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
return;
|
||||
}
|
||||
|
||||
try_convert_to_spans(tree_.pending);
|
||||
bytes_ += tree_.pending->CostBytes();
|
||||
|
||||
if (!tree_.root) {
|
||||
tree_.root = tree_.pending;
|
||||
tree_.pending->parent = nullptr;
|
||||
tree_.current = tree_.pending;
|
||||
} else if (!tree_.current) {
|
||||
// We are at the "pre-first-edit" state (undo past the first node).
|
||||
// In branching history, preserve the existing root chain as an alternate branch.
|
||||
tree_.pending->parent = nullptr;
|
||||
tree_.pending->next = tree_.root;
|
||||
tree_.root = tree_.pending;
|
||||
tree_.current = tree_.pending;
|
||||
} else {
|
||||
// Branching semantics: attach as a new redo branch under current.
|
||||
// Make the new edit the active child by inserting it at the head.
|
||||
tree_.pending->parent = tree_.current;
|
||||
if (!tree_.current->child) {
|
||||
tree_.current->child = tree_.pending;
|
||||
} else {
|
||||
tree_.pending->next = tree_.current->child;
|
||||
tree_.current->child = tree_.pending;
|
||||
}
|
||||
tree_.current = tree_.pending;
|
||||
}
|
||||
|
||||
tree_.pending = nullptr;
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
update_dirty_flag();
|
||||
if (group_depth_ == 0)
|
||||
maybe_prune();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::undo()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
// Seal any in-progress typed run before undo.
|
||||
commit();
|
||||
if (!tree_.current)
|
||||
return;
|
||||
debug_log("undo");
|
||||
const std::uint64_t gid = tree_.current->group_id;
|
||||
do {
|
||||
UndoNode *node = tree_.current;
|
||||
apply(node, -1);
|
||||
tree_.current = node->parent;
|
||||
} while (gid != 0 && tree_.current && tree_.current->group_id == gid);
|
||||
update_dirty_flag();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::redo()
|
||||
UndoSystem::redo(int branch_index)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
commit();
|
||||
UndoNode **head = nullptr;
|
||||
if (!tree_.current) {
|
||||
head = &tree_.root;
|
||||
} else {
|
||||
head = &tree_.current->child;
|
||||
}
|
||||
if (!head || !*head)
|
||||
return;
|
||||
if (branch_index < 0)
|
||||
branch_index = 0;
|
||||
|
||||
// Select the Nth sibling from the branch list and make it the active head.
|
||||
UndoNode *prev = nullptr;
|
||||
UndoNode *sel = *head;
|
||||
for (int i = 0; i < branch_index && sel; ++i) {
|
||||
prev = sel;
|
||||
sel = sel->next;
|
||||
}
|
||||
if (!sel)
|
||||
return;
|
||||
if (prev) {
|
||||
prev->next = sel->next;
|
||||
sel->next = *head;
|
||||
*head = sel;
|
||||
}
|
||||
|
||||
debug_log("redo");
|
||||
UndoNode *node = *head;
|
||||
const std::uint64_t gid = node->group_id;
|
||||
apply(node, +1);
|
||||
tree_.current = node;
|
||||
while (gid != 0 && tree_.current && tree_.current->child
|
||||
&& tree_.current->child->group_id == gid) {
|
||||
UndoNode *child = tree_.current->child;
|
||||
apply(child, +1);
|
||||
tree_.current = child;
|
||||
}
|
||||
update_dirty_flag();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::mark_saved()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
commit();
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::discard_pending()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
if (tree_.pending) {
|
||||
delete tree_.pending;
|
||||
tree_.pending = nullptr;
|
||||
}
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::clear()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
discard_pending();
|
||||
// The root may have siblings (edits made after undoing to the start).
|
||||
FreeUndoForest(tree_.root);
|
||||
tree_.root = nullptr;
|
||||
tree_.current = nullptr;
|
||||
tree_.saved = nullptr;
|
||||
base_unsaved_ = false;
|
||||
bytes_ = 0;
|
||||
active_group_id_ = 0;
|
||||
group_depth_ = 0;
|
||||
next_group_id_ = 1;
|
||||
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
|
||||
UndoSystem::apply(const UndoNode *node, int direction)
|
||||
{
|
||||
std::size_t ex = 0, ey = 0;
|
||||
if (!node)
|
||||
return;
|
||||
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
||||
// Low-level Buffer edit primitives do not move the cursor.
|
||||
switch (node->type) {
|
||||
case UndoType::Insert:
|
||||
case UndoType::Paste:
|
||||
if (direction > 0) {
|
||||
buf_->insert_text(node->row, node->col, node->text);
|
||||
insert_node_text(*buf_, node);
|
||||
end_of_text(*buf_, node, ex, ey);
|
||||
buf_->SetCursor(ex, ey);
|
||||
} 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));
|
||||
}
|
||||
break;
|
||||
case UndoType::Delete:
|
||||
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));
|
||||
} else {
|
||||
buf_->insert_text(node->row, node->col, node->text);
|
||||
insert_node_text(*buf_, node);
|
||||
end_of_text(*buf_, node, ex, ey);
|
||||
buf_->SetCursor(ex, ey);
|
||||
}
|
||||
break;
|
||||
case UndoType::Newline:
|
||||
if (direction > 0) {
|
||||
buf_->split_line(node->row, node->col);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||
} else {
|
||||
buf_->join_lines(node->row);
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::DeleteRow:
|
||||
if (direction > 0) {
|
||||
buf_->delete_row(node->row);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->insert_row(node->row, node->text);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::InsertRow:
|
||||
if (direction > 0) {
|
||||
buf_->insert_row(node->row, node->text);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->delete_row(node->row);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::JoinLines:
|
||||
// Mirror image of Newline: forward removes a newline, backward restores it.
|
||||
if (direction > 0) {
|
||||
buf_->join_lines(node->row);
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->split_line(node->row, node->col);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -116,20 +418,13 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
||||
void
|
||||
UndoSystem::free_node(UndoNode *node)
|
||||
{
|
||||
// Free this node and its child subtree, but not its siblings.
|
||||
if (!node)
|
||||
return;
|
||||
// Free child subtree(s) and sibling branches
|
||||
if (node->child) {
|
||||
// Free entire redo list starting at child, including each subtree
|
||||
UndoNode *branch = node->child;
|
||||
while (branch) {
|
||||
UndoNode *next = branch->next;
|
||||
free_node(branch);
|
||||
branch = next;
|
||||
}
|
||||
node->child = nullptr;
|
||||
}
|
||||
UndoNode *children = node->child;
|
||||
node->child = nullptr;
|
||||
delete node;
|
||||
FreeUndoForest(children);
|
||||
}
|
||||
|
||||
|
||||
@@ -137,26 +432,27 @@ void
|
||||
UndoSystem::free_branch(UndoNode *node)
|
||||
{
|
||||
// Free a branch list (node and its next siblings) including their subtrees
|
||||
while (node) {
|
||||
UndoNode *next = node->next;
|
||||
free_node(node);
|
||||
node = next;
|
||||
}
|
||||
FreeUndoForest(node);
|
||||
}
|
||||
|
||||
|
||||
// Iterative search for `target` below `cur`; sets out_parent to its parent.
|
||||
static bool
|
||||
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
||||
{
|
||||
if (!cur)
|
||||
return false;
|
||||
for (UndoNode *child = cur->child; child != nullptr; child = child->next) {
|
||||
if (child == target) {
|
||||
out_parent = cur;
|
||||
return true;
|
||||
std::vector<UndoNode *> stack{cur};
|
||||
while (!stack.empty()) {
|
||||
UndoNode *node = stack.back();
|
||||
stack.pop_back();
|
||||
for (UndoNode *child = node->child; child != nullptr; child = child->next) {
|
||||
if (child == target) {
|
||||
out_parent = node;
|
||||
return true;
|
||||
}
|
||||
stack.push_back(child);
|
||||
}
|
||||
if (dfs_find_parent(child, target, out_parent))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -175,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
|
||||
UndoSystem::update_dirty_flag()
|
||||
{
|
||||
// dirty if current != saved
|
||||
bool dirty = (tree_.current != tree_.saved);
|
||||
bool dirty = base_unsaved_ || (tree_.current != tree_.saved);
|
||||
buf_->SetDirty(dirty);
|
||||
}
|
||||
|
||||
@@ -206,6 +681,10 @@ UndoSystem::type_str(UndoType t)
|
||||
return "Newline";
|
||||
case UndoType::DeleteRow:
|
||||
return "DeleteRow";
|
||||
case UndoType::InsertRow:
|
||||
return "InsertRow";
|
||||
case UndoType::JoinLines:
|
||||
return "JoinLines";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
@@ -216,11 +695,14 @@ UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
|
||||
{
|
||||
if (!root || !target)
|
||||
return false;
|
||||
if (root == target)
|
||||
return true;
|
||||
for (UndoNode *child = root->child; child != nullptr; child = child->next) {
|
||||
if (is_descendant(child, target))
|
||||
std::vector<UndoNode *> stack{root};
|
||||
while (!stack.empty()) {
|
||||
UndoNode *node = stack.back();
|
||||
stack.pop_back();
|
||||
if (node == target)
|
||||
return true;
|
||||
for (UndoNode *child = node->child; child != nullptr; child = child->next)
|
||||
stack.push_back(child);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+109
-1
@@ -1,3 +1,44 @@
|
||||
/*
|
||||
* UndoSystem.h - undo/redo system with tree-based branching
|
||||
*
|
||||
* UndoSystem manages the undo/redo history for a Buffer. It provides:
|
||||
*
|
||||
* - Tree-based undo: Multiple redo branches at each node (not just linear history)
|
||||
* - Atomic grouping: Multiple operations can be undone/redone as a single step
|
||||
* - Dirty tracking: Marks when buffer matches last saved state
|
||||
* - Efficient storage: Nodes stored in UndoTree, operations applied to Buffer
|
||||
*
|
||||
* Key concepts:
|
||||
*
|
||||
* 1. Undo tree structure:
|
||||
* - Each edit creates a node in the tree
|
||||
* - Undo moves up the tree (toward root)
|
||||
* - Redo moves down the tree (toward leaves)
|
||||
* - Multiple redo branches preserved (not lost on new edits after undo)
|
||||
*
|
||||
* 2. Operation lifecycle:
|
||||
* - Begin(type): Start recording an operation (insert/delete)
|
||||
* - Append(text): Add content to the pending operation
|
||||
* - commit(): Finalize and add to undo tree
|
||||
* - discard_pending(): Cancel without recording
|
||||
*
|
||||
* 3. Atomic grouping:
|
||||
* - BeginGroup()/EndGroup(): Bracket multiple operations
|
||||
* - All operations in a group share the same group_id
|
||||
* - Undo/redo treats the entire group as one step
|
||||
*
|
||||
* 4. Integration with Buffer:
|
||||
* - UndoSystem holds a reference to its owning Buffer
|
||||
* - apply() executes undo/redo by calling Buffer's editing methods
|
||||
* - Buffer's dirty flag updated automatically
|
||||
*
|
||||
* Usage pattern:
|
||||
* undo_system.Begin(UndoType::Insert);
|
||||
* undo_system.Append("text");
|
||||
* undo_system.commit(); // Now undoable
|
||||
*
|
||||
* See also: UndoTree.h (storage), UndoNode.h (node structure)
|
||||
*/
|
||||
#pragma once
|
||||
#include <string_view>
|
||||
#include <cstddef>
|
||||
@@ -12,6 +53,12 @@ class UndoSystem {
|
||||
public:
|
||||
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
||||
|
||||
// Begin an atomic group: subsequent committed nodes with the same group_id will be
|
||||
// undone/redone as a single step. Returns the active group id.
|
||||
std::uint64_t BeginGroup();
|
||||
|
||||
void EndGroup();
|
||||
|
||||
void Begin(UndoType type);
|
||||
|
||||
void Append(char ch);
|
||||
@@ -22,17 +69,56 @@ public:
|
||||
|
||||
void undo();
|
||||
|
||||
void redo();
|
||||
// Redo the current node's active child branch.
|
||||
// If `branch_index` > 0, selects that redo sibling (0-based) and makes it active.
|
||||
// When current is null (pre-first-edit), branches are selected among `tree_.root` siblings.
|
||||
void redo(int branch_index = 0);
|
||||
|
||||
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 clear();
|
||||
|
||||
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)
|
||||
// Test-only introspection hook.
|
||||
const UndoTree &TreeForTests() const
|
||||
{
|
||||
return tree_;
|
||||
}
|
||||
#endif
|
||||
|
||||
private:
|
||||
enum class PendingAppendMode : std::uint8_t {
|
||||
Append,
|
||||
Prepend,
|
||||
};
|
||||
|
||||
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
||||
void free_node(UndoNode *node);
|
||||
|
||||
@@ -48,6 +134,28 @@ private:
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
Buffer *buf_;
|
||||
UndoTree &tree_;
|
||||
};
|
||||
+30
-2
@@ -1,3 +1,31 @@
|
||||
// Placeholder translation unit for UndoTree struct definition.
|
||||
// Undo logic is implemented in UndoSystem.
|
||||
// Undo logic is implemented in UndoSystem; this file only owns node lifetime.
|
||||
#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;
|
||||
}
|
||||
|
||||
+13
-1
@@ -7,4 +7,16 @@ struct UndoTree {
|
||||
UndoNode *current = nullptr; // current state of buffer
|
||||
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
||||
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);
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
/*
|
||||
* BufferBench.cc - microbenchmarks for GapBuffer and PieceTable
|
||||
*
|
||||
* This benchmark exercises the public APIs shared by both structures as used
|
||||
* in Buffer::Line: Reserve, AppendChar, Append, PrependChar, Prepend, Clear.
|
||||
*
|
||||
* Run examples:
|
||||
* ./kte_bench_buffer # defaults
|
||||
* ./kte_bench_buffer 200000 8 4096 # N=200k, rounds=8, chunk=4096
|
||||
*/
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <typeinfo>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
#include "PieceTable.h"
|
||||
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
using us = std::chrono::microseconds;
|
||||
|
||||
struct Result {
|
||||
std::string name;
|
||||
std::string scenario;
|
||||
double micros = 0.0;
|
||||
std::size_t bytes = 0;
|
||||
};
|
||||
|
||||
|
||||
static void
|
||||
print_header()
|
||||
{
|
||||
std::cout << std::left << std::setw(14) << "Structure"
|
||||
<< std::left << std::setw(18) << "Scenario"
|
||||
<< std::right << std::setw(12) << "time(us)"
|
||||
<< std::right << std::setw(14) << "bytes"
|
||||
<< std::right << std::setw(14) << "MB/s"
|
||||
<< "\n";
|
||||
std::cout << std::string(72, '-') << "\n";
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
print_row(const Result &r)
|
||||
{
|
||||
double mb = r.bytes / (1024.0 * 1024.0);
|
||||
double mbps = (r.micros > 0.0) ? (mb / (r.micros / 1'000'000.0)) : 0.0;
|
||||
std::cout << std::left << std::setw(14) << r.name
|
||||
<< std::left << std::setw(18) << r.scenario
|
||||
<< std::right << std::setw(12) << std::fixed << std::setprecision(2) << r.micros
|
||||
<< std::right << std::setw(14) << r.bytes
|
||||
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << mbps
|
||||
<< "\n";
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_sequential_append(std::size_t N, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "seq_append";
|
||||
const char c = 'x';
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
b.AppendChar(c);
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_sequential_prepend(std::size_t N, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "seq_prepend";
|
||||
const char c = 'x';
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
b.PrependChar(c);
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_chunk_append(std::size_t N, std::size_t chunk, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "chunk_append";
|
||||
std::string payload(chunk, 'y');
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
b.Append(payload.data(), now);
|
||||
written += now;
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_mixed(std::size_t N, std::size_t chunk, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "mixed";
|
||||
std::string payload(chunk, 'z');
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
// alternate append/prepend with small chunks
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
if ((written / chunk) % 2 == 0) {
|
||||
b.Append(payload.data(), now);
|
||||
} else {
|
||||
b.Prepend(payload.data(), now);
|
||||
}
|
||||
written += now;
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
main(int argc, char **argv)
|
||||
{
|
||||
// Parameters
|
||||
std::size_t N = 100'000; // bytes per round
|
||||
int rounds = 5; // iterations
|
||||
std::size_t chunk = 1024; // chunk size for chunked scenarios
|
||||
if (argc >= 2)
|
||||
N = static_cast<std::size_t>(std::stoull(argv[1]));
|
||||
if (argc >= 3)
|
||||
rounds = std::stoi(argv[2]);
|
||||
if (argc >= 4)
|
||||
chunk = static_cast<std::size_t>(std::stoull(argv[3]));
|
||||
|
||||
std::cout << "KTE Buffer Microbenchmarks" << "\n";
|
||||
std::cout << "N=" << N << ", rounds=" << rounds << ", chunk=" << chunk << "\n\n";
|
||||
|
||||
print_header();
|
||||
|
||||
// Run for GapBuffer
|
||||
print_row(bench_sequential_append<GapBuffer>(N, rounds));
|
||||
print_row(bench_sequential_prepend<GapBuffer>(N, rounds));
|
||||
print_row(bench_chunk_append<GapBuffer>(N, chunk, rounds));
|
||||
print_row(bench_mixed<GapBuffer>(N, chunk, rounds));
|
||||
|
||||
// Run for PieceTable
|
||||
print_row(bench_sequential_append<PieceTable>(N, rounds));
|
||||
print_row(bench_sequential_prepend<PieceTable>(N, rounds));
|
||||
print_row(bench_chunk_append<PieceTable>(N, chunk, rounds));
|
||||
print_row(bench_mixed<PieceTable>(N, chunk, rounds));
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
/*
|
||||
* PerformanceSuite.cc - broader performance and verification benchmarks
|
||||
*/
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <typeinfo>
|
||||
#include <vector>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
#include "PieceTable.h"
|
||||
#include "OptimizedSearch.h"
|
||||
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
using us = std::chrono::microseconds;
|
||||
|
||||
namespace {
|
||||
struct Stat {
|
||||
double micros{0.0};
|
||||
std::size_t bytes{0};
|
||||
std::size_t ops{0};
|
||||
};
|
||||
|
||||
|
||||
static void
|
||||
print_header(const std::string &title)
|
||||
{
|
||||
std::cout << "\n" << title << "\n";
|
||||
std::cout << std::left << std::setw(18) << "Case"
|
||||
<< std::left << std::setw(18) << "Type"
|
||||
<< std::right << std::setw(12) << "time(us)"
|
||||
<< std::right << std::setw(14) << "bytes"
|
||||
<< std::right << std::setw(14) << "ops/s"
|
||||
<< std::right << std::setw(14) << "MB/s"
|
||||
<< "\n";
|
||||
std::cout << std::string(90, '-') << "\n";
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
print_row(const std::string &caseName, const std::string &typeName, const Stat &s)
|
||||
{
|
||||
double mb = s.bytes / (1024.0 * 1024.0);
|
||||
double sec = s.micros / 1'000'000.0;
|
||||
double mbps = sec > 0 ? (mb / sec) : 0.0;
|
||||
double opss = sec > 0 ? (static_cast<double>(s.ops) / sec) : 0.0;
|
||||
std::cout << std::left << std::setw(18) << caseName
|
||||
<< std::left << std::setw(18) << typeName
|
||||
<< std::right << std::setw(12) << std::fixed << std::setprecision(2) << s.micros
|
||||
<< std::right << std::setw(14) << s.bytes
|
||||
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << opss
|
||||
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << mbps
|
||||
<< "\n";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
class PerformanceSuite {
|
||||
public:
|
||||
void benchmarkBufferOperations(std::size_t N, int rounds, std::size_t chunk)
|
||||
{
|
||||
print_header("Buffer Operations");
|
||||
run_buffer_case<GapBuffer>("append_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.AppendChar('a');
|
||||
});
|
||||
run_buffer_case<GapBuffer>("prepend_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.PrependChar('a');
|
||||
});
|
||||
run_buffer_case<GapBuffer>("chunk_mix", N, rounds, chunk, [&](auto &b, std::size_t) {
|
||||
std::string payload(chunk, 'x');
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
if (((written / chunk) & 1) == 0)
|
||||
b.Append(payload.data(), now);
|
||||
else
|
||||
b.Prepend(payload.data(), now);
|
||||
written += now;
|
||||
}
|
||||
});
|
||||
run_buffer_case<PieceTable>("append_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.AppendChar('a');
|
||||
});
|
||||
run_buffer_case<PieceTable>("prepend_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.PrependChar('a');
|
||||
});
|
||||
run_buffer_case<PieceTable>("chunk_mix", N, rounds, chunk, [&](auto &b, std::size_t) {
|
||||
std::string payload(chunk, 'x');
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
if (((written / chunk) & 1) == 0)
|
||||
b.Append(payload.data(), now);
|
||||
else
|
||||
b.Prepend(payload.data(), now);
|
||||
written += now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void benchmarkSearchOperations(std::size_t textLen, std::size_t patLen, int rounds)
|
||||
{
|
||||
print_header("Search Operations");
|
||||
std::mt19937_64 rng(0xC0FFEE);
|
||||
std::uniform_int_distribution<int> dist('a', 'z');
|
||||
std::string text(textLen, '\0');
|
||||
for (auto &ch: text)
|
||||
ch = static_cast<char>(dist(rng));
|
||||
std::string pattern(patLen, '\0');
|
||||
for (auto &ch: pattern)
|
||||
ch = static_cast<char>(dist(rng));
|
||||
|
||||
// Ensure at least one hit
|
||||
if (textLen >= patLen && patLen > 0) {
|
||||
std::size_t pos = textLen / 2;
|
||||
std::memcpy(&text[pos], pattern.data(), patLen);
|
||||
}
|
||||
|
||||
// OptimizedSearch find_all vs std::string reference
|
||||
OptimizedSearch os;
|
||||
Stat s{};
|
||||
auto start = clock_t::now();
|
||||
std::size_t matches = 0;
|
||||
std::size_t bytesScanned = 0;
|
||||
for (int r = 0; r < rounds; ++r) {
|
||||
auto hits = os.find_all(text, pattern, 0);
|
||||
matches += hits.size();
|
||||
bytesScanned += text.size();
|
||||
// Verify with reference
|
||||
std::vector<std::size_t> ref;
|
||||
std::size_t from = 0;
|
||||
while (true) {
|
||||
auto p = text.find(pattern, from);
|
||||
if (p == std::string::npos)
|
||||
break;
|
||||
ref.push_back(p);
|
||||
from = p + (patLen ? patLen : 1);
|
||||
}
|
||||
assert(ref == hits);
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
s.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
s.bytes = bytesScanned;
|
||||
s.ops = matches;
|
||||
print_row("find_all", "OptimizedSearch", s);
|
||||
}
|
||||
|
||||
|
||||
void benchmarkMemoryAllocation(std::size_t N, int rounds)
|
||||
{
|
||||
print_header("Memory Allocation (allocations during editing)");
|
||||
// Measure number of allocations by simulating editing patterns.
|
||||
auto run_session = [&](auto &&buffer) {
|
||||
// alternate small appends and prepends
|
||||
const std::size_t chunk = 32;
|
||||
std::string payload(chunk, 'q');
|
||||
for (int r = 0; r < rounds; ++r) {
|
||||
buffer.Clear();
|
||||
for (std::size_t i = 0; i < N; i += chunk)
|
||||
buffer.Append(payload.data(), std::min(chunk, N - i));
|
||||
for (std::size_t i = 0; i < N / 2; i += chunk)
|
||||
buffer.Prepend(payload.data(), std::min(chunk, N / 2 - i));
|
||||
}
|
||||
};
|
||||
|
||||
// Local allocation counters for this TU via overriding operators
|
||||
reset_alloc_counters();
|
||||
GapBuffer gb;
|
||||
run_session(gb);
|
||||
auto gap_allocs = current_allocs();
|
||||
print_row("edit_session", "GapBuffer", Stat{
|
||||
0.0, static_cast<std::size_t>(gap_allocs.bytes),
|
||||
static_cast<std::size_t>(gap_allocs.count)
|
||||
});
|
||||
|
||||
reset_alloc_counters();
|
||||
PieceTable pt;
|
||||
run_session(pt);
|
||||
auto pt_allocs = current_allocs();
|
||||
print_row("edit_session", "PieceTable", Stat{
|
||||
0.0, static_cast<std::size_t>(pt_allocs.bytes),
|
||||
static_cast<std::size_t>(pt_allocs.count)
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
template<typename Buf, typename Fn>
|
||||
void run_buffer_case(const std::string &caseName, std::size_t N, int rounds, std::size_t chunk, Fn fn)
|
||||
{
|
||||
Stat s{};
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
std::size_t ops = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
fn(b, N);
|
||||
// compare to reference string where possible (only for append_char/prepend_char)
|
||||
bytes += N;
|
||||
ops += N / (chunk ? chunk : 1);
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
s.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
s.bytes = bytes;
|
||||
s.ops = ops;
|
||||
print_row(caseName, typeid(Buf).name(), s);
|
||||
}
|
||||
|
||||
|
||||
// Simple global allocation tracking for this TU
|
||||
struct AllocStats {
|
||||
std::uint64_t count{0};
|
||||
std::uint64_t bytes{0};
|
||||
};
|
||||
|
||||
|
||||
static AllocStats &alloc_stats()
|
||||
{
|
||||
static AllocStats s;
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
static void reset_alloc_counters()
|
||||
{
|
||||
alloc_stats() = {};
|
||||
}
|
||||
|
||||
|
||||
static AllocStats current_allocs()
|
||||
{
|
||||
return alloc_stats();
|
||||
}
|
||||
|
||||
|
||||
// Friend global new/delete defined below
|
||||
friend void *operator new(std::size_t sz) noexcept(false);
|
||||
|
||||
friend void operator delete(void *p) noexcept;
|
||||
|
||||
friend void *operator new[](std::size_t sz) noexcept(false);
|
||||
|
||||
friend void operator delete[](void *p) noexcept;
|
||||
};
|
||||
|
||||
// Override new/delete only in this translation unit to track allocations made here
|
||||
void *
|
||||
operator new(std::size_t sz) noexcept(false)
|
||||
{
|
||||
auto &s = PerformanceSuite::alloc_stats();
|
||||
s.count++;
|
||||
s.bytes += sz;
|
||||
if (void *p = std::malloc(sz))
|
||||
return p;
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
operator delete(void *p) noexcept
|
||||
{
|
||||
std::free(p);
|
||||
}
|
||||
|
||||
|
||||
void *
|
||||
operator new[](std::size_t sz) noexcept(false)
|
||||
{
|
||||
auto &s = PerformanceSuite::alloc_stats();
|
||||
s.count++;
|
||||
s.bytes += sz;
|
||||
if (void *p = std::malloc(sz))
|
||||
return p;
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
operator delete[](void *p) noexcept
|
||||
{
|
||||
std::free(p);
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
main(int argc, char **argv)
|
||||
{
|
||||
std::size_t N = 200'000; // bytes per round for buffer cases
|
||||
int rounds = 3;
|
||||
std::size_t chunk = 1024;
|
||||
if (argc >= 2)
|
||||
N = static_cast<std::size_t>(std::stoull(argv[1]));
|
||||
if (argc >= 3)
|
||||
rounds = std::stoi(argv[2]);
|
||||
if (argc >= 4)
|
||||
chunk = static_cast<std::size_t>(std::stoull(argv[3]));
|
||||
|
||||
std::cout << "KTE Performance Suite" << "\n";
|
||||
std::cout << "N=" << N << ", rounds=" << rounds << ", chunk=" << chunk << "\n";
|
||||
|
||||
PerformanceSuite suite;
|
||||
suite.benchmarkBufferOperations(N, rounds, chunk);
|
||||
suite.benchmarkSearchOperations(1'000'000, 16, rounds);
|
||||
suite.benchmarkMemoryAllocation(N, rounds);
|
||||
return 0;
|
||||
}
|
||||
@@ -24,5 +24,8 @@
|
||||
<string>10.13</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<!-- Allow running multiple instances of the app -->
|
||||
<key>LSMultipleInstancesProhibited</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,105 @@
|
||||
cmake_minimum_required(VERSION 3.15)
|
||||
|
||||
# Fix up a macOS .app bundle by copying non-system dylibs into
|
||||
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
|
||||
#
|
||||
# Usage:
|
||||
# cmake -DAPP_BUNDLE=/path/to/kge.app -P cmake/fix_bundle.cmake
|
||||
|
||||
if (NOT APP_BUNDLE)
|
||||
message(FATAL_ERROR "APP_BUNDLE not set. Invoke with -DAPP_BUNDLE=/path/to/App.app")
|
||||
endif ()
|
||||
|
||||
get_filename_component(APP_DIR "${APP_BUNDLE}" ABSOLUTE)
|
||||
set(EXECUTABLE "${APP_DIR}/Contents/MacOS/kge")
|
||||
|
||||
if (NOT EXISTS "${EXECUTABLE}")
|
||||
message(FATAL_ERROR "Executable not found at: ${EXECUTABLE}")
|
||||
endif ()
|
||||
|
||||
include(BundleUtilities)
|
||||
|
||||
# Directories to search when resolving prerequisites. We include Homebrew so that
|
||||
# if any deps are currently resolved from there, fixup_bundle will copy them into
|
||||
# the bundle and rewrite install names to be self-contained.
|
||||
set(DIRS
|
||||
"/usr/local/lib"
|
||||
"/opt/homebrew/lib"
|
||||
"/opt/homebrew/opt"
|
||||
)
|
||||
|
||||
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
|
||||
# libs it references recursively, copying every non-system dylib (e.g. Homebrew
|
||||
# SDL2, freetype, pcre2) into Contents/Frameworks; SDL3 is handled below.
|
||||
# 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.
|
||||
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
|
||||
set(gp_otool "/usr/bin/otool")
|
||||
set(CMAKE_INSTALL_NAME_TOOL "/usr/bin/install_name_tool")
|
||||
set(CMAKE_OTOOL "/usr/bin/otool")
|
||||
set(ENV{PATH} "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
|
||||
execute_process(COMMAND chmod -R u+w "${APP_DIR}/Contents/Frameworks")
|
||||
|
||||
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
|
||||
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
|
||||
# for it to be allowed to run.
|
||||
# We sign deep, but sometimes explicit signing of components is more reliable.
|
||||
message(STATUS "Re-signing ${APP_DIR} after fixup...")
|
||||
|
||||
# 1. Sign dylibs in Frameworks
|
||||
file(GLOB_RECURSE DYLIBS "${APP_DIR}/Contents/Frameworks/*.dylib")
|
||||
foreach (DYLIB ${DYLIBS})
|
||||
message(STATUS "Signing ${DYLIB}...")
|
||||
execute_process(COMMAND /usr/bin/codesign --force --sign - "${DYLIB}")
|
||||
endforeach ()
|
||||
|
||||
# 2. Sign nested executables
|
||||
message(STATUS "Signing nested kte...")
|
||||
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kte")
|
||||
|
||||
# 3. Sign the main executable explicitly
|
||||
message(STATUS "Signing main kge...")
|
||||
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kge")
|
||||
|
||||
# 4. Sign the main bundle
|
||||
execute_process(
|
||||
COMMAND /usr/bin/codesign --force --deep --sign - "${APP_DIR}"
|
||||
RESULT_VARIABLE CODESIGN_RESULT
|
||||
)
|
||||
|
||||
if (NOT CODESIGN_RESULT EQUAL 0)
|
||||
message(FATAL_ERROR "Codesign failed with error: ${CODESIGN_RESULT}")
|
||||
endif ()
|
||||
|
||||
message(STATUS "fix_bundle.cmake completed for ${APP_DIR}")
|
||||
+53
-15
@@ -2,14 +2,16 @@
|
||||
lib,
|
||||
stdenv,
|
||||
cmake,
|
||||
pkg-config,
|
||||
ncurses,
|
||||
pcre2,
|
||||
SDL2,
|
||||
libGL,
|
||||
xorg,
|
||||
installShellFiles,
|
||||
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
graphical ? false,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cmakeContent = builtins.readFile ./CMakeLists.txt;
|
||||
@@ -20,17 +22,23 @@ let
|
||||
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "kte";
|
||||
pname = if graphical then "kge" else "kte";
|
||||
inherit version;
|
||||
|
||||
src = lib.cleanSource ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
cmake
|
||||
ncurses
|
||||
pkg-config
|
||||
installShellFiles
|
||||
]
|
||||
++ lib.optionals graphical [
|
||||
] ++ lib.optionals graphical [
|
||||
copyDesktopItems
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
ncurses
|
||||
pcre2 # regex engine (optional; std::regex without it)
|
||||
] ++ lib.optionals graphical [
|
||||
SDL2
|
||||
libGL
|
||||
xorg.libX11
|
||||
@@ -39,6 +47,31 @@ stdenv.mkDerivation {
|
||||
cmakeFlags = [
|
||||
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
|
||||
"-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 = ''
|
||||
@@ -46,17 +79,22 @@ stdenv.mkDerivation {
|
||||
|
||||
mkdir -p $out/bin
|
||||
cp kte $out/bin/
|
||||
|
||||
installManPage ../docs/kte.1
|
||||
|
||||
''
|
||||
+ lib.optionalString graphical ''
|
||||
cp kge $out/bin/
|
||||
installManPage ../docs/kge.1
|
||||
mkdir -p $out/share/icons
|
||||
cp ../kge.png $out/share/icons/
|
||||
''
|
||||
+ ''
|
||||
${lib.optionalString graphical ''
|
||||
cp kge $out/bin/kge
|
||||
installManPage ../docs/kge.1
|
||||
|
||||
mkdir -p $out/share/icons/hicolor/256x256/apps
|
||||
cp ../kge.png $out/share/icons/hicolor/256x256/apps/kge.png
|
||||
''}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "kyle's text editor" + lib.optionalString graphical " (graphical)";
|
||||
platforms = lib.platforms.unix;
|
||||
mainProgram = if graphical then "kge" else "kte";
|
||||
};
|
||||
}
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Helper script to test Linux builds using Docker/Podman
|
||||
# This script mounts the current source tree into a Linux container,
|
||||
# builds kte in terminal-only mode, and runs the test suite.
|
||||
|
||||
set -e
|
||||
|
||||
# Detect whether to use docker or podman
|
||||
if command -v docker &> /dev/null; then
|
||||
CONTAINER_CMD="docker"
|
||||
elif command -v podman &> /dev/null; then
|
||||
CONTAINER_CMD="podman"
|
||||
else
|
||||
echo "Error: Neither docker nor podman found in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE_NAME="kte-linux"
|
||||
|
||||
# Check if image exists, if not, build it
|
||||
if ! $CONTAINER_CMD image inspect "$IMAGE_NAME" &> /dev/null; then
|
||||
echo "Building $IMAGE_NAME image..."
|
||||
$CONTAINER_CMD build -t "$IMAGE_NAME" .
|
||||
fi
|
||||
|
||||
# Run the container with the current directory mounted
|
||||
echo "Running Linux build and tests..."
|
||||
$CONTAINER_CMD run --rm -v "$(pwd):/kte" "$IMAGE_NAME"
|
||||
@@ -0,0 +1,245 @@
|
||||
# kte Benchmarking and Testing Guide
|
||||
|
||||
This document describes the benchmarking infrastructure and testing
|
||||
improvements added to ensure high performance and correctness of core
|
||||
operations.
|
||||
|
||||
## Overview
|
||||
|
||||
The kte test suite now includes comprehensive benchmarks and migration
|
||||
coverage tests to:
|
||||
|
||||
- Measure performance of core operations (PieceTable, Buffer, syntax
|
||||
highlighting)
|
||||
- Ensure no performance regressions from refactorings
|
||||
- Validate correctness of API migrations (Buffer::Rows() →
|
||||
GetLineString/GetLineView)
|
||||
- Provide performance baselines for future optimizations
|
||||
|
||||
## Running Tests
|
||||
|
||||
### All Tests (including benchmarks)
|
||||
|
||||
```bash
|
||||
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
|
||||
search, etc.
|
||||
- **15 benchmark tests**: Performance measurements for critical
|
||||
operations
|
||||
- **30 migration coverage tests**: Edge cases and correctness validation
|
||||
|
||||
Total: **98 tests**
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
### Buffer Iteration Patterns (5,000 lines)
|
||||
|
||||
| Pattern | Time | Speedup vs Rows() |
|
||||
|-----------------------------------------|---------|-------------------|
|
||||
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
|
||||
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
|
||||
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
|
||||
|
||||
**Key Insight**: `GetLineView()` provides zero-copy access and is
|
||||
dramatically faster than materializing the entire rows cache.
|
||||
|
||||
### PieceTable Operations (10,000 lines)
|
||||
|
||||
| Operation | Time |
|
||||
|-----------------------------|---------|
|
||||
| Sequential inserts (10K) | 2.1 ms |
|
||||
| Random inserts (5K) | 32.9 ms |
|
||||
| `GetLine()` sequential | 4.7 ms |
|
||||
| `GetLineRange()` sequential | 1.3 ms |
|
||||
|
||||
### Buffer Operations
|
||||
|
||||
| Operation | Time |
|
||||
|--------------------------------------|---------|
|
||||
| `Nrows()` (1M calls) | 13.0 ms |
|
||||
| `GetLineString()` (10K lines) | 4.8 ms |
|
||||
| `GetLineView()` (10K lines) | 1.6 ms |
|
||||
| `Rows()` materialization (10K lines) | 6.2 ms |
|
||||
|
||||
### Syntax Highlighting
|
||||
|
||||
| Operation | Time | Notes |
|
||||
|------------------------------------|---------|----------------|
|
||||
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
|
||||
| HighlighterEngine cache population | 19.9 ms | |
|
||||
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
|
||||
|
||||
### Large File Performance
|
||||
|
||||
| Operation | Time |
|
||||
|---------------------------------|---------|
|
||||
| Insert 50K lines | 0.53 ms |
|
||||
| Iterate 50K lines (GetLineView) | 2.7 ms |
|
||||
| Random access (10K accesses) | 1.8 ms |
|
||||
|
||||
## API Differences: GetLineString vs GetLineView
|
||||
|
||||
Understanding the difference between these APIs is critical:
|
||||
|
||||
### `GetLineString(row)`
|
||||
|
||||
- Returns: `std::string` (copy)
|
||||
- Content: Line text **without** trailing newline
|
||||
- Use case: When you need to modify the string or store it
|
||||
- Example: `"hello"` for line `"hello\n"`
|
||||
|
||||
### `GetLineView(row)`
|
||||
|
||||
- Returns: `std::string_view` (zero-copy)
|
||||
- Content: Raw line range **including** trailing newline
|
||||
- Use case: Read-only access, maximum performance
|
||||
- Example: `"hello\n"` for line `"hello\n"`
|
||||
- **Warning**: View becomes invalid after buffer modifications
|
||||
|
||||
### `Rows()`
|
||||
|
||||
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
|
||||
- Content: Lines **without** trailing newlines
|
||||
- Use case: Legacy code, being phased out
|
||||
- Performance: Slower due to materialization overhead
|
||||
|
||||
## Migration Coverage Tests
|
||||
|
||||
The `test_migration_coverage.cc` file provides 30 tests covering:
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Empty buffers
|
||||
- Single lines (with/without newlines)
|
||||
- Very long lines (10,000 characters)
|
||||
- Many empty lines (1,000 newlines)
|
||||
|
||||
### Consistency
|
||||
|
||||
- `GetLineString()` vs `GetLineView()` vs `Rows()`
|
||||
- Consistency after edits (insert, delete, split, join)
|
||||
|
||||
### Boundary Conditions
|
||||
|
||||
- First line access
|
||||
- Last line access
|
||||
- Line range boundaries
|
||||
|
||||
### Special Characters
|
||||
|
||||
- Tabs, carriage returns, null bytes
|
||||
- Unicode (UTF-8 multibyte characters)
|
||||
|
||||
### Stress Tests
|
||||
|
||||
- Large files (10,000 lines)
|
||||
- Many small operations (100+ inserts)
|
||||
- Alternating insert/delete patterns
|
||||
|
||||
### Regression Tests
|
||||
|
||||
- Shebang detection pattern (Editor.cc)
|
||||
- Empty buffer check pattern (Editor.cc)
|
||||
- Syntax highlighter pattern (all highlighters)
|
||||
- Swap snapshot pattern (Swap.cc)
|
||||
|
||||
## Performance Recommendations
|
||||
|
||||
Based on benchmark results:
|
||||
|
||||
1. **Prefer `GetLineView()` for read-only access**
|
||||
- 11x faster than `Rows()` for iteration
|
||||
- Zero-copy, minimal overhead
|
||||
- Use immediately (view invalidates on edit)
|
||||
|
||||
2. **Use `GetLineString()` when you need a copy**
|
||||
- Still 1.7x faster than `Rows()`
|
||||
- Safe to store and modify
|
||||
- Strips trailing newlines automatically
|
||||
|
||||
3. **Avoid `Rows()` in hot paths**
|
||||
- Materializes entire line cache
|
||||
- Slower for large files
|
||||
- Being phased out (legacy API)
|
||||
|
||||
4. **Cache `Nrows()` in tight loops**
|
||||
- Very fast (13ms for 1M calls)
|
||||
- But still worth caching in inner loops
|
||||
|
||||
5. **Leverage HighlighterEngine caching**
|
||||
- 38x speedup on cache hits
|
||||
- Automatically invalidates on edits
|
||||
- Prefetch viewport for smooth scrolling
|
||||
|
||||
## Adding New Benchmarks
|
||||
|
||||
To add a new benchmark:
|
||||
|
||||
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
|
||||
2. Use `BenchmarkTimer` to measure critical sections:
|
||||
```cpp
|
||||
{
|
||||
BenchmarkTimer timer("Operation description");
|
||||
// ... code to benchmark ...
|
||||
}
|
||||
```
|
||||
3. Print section headers with `std::cout` for clarity
|
||||
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
|
||||
|
||||
Example:
|
||||
|
||||
```cpp
|
||||
TEST(Benchmark_MyOperation) {
|
||||
std::cout << "\n=== My Operation Benchmark ===\n";
|
||||
|
||||
// Setup
|
||||
Buffer buf;
|
||||
std::string data = generate_test_data();
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
std::size_t result = 0;
|
||||
{
|
||||
BenchmarkTimer timer("My operation on 10K lines");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
result += my_operation(buf, i);
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(result > 0);
|
||||
}
|
||||
```
|
||||
|
||||
## Continuous Performance Monitoring
|
||||
|
||||
Run benchmarks regularly to detect regressions:
|
||||
|
||||
```bash
|
||||
# Run tests and save output
|
||||
./cmake-build-debug/kte_tests > benchmark_results.txt
|
||||
|
||||
# Compare with baseline
|
||||
diff benchmark_baseline.txt benchmark_results.txt
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- Significant time increases (>20%) in any benchmark
|
||||
- New operations that are slower than expected
|
||||
- Cache effectiveness degradation
|
||||
|
||||
## Conclusion
|
||||
|
||||
The benchmark suite provides:
|
||||
|
||||
- **Performance validation**: Ensures migrations don't regress
|
||||
performance
|
||||
- **Optimization guidance**: Identifies fastest APIs for each use case
|
||||
- **Regression detection**: Catches performance issues early
|
||||
- **Documentation**: Demonstrates correct API usage patterns
|
||||
|
||||
All 98 tests pass with 0 failures, confirming both correctness and
|
||||
performance of the migrated codebase.
|
||||
File diff suppressed because it is too large
Load Diff
+55
-15
@@ -2,27 +2,43 @@
|
||||
|
||||
## Overview
|
||||
|
||||
`TestFrontend` is a headless implementation of the `Frontend` interface designed to facilitate programmatic testing of editor features. It allows you to queue commands and text input manually, execute them step-by-step, and inspect the editor/buffer state.
|
||||
`TestFrontend` is a headless implementation of the `Frontend` interface
|
||||
designed to facilitate programmatic testing of editor features. It
|
||||
allows you to queue commands and text input manually, execute them
|
||||
step-by-step, and inspect the editor/buffer state.
|
||||
|
||||
## Components
|
||||
|
||||
### TestInputHandler
|
||||
|
||||
A programmable input handler that uses a queue-based system:
|
||||
- `QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` - Queue a specific command
|
||||
- `QueueText(const std::string &text)` - Queue text for insertion (character by character)
|
||||
|
||||
-
|
||||
`QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` -
|
||||
Queue a specific command
|
||||
- `QueueText(const std::string &text)` - Queue text for insertion (
|
||||
character by character)
|
||||
- `Poll(MappedInput &out)` - Returns queued commands one at a time
|
||||
- `IsEmpty()` - Check if the input queue is empty
|
||||
|
||||
### TestRenderer
|
||||
|
||||
A minimal no-op renderer for testing:
|
||||
- `Draw(Editor &ed)` - No-op implementation, just increments draw counter
|
||||
|
||||
- `Draw(Editor &ed)` - No-op implementation, just increments draw
|
||||
counter
|
||||
- `GetDrawCount()` - Returns the number of times Draw() was called
|
||||
- `ResetDrawCount()` - Resets the draw counter
|
||||
|
||||
### TestFrontend
|
||||
The main frontend class that integrates TestInputHandler and TestRenderer:
|
||||
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions to 24x80)
|
||||
- `Step(Editor &ed, bool &running)` - Processes one command from the queue and renders
|
||||
|
||||
The main frontend class that integrates TestInputHandler and
|
||||
TestRenderer:
|
||||
|
||||
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions
|
||||
to 24x80)
|
||||
- `Step(Editor &ed, bool &running)` - Processes one command from the
|
||||
queue and renders
|
||||
- `Shutdown()` - Cleanup (no-op for TestFrontend)
|
||||
- `Input()` - Access the TestInputHandler
|
||||
- `Renderer()` - Access the TestRenderer
|
||||
@@ -75,31 +91,55 @@ int main() {
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Programmable Input**: Queue any sequence of commands or text programmatically
|
||||
1. **Programmable Input**: Queue any sequence of commands or text
|
||||
programmatically
|
||||
2. **Step-by-Step Execution**: Run the editor one command at a time
|
||||
3. **State Inspection**: Access and verify editor/buffer state between commands
|
||||
4. **No UI Dependencies**: Headless operation, no terminal or GUI required
|
||||
5. **Integration Testing**: Test command sequences, undo/redo, multi-line editing, etc.
|
||||
3. **State Inspection**: Access and verify editor/buffer state between
|
||||
commands
|
||||
4. **No UI Dependencies**: Headless operation, no terminal or GUI
|
||||
required
|
||||
5. **Integration Testing**: Test command sequences, undo/redo,
|
||||
multi-line editing, etc.
|
||||
|
||||
## Available Commands
|
||||
|
||||
All commands from `CommandId` enum can be queued, including:
|
||||
|
||||
- `CommandId::InsertText` - Insert text (use `QueueText()` helper)
|
||||
- `CommandId::Newline` - Insert newline
|
||||
- `CommandId::Backspace` - Delete character before cursor
|
||||
- `CommandId::Backspace` - Delete character before cursor
|
||||
- `CommandId::DeleteChar` - Delete character at cursor
|
||||
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor movement
|
||||
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor
|
||||
movement
|
||||
- `CommandId::Undo`, `CommandId::Redo` - Undo/redo operations
|
||||
- `CommandId::Save`, `CommandId::Quit` - File operations
|
||||
- And many more (see Command.h)
|
||||
|
||||
## Integration
|
||||
|
||||
TestFrontend is built into both `kte` and `kge` executables as part of the common source files. You can create standalone test programs by linking against the same source files and ncurses.
|
||||
TestFrontend is built into both `kte` and `kge` executables as part of
|
||||
the common source files. You can create standalone test programs by
|
||||
linking against the same source files and ncurses.
|
||||
|
||||
## Notes
|
||||
|
||||
- Always call `InstallDefaultCommands()` before using any commands
|
||||
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before queuing edit commands
|
||||
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before
|
||||
queuing edit commands
|
||||
- Undo/redo requires the buffer to have an UndoSystem attached
|
||||
- The test frontend sets editor dimensions to 24x80 by default
|
||||
|
||||
## Highlighter stress harness
|
||||
|
||||
For renderer/highlighter race testing without a UI, `kte` provides a
|
||||
lightweight stress mode:
|
||||
|
||||
```
|
||||
kte --stress-highlighter=5
|
||||
```
|
||||
|
||||
This runs a short synthetic workload (5 seconds by default) that edits
|
||||
and scrolls a buffer while
|
||||
exercising `HighlighterEngine::PrefetchViewport` and `GetLine`
|
||||
concurrently. Use Debug builds with
|
||||
AddressSanitizer enabled for best effect.
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
# Bug and Performance Audit
|
||||
|
||||
**Project:** kte (Kyle's Text Editor)
|
||||
**Date:** 2026-09-23
|
||||
**Auditor:** Claude Code (automated review, four parallel reviewers plus
|
||||
spot verification)
|
||||
**Baseline:** `d8d2ae8` (debug build clean, `kte_tests` 0 failures)
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
The tree was split into four review areas: core text model (PieceTable,
|
||||
Buffer, OptimizedSearch), undo and swap (UndoSystem/Tree/Node, Swap,
|
||||
ErrorRecovery, SyscallWrappers), commands (Command.cc, KKeymap), and
|
||||
frontends/syntax (Terminal, ImGui, Qt, Editor, `syntax/`). Every finding
|
||||
below was traced to a concrete scenario. Findings marked **[repro]** were
|
||||
reproduced with a small test program; the rest were confirmed by reading
|
||||
the code.
|
||||
|
||||
Finding IDs are stable and are referenced by fix commits:
|
||||
|
||||
- **M** — memory safety (crash, out-of-bounds, use-after-free, data race)
|
||||
- **D** — data integrity (undo, swap journal, text corruption)
|
||||
- **B** — incorrect behaviour
|
||||
- **P** — performance
|
||||
|
||||
---
|
||||
|
||||
## M — Memory safety
|
||||
|
||||
### M1. PieceTable assignment keeps a stale line index **[repro]**
|
||||
|
||||
`PieceTable.cc:47-98`. Copy and move assignment copy `pieces_`, `add_`,
|
||||
and `total_size_`, but leave the destination's `line_index_` and
|
||||
`line_index_dirty_` untouched. `Editor::CloseBuffer` erases from
|
||||
`std::vector<Buffer>`, which move-assigns every later buffer into the
|
||||
previous slot.
|
||||
|
||||
Scenario: open A (`"line1\nline2\nline3\n"`), open B (`"x"`), render
|
||||
both, close A. Slot 0 now holds B's text with A's index: `LineCount()`
|
||||
returns 4 and `GetLineView(1)` builds a view over bytes 6–12 of a 1-byte
|
||||
buffer.
|
||||
|
||||
Fix: mark the line index dirty (and clear it) in both assignment
|
||||
operators.
|
||||
|
||||
### M2. Background highlighter thread races buffer mutation
|
||||
|
||||
`syntax/HighlighterEngine.cc:184-249`. `PrefetchViewport` (called from
|
||||
the terminal and ImGui renderers) queues rows for a worker thread which
|
||||
calls `buf.GetLineString`, walking `pieces_` and reading `add_`. The main
|
||||
thread's `PieceTable::Insert`/`Delete` mutate both without any shared
|
||||
lock; `add_.append` may reallocate. The worker also stores a raw
|
||||
`Buffer*` (`pending_.buf`) which dangles when `AddBuffer`'s `push_back`
|
||||
reallocates or `CloseBuffer`'s `erase` shifts buffers.
|
||||
|
||||
Fix: remove the background warm-up, or feed it an immutable snapshot.
|
||||
|
||||
### M3. Per-window SwapManager leaves dangling recorders (ImGui multi-window)
|
||||
|
||||
`ImGuiFrontend.cc:381-385`, `Editor.cc:205-213`. Each window's `Editor`
|
||||
owns its own `SwapManager` but shares the buffer vector. A buffer created
|
||||
from a secondary window keeps a `swap_rec_` pointing into that window's
|
||||
manager; closing the window destroys it, and the next edit in the main
|
||||
window calls into freed memory. `Rehome` from the wrong manager also
|
||||
returns `nullptr`, silently disabling journaling for the primary's
|
||||
buffers.
|
||||
|
||||
Fix: share one `SwapManager` across windows, and detach every buffer an
|
||||
`Editor` attached when that `Editor` is destroyed.
|
||||
|
||||
### M4. Recursive undo-tree free overflows the stack **[repro]**
|
||||
|
||||
`UndoTree.cc:12` (`free_node_graph`), also `UndoSystem.cc:354`
|
||||
(`is_descendant`, `dfs_find_parent`). Recursion depth equals history
|
||||
length. About 300k nodes segfaults a debug build on an 8 MB stack;
|
||||
replace-all over ~150k matches (two nodes each) followed by close or
|
||||
reload is enough.
|
||||
|
||||
Fix: free iteratively with an explicit stack.
|
||||
|
||||
### M5. Jump-to-mark places the cursor past the end of the buffer
|
||||
|
||||
`Command.cc:3720`. Marks are not adjusted on edits and
|
||||
`cmd_jump_to_mark` does not clamp. After deleting lines below a mark,
|
||||
C-k j puts the cursor on a nonexistent row; `cmd_move_left` and
|
||||
`cmd_move_up` (`:3866`, `:3988`) then index `rows[y]` out of bounds.
|
||||
|
||||
Fix: clamp in `cmd_jump_to_mark` and in `compute_mark_region`.
|
||||
|
||||
---
|
||||
|
||||
## D — Data integrity
|
||||
|
||||
### D1. Smart-newline undo deletes the wrong text **[repro]**
|
||||
|
||||
`Command.cc:3139-3146`. The indent is inserted and the cursor moved past
|
||||
it before `u->Begin(UndoType::Insert)`; `UndoSystem::Begin` records the
|
||||
cursor position, so the node is placed `indent.size()` columns too far
|
||||
right. `" foobar"` + SmartNewline + two undos yields
|
||||
`" foo next"` (the following line's text is spliced in and `bar`
|
||||
is lost).
|
||||
|
||||
Fix: `Begin` at `new_x`, then insert, then move the cursor; group the
|
||||
newline and the indent.
|
||||
|
||||
### D2. Cursor column left past end-of-line corrupts undo
|
||||
|
||||
`cmd_scroll_up`/`cmd_scroll_down` (`Command.cc:4178`, `:4215`, bound to
|
||||
the mouse wheel), replace-all's cursor restore (`:2580`), and
|
||||
`cmd_jump_to_mark` call `SetCursor` without clamping x. `delete_text`
|
||||
does not clamp; it treats the excess as crossing the newline.
|
||||
Backspace then joins lines with an empty (dropped) undo node, and
|
||||
typing followed by undo deletes a newline instead of the typed
|
||||
character.
|
||||
|
||||
Fix: clamp x to the line length in `Buffer::SetCursor`.
|
||||
|
||||
### D3. `delete_row` on a final line without trailing newline leaves a stray `\n`
|
||||
|
||||
`Buffer.cc:803`. Affects `delete_region` (C-w, M-d, M-Backspace), regex
|
||||
replace (`Command.cc:2996`), reflow (`:4884`), and the kill ring.
|
||||
`"foo\nbar"` + M-d at end of `foo` yields `"foo\n"`.
|
||||
|
||||
### D4. Phantom empty row after a trailing newline
|
||||
|
||||
`PieceTable::LineCount()` counts the empty row after a final `\n`.
|
||||
C-k C-d on that row records a `DeleteRow("")` whose undo inserts a
|
||||
line (`Command.cc:3516`); regex replace of `^` matches it and appends
|
||||
an extra line (`:2985`).
|
||||
|
||||
### D5. Missing or wrong undo grouping
|
||||
|
||||
- Plain replace-all (`Command.cc:2537-2577`) has no group: N
|
||||
replacements need 2N undos, and the final Insert stays pending so the
|
||||
next typed characters coalesce into it.
|
||||
- Visual-line newline (`:3043-3075`) records no undo at all.
|
||||
|
||||
### D6. Reflow on a blank line merges the adjacent paragraphs
|
||||
|
||||
`Command.cc:4592`. `"aaa\n\nbbb"` with the cursor on the blank line and
|
||||
ESC q gives `"aaa bbb"`.
|
||||
|
||||
### D7. Swap journal is fragile exactly where it matters
|
||||
|
||||
- `Swap.cc:1344-1373`: a torn final record (the power-loss case) makes
|
||||
the whole file unreadable instead of replaying the valid prefix.
|
||||
- `Swap.cc:1134-1157`, `:1187`, `:1224`: after any dropped record
|
||||
(breaker open, >16 MB payload, partial write) later records still
|
||||
replay against a different base state; a partial write leaves bytes
|
||||
that later records are appended behind.
|
||||
- `Swap.cc:1040` vs `:1187`: checkpoints allow 4 GB but the record
|
||||
length is 24 bits; buffers over 16 MB are never checkpointed and the
|
||||
journal grows without bound.
|
||||
- `Swap.cc:644`, `:708`: a failed header write leaves an fd with no
|
||||
header; answering N to "delete corrupt swap?" appends new edits to the
|
||||
corrupt file.
|
||||
|
||||
### D8. Circuit breaker failure window never expires
|
||||
|
||||
`ErrorRecovery.cc:77`. `last_failure_time_` is updated before
|
||||
`IsWindowExpired()` reads it, so elapsed time is always zero; five
|
||||
failures hours apart still open the breaker.
|
||||
|
||||
### D9. Stale syntax highlighting after undo-to-clean or reload
|
||||
|
||||
`Buffer.h:391-400`. `version_` is bumped only in `SetDirty(true)`. Undo
|
||||
back to the saved node calls `SetDirty(false)`, and `OpenFromFile`
|
||||
(reload) never bumps it, so `HighlighterEngine` keeps serving spans for
|
||||
the old text.
|
||||
|
||||
---
|
||||
|
||||
## B — Behaviour
|
||||
|
||||
### B1. `set_escdelay` is compiled out
|
||||
|
||||
`TerminalFrontend.cc:46`. `set_escdelay` is a function in ncurses, not a
|
||||
macro, so `#ifdef set_escdelay` is always false and ESC waits the
|
||||
1000 ms default.
|
||||
|
||||
### B2. Terminal resize runs C-g
|
||||
|
||||
`TerminalInputHandler.cc:167-171` maps `KEY_RESIZE` to
|
||||
`CommandId::Refresh`, the cancel handler: it clears the mark, cancels
|
||||
prompts (including the swap-recovery prompt, so the file is never
|
||||
opened), and resets the universal argument.
|
||||
|
||||
### B3. ESC-as-Meta flag is not cleared on most paths
|
||||
|
||||
`TerminalInputHandler.cc:56-279`. ESC followed by an arrow, Enter, Tab,
|
||||
or a control chord leaves `esc_meta` set; the next plain `d` runs M-d.
|
||||
Same in `ImGuiInputHandler.cc:43-73`.
|
||||
|
||||
### B4. C-h shadows Backspace in the terminal
|
||||
|
||||
The generic control lookup (`TerminalInputHandler.cc:264`) maps ^H to
|
||||
SearchReplace before the Backspace branch (`:307`) runs.
|
||||
|
||||
### B5. Byte-based editing splits UTF-8
|
||||
|
||||
`Command.cc:3259`, `:3352`, `:3861-3947`. Backspace, delete, and
|
||||
horizontal motion step by byte: Backspace on `é` leaves a lone `0xC3`,
|
||||
and C-b/C-f land inside code points.
|
||||
|
||||
### B6. Horizontal scroll counts bytes, renderer counts cells
|
||||
|
||||
`compute_render_x` (`Command.cc:52-60`) counts bytes while
|
||||
`TerminalRenderer.cc:269-322` skips display cells: a long CJK line
|
||||
scrolls to blank with the cursor off-screen.
|
||||
|
||||
### B7. GUI draws non-ASCII text as replacement glyphs
|
||||
|
||||
Highlighters emit one span per non-ASCII byte and `ImGuiRenderer.cc`
|
||||
(~510) / `QtFrontend.cc` (~333) decode each span separately.
|
||||
|
||||
### B8. ImGui drops the character after Ctrl+V
|
||||
|
||||
`ImGuiInputHandler.cc:362`, `:382`, `:401` set a suppress-next-text flag
|
||||
for chords that produce no text event.
|
||||
|
||||
### B9. Control characters mis-measured in the terminal
|
||||
|
||||
`TerminalRenderer.cc:314`, `:364`, `:413` count C0 controls as width 1;
|
||||
ncurses draws them as `^X`.
|
||||
|
||||
### B10. Qt tab width disagrees with the command layer
|
||||
|
||||
`QtFrontend.cc:138` uses 4; `Command.cc` uses 8.
|
||||
|
||||
### B11. Smart newline edits the old buffer while a prompt is open
|
||||
|
||||
`Command.cc:3131`. With a prompt active, `cmd_newline` accepts it and
|
||||
the saved indent is inserted through a possibly stale `Buffer*`.
|
||||
|
||||
### B12. Line count stale after merged appends **[repro]**
|
||||
|
||||
`PieceTable.cc:220-260`. `addPieceBack`/`addPieceFront` merge paths
|
||||
skip `InvalidateLineIndex()`. Latent today.
|
||||
|
||||
### B13. Syntax highlighter edge cases
|
||||
|
||||
Go raw strings and Rust multi-line strings do not carry state across
|
||||
lines; shell `$#` is highlighted as a comment; a C++ `#define` line
|
||||
opening `/*` does not carry the comment.
|
||||
|
||||
---
|
||||
|
||||
## P — Performance
|
||||
|
||||
### P1. Whole-file `Rows()` rebuild per frame
|
||||
|
||||
Renderers call `buf->Rows()` every frame (`TerminalRenderer.cc:45`,
|
||||
`ImGuiRenderer.cc:69`, `QtFrontend.cc:477`), and
|
||||
`ensure_cursor_visible` (`Command.cc:91`) after every command. After an
|
||||
edit this rebuilds a `std::string` per line, each via two `GetRange`
|
||||
calls.
|
||||
|
||||
### P2. Whole-buffer materialization for one line
|
||||
|
||||
`Buffer::GetLineView` (`Buffer.cc:633`) calls `content_.Data()`, which
|
||||
copies the entire file; the line index is rebuilt byte-by-byte after
|
||||
every edit.
|
||||
|
||||
### P3. Re-highlight from row 0 on every keystroke
|
||||
|
||||
`SetDirty(true)` calls `InvalidateFrom(0)`; stateful highlighters then
|
||||
re-lex from the top of the file to the viewport.
|
||||
|
||||
### P4. Quadratic region commands
|
||||
|
||||
Replace-all, `delete_region`, indent/unindent, reflow, and repeated
|
||||
backspace re-fetch `Rows()` after each mutation inside their loops.
|
||||
|
||||
### P5. Opening a file checkpoints into a journal that is then deleted
|
||||
|
||||
`Swap.cc:493` snapshots, writes, and fsyncs the whole file into
|
||||
`unnamed-N.swp` before removing it.
|
||||
|
||||
### P6. Boyer–Moore bad-character shift degenerates to 1
|
||||
|
||||
`OptimizedSearch.cc:63`, `:96`: when the mismatched byte is absent from
|
||||
the pattern the shift is 1 instead of `j`. Test-only today.
|
||||
|
||||
---
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. M1 (one-line fix)
|
||||
2. M2 (remove or snapshot the warm-up thread)
|
||||
3. D2 + M5 (clamp in `SetCursor` and jump-to-mark)
|
||||
4. D1 (smart-newline undo order)
|
||||
5. D7 (accept a torn final swap record)
|
||||
6. P1/P2 (`Nrows()` + `GetLineString` in hot paths)
|
||||
|
||||
---
|
||||
|
||||
## Fix status
|
||||
|
||||
Updated as fixes land on `claude/funny-fermat-gj2vm5`.
|
||||
|
||||
| ID | Status |
|
||||
|----|--------|
|
||||
| M1 | fixed: assignment operators invalidate the line index |
|
||||
| M2 | fixed: background warm-up thread removed; visible rows highlighted synchronously |
|
||||
| M3 | fixed: editors sharing buffers share the owner's SwapManager |
|
||||
| M4 | fixed: iterative free/search; `clear()` also leaked root siblings, now frees all |
|
||||
| M5 | fixed: jump-to-mark clamps; move up/down clamp the row (move-down also underflowed) |
|
||||
| D1 | fixed: `Begin` before the cursor moves; newline + indent grouped |
|
||||
| D2 | fixed for scroll up/down and replace-all restore |
|
||||
| D3 | fixed: kill-region, regex replace, reflow and kill-line edit byte ranges in place |
|
||||
| D4 | fixed: regex replace skips the row after a final newline (a zero-width pattern used to hang the editor); kill-line there is a no-op |
|
||||
| D5 | fixed: replace-all one undo group; visual-line newline recorded as one group |
|
||||
| D6 | fixed: reflow on a blank line is a no-op |
|
||||
| D7 | fixed: torn tail replays the valid prefix; lost records mark a gap resynced by checkpoint; partial writes truncated; header failure closes; large inserts split; oversize checkpoints skipped; declined corrupt swap moved to `.corrupt` |
|
||||
| D8 | fixed: window checked before recording the failure |
|
||||
| D9 | fixed: every text mutation bumps the version (including undo to the saved state); reload and replace also clear undo/highlighting |
|
||||
| B1 | fixed: guard on `NCURSES_VERSION` |
|
||||
| B2 | fixed: `KEY_RESIZE` issues no command |
|
||||
| B3 | fixed: ESC-meta flag consumed on every key; ESC ^H and ESC Enter reach their bindings |
|
||||
| B4 | not changed: C-h is a deliberate binding (search & replace); terminals whose terminfo `kbs` is ^H are translated to KEY_BACKSPACE by ncurses |
|
||||
| B5 | fixed: backspace/delete/left/right step over whole UTF-8 characters; up/down snap to a boundary |
|
||||
| B6 | fixed: horizontal scroll and mouse column use display cells (mbrtowc/wcwidth) |
|
||||
| B7 | fixed: spans are widened to whole UTF-8 characters before the GUIs draw them (`SanitizeSpans`) |
|
||||
| B8 | fixed: a text-event suppression lasts only until the next key press |
|
||||
| B9 | fixed: C0 controls and DEL drawn and measured as two-cell caret notation (`TermWidth.h`) |
|
||||
| B10 | fixed: one tab width (`kte::kTabWidth`); the Qt frontend has since been removed |
|
||||
| B13 | open (highlighter edge cases) |
|
||||
| B11 | fixed: SmartNewline with a prompt open delegates to Newline |
|
||||
| B12 | fixed: merged appends invalidate the line index |
|
||||
| P1 | fixed: `ensure_cursor_visible` and the terminal/ImGui renderers no longer call `Rows()` |
|
||||
| P2 | fixed: line index updated in place on insert/delete (14.7 ms -> 0.28 ms per keystroke on a 38 MB buffer) |
|
||||
| P3 | fixed: highlighting invalidated from the edited row (46 ms -> 0.14 ms per keystroke near the end of a 100k-line C++ file) |
|
||||
| P4 | fixed: replace-all, delete-region, indent/unindent and visual-line edits each apply as one edit |
|
||||
| P5 | fixed: no throwaway checkpoint on filename change |
|
||||
| P6 | moot: `OptimizedSearch` was test-only and has been removed |
|
||||
|
||||
Regression tests: `tests/test_audit_regressions.cc`. Each test fails on the
|
||||
unfixed code (M4 by crashing).
|
||||
|
||||
---
|
||||
|
||||
## Follow-up review rounds
|
||||
|
||||
After the first batch of fixes, further review rounds (an adversarial
|
||||
review of each batch of changes, plus fresh reviews of areas the first
|
||||
audit covered lightly) found further defects. All of the following are
|
||||
fixed on this branch, each with a regression test that fails on the code
|
||||
before the fix (unless noted).
|
||||
|
||||
**Crash / hang**
|
||||
- Opening a directory (bad_alloc exit), a FIFO (open blocked forever), or
|
||||
a device node (later replaced by a regular file on save).
|
||||
- Filesystem exceptions (ENAMETOOLONG, EACCES, deleted cwd) and any other
|
||||
exception escaping a command terminated the editor.
|
||||
- Regex search/replace, and renderer match highlighting, overflowed the
|
||||
stack on long lines (std::regex recursion); a zero-width regex replace
|
||||
hung the editor (D4).
|
||||
- Piped (non-tty) stdin was executed as commands, then spun at full CPU.
|
||||
|
||||
**Data loss / corruption**
|
||||
- Recovery deleted the swap it was about to replay when another named
|
||||
buffer was current (pre-existing).
|
||||
- Recovered sessions appended behind a torn final record.
|
||||
- Reload left the old journal (discarded edits came back on recovery);
|
||||
reload discarded unsaved edits without confirmation and "reloaded" a
|
||||
deleted file as empty.
|
||||
- Two buffers (duplicate open, save-as onto an open file) or two
|
||||
sessions shared one journal; closing/saving one deleted the other's
|
||||
live journal. Journals are now flock()ed; a session never removes a
|
||||
journal it does not own; unnamed journals include the pid; distinct
|
||||
paths no longer map to one swap name.
|
||||
- Journals now record the base file's size, mtime and CRC-32 and are
|
||||
not replayed onto a file that changed afterwards.
|
||||
- Quit checked only the current buffer; Enter at "Recover?" / "Save
|
||||
changes?" took the destructive branch; the close prompt's save
|
||||
overwrote on-disk changes; save-as overwrote other files silently.
|
||||
- Kill-line with a count, stale marks in kill-region, commands running
|
||||
behind an open prompt (read-only bypass, wrong buffer closed), paste
|
||||
into a prompt, nested undo groups, UTF-8 splitting in word motions.
|
||||
- Save: fsync retried after failure (could report success for lost
|
||||
data); new files got 0600; symlinks replaced; hard links split;
|
||||
owner and setuid/setgid bits lost; umask race with the writer thread.
|
||||
- Writer-thread fsync raced with closes (fd reuse; kept a closed
|
||||
journal's lock alive); crc32 table init race; Close() retried EINTR.
|
||||
|
||||
**Performance**
|
||||
- Motion/delete commands rebuilt every line after an edit (80-90 ms per
|
||||
keystroke on 500k lines; C-u 3000 C-d took 273 s, now 0.25 s).
|
||||
- Line index invalidated by consolidation; incremental index added.
|
||||
- Idle redraw every 16 ms rescanned long lines (one core busy); paste
|
||||
handled one key per frame; RowsView retained unbounded copies.
|
||||
|
||||
**Fourth round (stress testing)**
|
||||
- Undo/redo of multi-line text left the cursor past the end of the
|
||||
line; later edits and their undo records went astray and
|
||||
delete-word-prev read out of bounds (ASan).
|
||||
- A recovered buffer looked clean after one edit and its undo; closing
|
||||
it discarded the recovered text and journal. Save-and-quit did not
|
||||
record the saved state; stale visual-line selections pointed past the
|
||||
end of the buffer; open undo groups survived an exception.
|
||||
- A reload that ran out of memory emptied the buffer, and the next save
|
||||
truncated the file. Exceptions from deferred opens ended the editor.
|
||||
A leftover hard-link copy blocked every later save; a file in a
|
||||
read-only directory could not be saved.
|
||||
- The journal writer's periodic fsync held the lock every keystroke
|
||||
needs (typing froze on slow disks); every open and save re-read the
|
||||
whole file under that lock; two ThreadSanitizer races.
|
||||
- Replace-all, yank and repeat counts issued one edit per match, line
|
||||
or repetition (quadratic: minutes for 229k matches, now 0.14 s).
|
||||
- With LANG unset, multibyte input was dropped. Renderer caches and the
|
||||
reload confirmation were keyed on buffer addresses, which are reused
|
||||
after a close; a reload confirmation survived other commands; a swap
|
||||
recovery prompt from a deferred open was not drawn until a key.
|
||||
|
||||
**Fifth round (review of the fourth round's changes)**
|
||||
- The save fallbacks added in round four (write in place when no temp
|
||||
file or hard-link copy can be made) also fired on ENOSPC/EDQUOT/EIO,
|
||||
truncating the file and then failing: the file on disk was left
|
||||
corrupt. They now apply only when the directory refuses a new name
|
||||
(EACCES, EPERM, ENAMETOOLONG); otherwise the save fails and the file
|
||||
is untouched.
|
||||
- Whole-buffer replace recorded the entire old and new text for undo on
|
||||
every run (about 150 MB more per replace-all on a 50 MB file); only
|
||||
the span between the common prefix and suffix is now recorded.
|
||||
- Regex search silently never found matches on lines over 20,000 bytes;
|
||||
the skip now applies only to search-as-you-type (at 2,000 bytes, where
|
||||
the worst case is well under a second), says so in the status line,
|
||||
and moving to the next/previous match searches every line.
|
||||
- After a lost journal record, the recovery checkpoint was requested
|
||||
only by later edits, at most once a second; if the first attempt
|
||||
failed and the user then stopped typing, the journal stayed behind
|
||||
indefinitely. The editor loop now retries it each second while idle
|
||||
(this was also why SwapReplay_LostRecord_ResyncsWithCheckpoint was
|
||||
flaky under load).
|
||||
- Undoing C-k C-k (and redoing an inserted row) journaled the row and
|
||||
its newline as two records; a checkpoint triggered by the first
|
||||
already contained the newline, so recovery silently produced an
|
||||
extra blank line.
|
||||
- Visual-line yank with the cursor moved out of the selection (C-k a,
|
||||
Enter) left it past the end of a line or the buffer, and text typed
|
||||
there could not be undone. Every command now leaves the cursor
|
||||
inside the buffer (checked in the command dispatcher).
|
||||
- ENABLE_ASAN instrumented only C sources, i.e. none of the project;
|
||||
it now applies to C++ as well. (Earlier "ASan clean" results in this
|
||||
audit came from that uninstrumented build; the full suite has since
|
||||
passed under a correctly instrumented ASan build with leak checks,
|
||||
and the stress fuzzers ran with ASan and UBSan.)
|
||||
|
||||
**Sixth round (real terminal, file I/O, recovery end to end)**
|
||||
- UTF-8 typed or pasted over a slow link lost characters: with the
|
||||
input batch read at zero timeout, ncurses gave up on a character split
|
||||
across reads and discarded the rest as invalid. kte now assembles
|
||||
UTF-8 itself and waits for continuation bytes. (This regression came
|
||||
from this audit's own input batching.)
|
||||
- Visual-line (multi-line) insert, delete, backspace and newline used
|
||||
the cursor's byte column on every selected line, splitting multibyte
|
||||
characters on the others; the column is now carried in characters.
|
||||
- Journal failures reached only the error log: a session locked out of
|
||||
the journal by another kte (which only happens when the other opened
|
||||
the file before this one's first edit), or whose journal writes
|
||||
failed, silently had no or partial crash recovery. The status line now
|
||||
says so (once per failure).
|
||||
- Basenames over about 233 bytes produced a swap name over NAME_MAX, so
|
||||
such files were never journaled. (The first fix capped the name at
|
||||
exactly 255 bytes, which broke compaction's ".tmp" sibling and let
|
||||
the journal grow without bound; it now leaves room for it.)
|
||||
|
||||
**Seventh round (highlighters, region and paragraph commands, buffers)**
|
||||
- Indent-region on a selection ending in the empty row after a final
|
||||
newline added a tab-only last line (empty lines are now skipped).
|
||||
- Reflow emitted a prefix-only line before a first word longer than the
|
||||
width (a blank line before a long URL, a bullet split from its text),
|
||||
adding another on every reflow; joined paragraphs separated by a
|
||||
whitespace-only line; and moved the CRs of CRLF lines into the middle
|
||||
of lines.
|
||||
- A counted backspace/delete that joined lines took several undos.
|
||||
- The C++ highlighter scanned to the end of the line for a raw-string
|
||||
'(' at every R", quadratic on long lines.
|
||||
- Fuzzing every highlighter (incremental vs full), 20,000 random command
|
||||
sequences, and 6,300 random buffer-list sessions found nothing else.
|
||||
|
||||
**Eighth round (reflow review; large-file performance)**
|
||||
- Reflow's CRLF handling applied only when every line had a CR, so the
|
||||
last paragraph of a CRLF file without a final newline still got CRs
|
||||
mid-line; wrapping could start a line with "-" or "1.", which the next
|
||||
reflow read as a list.
|
||||
- Large files (26-105 MB): the stateful highlighter cached spans for
|
||||
every row above the viewport (854 MB for a 78 MB C++ file after
|
||||
jumping to the end; now 197 MB); search built and kept a string per
|
||||
line on every keystroke after an edit (now one scan of the
|
||||
materialized text: 42 -> 28 ms per keystroke on 26 MB); indent,
|
||||
unindent and visual-line insert/delete/backspace/newline/yank made one
|
||||
piece-table edit per row (1.1 s per keystroke with 5,000 rows
|
||||
selected; now 4 ms, as one edit).
|
||||
|
||||
**Ninth round (review of the eighth round's changes)**
|
||||
- No serious findings. Highlighting (cached vs fresh), search (against
|
||||
the old per-line code, 3,200 random buffers) and the row-range edits
|
||||
(differential against the previous commit, ~88k random steps: text
|
||||
identical after every step; undo-all and journal replay exact) all
|
||||
agreed. Redo-all after heavy branching can end on an older branch of
|
||||
the undo tree (a state that did exist), as before.
|
||||
|
||||
**Follow-up: undo memory**
|
||||
- Undo texts of 4 KiB or more are held as references to the piece
|
||||
table's (append-only) storage instead of copies, verified byte for
|
||||
byte when converted; undo/redo re-splice the stored bytes instead of
|
||||
copying them into storage again. A replace-all session on a 26 MB file
|
||||
ends at 181 MB instead of 332 MB; undo/redo cycles of a 6.6 MB kill
|
||||
stay at 75 MB instead of growing by 6.6 MB each.
|
||||
- History has a byte budget (default max(64 MiB, twice the buffer)):
|
||||
over it, branches off the current path, then the oldest edits (whole
|
||||
undo groups), then the redo line are dropped. If the saved state goes,
|
||||
the buffer stays modified until the next save.
|
||||
- replace_all_bytes clears undo history (it was recorded against, and
|
||||
may reference, the previous content).
|
||||
|
||||
**Follow-up: search**
|
||||
- Search finds the next match from the search origin (then from the
|
||||
current match) and stops there; it no longer collects every match in
|
||||
the buffer on every keystroke and next/previous. The i/N count stops
|
||||
at 1,000 matches (and, for regex, after 4 MiB). On 26 MB: plain
|
||||
keystroke 30 ms -> 0.1 ms, next 24 -> 3 ms, regex keystroke 434 ->
|
||||
1.6 ms. Previous-match scans backwards in 1 MiB forward-searched
|
||||
blocks (string_view::rfind: 511 ms -> 6 ms on 105 MB).
|
||||
- Semantics change: the first match is the first one at or after the
|
||||
cursor (it was the first in the buffer), and Left/Right also step
|
||||
through matches in the regex replace prompt; Up/Down in a regex prompt
|
||||
use the regex.
|
||||
|
||||
**Follow-up: journals for large buffers**
|
||||
- Buffers over 16 MiB are checkpointed as CHKPT_BEGIN (size, CRC-32),
|
||||
data chunks and CHKPT_END; replay applies the checkpoint only when it
|
||||
is complete and its CRC matches, and treats an unfinished one as a
|
||||
torn tail. Such buffers could not be checkpointed at all: a lost
|
||||
record left the journal permanently behind and it was never
|
||||
compacted. The journal header is now version 2 (version 1 journals
|
||||
are still read; older kte builds will not read version 2).
|
||||
- Checkpoint thresholds scale with the buffer: after edits amounting to
|
||||
half its size, on the timer only after a sixteenth, compaction at
|
||||
twice its size, gap retries spaced by size. A checkpoint that would
|
||||
compact is written straight into the compacted journal.
|
||||
- The recovery prompt keeps the content it replayed for the preview;
|
||||
answering y installs it instead of replaying the journal again.
|
||||
- The CRC of a base file over 64 MiB is computed by the journal writer
|
||||
thread (it was skipped), so a touched but unchanged large file no
|
||||
longer makes its journal unusable for recovery.
|
||||
|
||||
**Follow-up: regex engine**
|
||||
- kte::Regex (RegexEngine.h) wraps PCRE2 with its JIT when libpcre2-8
|
||||
is found (KTE_USE_PCRE2, default ON), std::regex otherwise. Search,
|
||||
regex replace-all and the terminal and ImGui renderers use it (the Qt
|
||||
frontend's highlighting still calls std::regex). PCRE2 has a match
|
||||
limit, so catastrophic backtracking ("^(a+)+$") stops with a status
|
||||
note instead of hanging, and does not recurse on the C stack, so its
|
||||
per-keystroke line limit is 1 MiB instead of 2,000 bytes. Replacement
|
||||
strings keep std::regex_replace semantics with either engine.
|
||||
- On 26 MB: regex keystroke 1.6 -> 0.14 ms, next 62 -> 6 ms,
|
||||
replace-all 929 -> 438 ms; a rarely matching regex over 105 MB:
|
||||
1.55 s -> 0.10 s per keystroke.
|
||||
- Only one checkpoint per journal is queued at a time; whole-file edits
|
||||
in a row on a large buffer each queued a full copy of it.
|
||||
- Review of this work found, and these are fixed: with PCRE2, '.'
|
||||
matched the CR of CRLF lines (regex replace-all deleted it) — PCRE2
|
||||
now uses the ANYCRLF newline convention and $ matches only at the end;
|
||||
$0, out-of-range $n and $nn in replacements now follow libstdc++;
|
||||
plain search visits overlapping matches in both directions (the count
|
||||
could read "2/1"); the recovery prompt replays again if the journal
|
||||
changed while it was open; a version 1 journal continued after
|
||||
recovery gets a chunked checkpoint only by being rewritten as
|
||||
version 2.
|
||||
|
||||
**Known limitations (not fixed)**
|
||||
- Without PCRE2 (std::regex builds), catastrophic regex backtracking
|
||||
can still take very long; std::regex has no limit.
|
||||
- Without PCRE2 on libc++ (macOS), '^' also matches where a search
|
||||
resumes inside a line (libc++ ignores match_prev_avail, and
|
||||
match_not_bol with it, for '^'), and '\b' never matches at the end of
|
||||
a line. Search_NextPrevVisitReferenceMatches fails in that
|
||||
configuration; the default macOS build uses PCRE2.
|
||||
- Typing in a multi-megabyte single line costs O(line length) per
|
||||
keystroke (cursor column computed by scanning the line).
|
||||
- Highlighter edge cases B13 are unchanged. (B7, B8 and B10 were fixed
|
||||
in the debt pass below, once the GUIs could be built and run.)
|
||||
- Journals are created at the first edit, so a second kte that opens
|
||||
the file before the first one edits it is not warned at open; the
|
||||
session that is locked out is told at its first edit instead.
|
||||
- A regex that matches rarely or not at all scans the whole buffer
|
||||
(0.1 s on 105 MB with PCRE2, 1.6 s with std::regex).
|
||||
|
||||
---
|
||||
|
||||
## Debt and performance pass (2026-09-24)
|
||||
|
||||
After the merge of the fixes above. Both GUI frontends (ImGui and Qt) were
|
||||
built in this environment and checked by screenshot under Xvfb; the ImGui
|
||||
renderer changes are pixel-identical to before on a sample with tabs,
|
||||
UTF-8, syntax colouring and search highlights.
|
||||
|
||||
**Performance**
|
||||
- Typing near the top of a large file: each edit shifted every later line
|
||||
start. The shift is now pending and applied on read; piece lookup
|
||||
binary-searches prefix sums instead of walking the piece list. Typing on
|
||||
line 10 of a 1M-line file, with a frame's worth of line reads per
|
||||
keystroke: 0.52 -> 0.017 ms.
|
||||
- A loaded file is adopted as the piece table's original storage: the
|
||||
first keystroke no longer copies the whole file (140 ms at 200 MB), and
|
||||
the read buffer is not copied again. Save writes the pieces instead of
|
||||
materializing a second full copy that then stayed resident. An unedited
|
||||
(single-piece) buffer is viewed in place; `GetLineView` no longer copies
|
||||
the whole buffer after an edit when the line lies within one piece.
|
||||
- Line reads decide "ends with a newline" from the line index instead of
|
||||
fetching the last byte; the index is rebuilt with `memchr`.
|
||||
- ImGui measured every span and the cursor from column 0 of the line
|
||||
(quadratic on long lines); the terminal renderer rescanned spans per
|
||||
byte; Qt compiled a `std::regex` per visible line per frame. All now
|
||||
linear, with search-match ranges computed by one shared
|
||||
`SearchHighlight`.
|
||||
- The highlighter engine returns cached spans by reference and no longer
|
||||
`dynamic_cast`s per call.
|
||||
|
||||
**Bugs found on the way**
|
||||
- Qt: operators were drawn in the dark background colour (invisible);
|
||||
tabs were 4 columns against the command layer's 8; text after a
|
||||
multi-byte character was placed one column per byte; the editor's
|
||||
column count was computed three different ways from painting and the
|
||||
cell width was rounded, so on long lines the cursor drifted off the
|
||||
text. Unhighlighted text between spans is now drawn.
|
||||
- B7, B8 (above).
|
||||
|
||||
**Debt removed**
|
||||
- `Buffer::Rows()` and the `Line` wrapper (tests use `GetLineString` or a
|
||||
test-only `LinesForTests()`); `OptimizedSearch`, `UndoNodePool.h`, the
|
||||
unused swap SPLIT/JOIN writers and several unused Editor/Buffer/PieceTable
|
||||
members; mutexes that guarded only reads in single-threaded classes
|
||||
(the claim that PieceTable reads were thread-safe was false).
|
||||
- Consolidated: filetype detection and highlighter installation
|
||||
(`Buffer::ApplyDetectedFiletype`, `InstallFiletypeHighlighter`), whole
|
||||
buffer copies (`Buffer::Bytes`), character measurement
|
||||
(`kte::MeasureChar`, `DisplayColumns`), span sanitizing
|
||||
(`kte::SanitizeSpans`), word motion scans, the search reset, and the
|
||||
regex engine's stack policy (`kte::Regex::RunGuarded`).
|
||||
|
||||
**Left as is**
|
||||
- The C-k / ESC-meta / universal-argument key state machine is still
|
||||
written once per frontend; unifying it is a behavioural change best done
|
||||
with its own tests.
|
||||
- The highlighter keeps one line state per row (about 40 bytes each) and
|
||||
highlighters copy each line; both are modest next to the file itself.
|
||||
- A long run of backspaces grows its undo record by prepending (quadratic
|
||||
only in the length of one typed run).
|
||||
|
||||
---
|
||||
|
||||
## Follow-up: macOS build and Qt removal (2026-09-24)
|
||||
|
||||
The gate had only run on Linux (as root). On macOS it failed three
|
||||
tests and the app bundle could not start.
|
||||
|
||||
- kge stopped at launch with "Failed loading SDL3 library": Homebrew's
|
||||
sdl2 is sdl2-compat, which dlopen()s SDL3, and the bundle fixup copies
|
||||
only linked libraries. SDL3 is now bundled when sdl2-compat is.
|
||||
- Saving lost setuid/setgid when not running as root: writing the data
|
||||
clears them, and the mode was applied before the write. Ownership and
|
||||
mode now go on after the data; in-place writes restore the mode.
|
||||
- Two tests used libc++'s std::regex as the reference, which misses
|
||||
'\b' at the end of the subject and ignores match_prev_avail for '^'.
|
||||
- After C-k, pressing Shift (for C-k C, C-k ^ or a shifted symbol) or
|
||||
pressing Ctrl again (C-k C-d) ended the prefix as an unknown command
|
||||
in kge; modifier keys alone are now ignored there (checked with
|
||||
synthetic SDL key events against kge's objects).
|
||||
- The Qt frontend was removed: unused, out of make-app-release since
|
||||
March, not built by the gate, and its bundle could not start (no
|
||||
platform plugin). Every renderer and input change had to be made in
|
||||
it separately.
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
# Error Propagation Standardization Report
|
||||
|
||||
**Project:** kte (Kyle's Text Editor)
|
||||
**Date:** 2026-02-17
|
||||
**Auditor:** Error Propagation Standardization Review
|
||||
**Language:** C++20
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report documents the standardization of error propagation patterns
|
||||
across the kte codebase. Following the implementation of centralized
|
||||
error handling (ErrorHandler), this audit identifies inconsistencies in
|
||||
error propagation and provides concrete remediation recommendations.
|
||||
|
||||
**Key Findings:**
|
||||
|
||||
- **Dominant Pattern**: `bool + std::string &err` is used consistently
|
||||
in Buffer and SwapManager for I/O operations
|
||||
- **Inconsistencies**: PieceTable has no error reporting mechanism; some
|
||||
internal helpers lack error propagation
|
||||
- **Standard Chosen**: `bool + std::string &err` pattern (C++20 project,
|
||||
std::expected not available)
|
||||
- **Documentation**: Comprehensive error handling conventions added to
|
||||
DEVELOPER_GUIDE.md
|
||||
|
||||
**Overall Assessment**: The codebase has a **solid foundation** with the
|
||||
`bool + err` pattern used consistently in critical I/O paths. Primary
|
||||
gaps are in PieceTable memory allocation error handling and some
|
||||
internal helper functions.
|
||||
|
||||
---
|
||||
|
||||
## 1. CURRENT STATE ANALYSIS
|
||||
|
||||
### 1.1 Error Propagation Patterns Found
|
||||
|
||||
#### Pattern 1: `bool + std::string &err` (Dominant)
|
||||
|
||||
**Usage**: File I/O, swap operations, resource allocation
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `Buffer::OpenFromFile(const std::string &path, std::string &err)` (
|
||||
Buffer.h:72)
|
||||
- `Buffer::Save(std::string &err)` (Buffer.h:74)
|
||||
- `Buffer::SaveAs(const std::string &path, std::string &err)` (Buffer.h:
|
||||
75)
|
||||
- `Editor::OpenFile(const std::string &path, std::string &err)` (
|
||||
Editor.h:536)
|
||||
-
|
||||
`SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)` (
|
||||
Swap.h:104)
|
||||
-
|
||||
`SwapManager::open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` (
|
||||
Swap.h:208)
|
||||
-
|
||||
`SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err)` (
|
||||
Swap.h:212-213)
|
||||
|
||||
**Assessment**: ✅ **Excellent** - Consistent, well-implemented,
|
||||
integrated with ErrorHandler
|
||||
|
||||
#### Pattern 2: `void` (State Changes)
|
||||
|
||||
**Usage**: Setters, cursor movement, flag toggles, internal state
|
||||
modifications
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `Buffer::SetCursor(std::size_t x, std::size_t y)` (Buffer.h:348)
|
||||
- `Buffer::SetDirty(bool d)` (Buffer.h:368)
|
||||
- `Buffer::SetMark(std::size_t x, std::size_t y)` (Buffer.h:387)
|
||||
- `Buffer::insert_text(int row, int col, std::string_view text)` (
|
||||
Buffer.h:545)
|
||||
- `Buffer::delete_text(int row, int col, std::size_t len)` (Buffer.h:
|
||||
547)
|
||||
- `Editor::SetStatus(const std::string &msg)` (Editor.h:various)
|
||||
|
||||
**Assessment**: ✅ **Appropriate** - These operations are infallible
|
||||
state changes
|
||||
|
||||
#### Pattern 3: `bool` without error parameter (Control Flow)
|
||||
|
||||
**Usage**: Validation checks, control flow decisions
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `Editor::ProcessPendingOpens()` (Editor.h:544)
|
||||
- `Editor::ResolveRecoveryPrompt(bool yes)` (Editor.h:558)
|
||||
- `Editor::SwitchTo(std::size_t index)` (Editor.h:563)
|
||||
- `Editor::CloseBuffer(std::size_t index)` (Editor.h:565)
|
||||
|
||||
**Assessment**: ✅ **Appropriate** - Success/failure is sufficient for
|
||||
control flow
|
||||
|
||||
#### Pattern 4: No Error Reporting (PieceTable)
|
||||
|
||||
**Usage**: Memory allocation, text manipulation
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `void PieceTable::Reserve(std::size_t newCapacity)` (PieceTable.h:71)
|
||||
- `void PieceTable::Append(const char *s, std::size_t len)` (
|
||||
PieceTable.h:75)
|
||||
-
|
||||
`void PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)` (
|
||||
PieceTable.h:118)
|
||||
- `char *PieceTable::Data()` (PieceTable.h:89-93) - returns nullptr on
|
||||
allocation failure
|
||||
|
||||
**Assessment**: ⚠️ **Gap** - Memory allocation failures are not reported
|
||||
|
||||
---
|
||||
|
||||
## 2. STANDARDIZATION DECISION
|
||||
|
||||
### 2.1 Chosen Pattern: `bool + std::string &err`
|
||||
|
||||
**Rationale**:
|
||||
|
||||
1. **C++20 Project**: `std::expected` (C++23) is not available
|
||||
2. **Existing Adoption**: Already used consistently in Buffer,
|
||||
SwapManager, Editor for I/O operations
|
||||
3. **Clear Semantics**: `bool` return indicates success/failure, `err`
|
||||
provides details
|
||||
4. **ErrorHandler Integration**: Works seamlessly with centralized error
|
||||
logging
|
||||
5. **Zero Overhead**: No exceptions, no dynamic allocation for error
|
||||
paths
|
||||
6. **Testability**: Easy to verify error messages in unit tests
|
||||
|
||||
**Alternative Considered**: `std::expected<T, std::string>` (C++23)
|
||||
|
||||
- **Rejected**: Requires C++23, would require major refactoring, not
|
||||
available in current toolchain
|
||||
|
||||
### 2.2 Pattern Selection Guidelines
|
||||
|
||||
| Operation Type | Pattern | Example |
|
||||
|---------------------|---------------------------|-----------------------------------------------------------------------------------|
|
||||
| File I/O | `bool + std::string &err` | `Buffer::Save(std::string &err)` |
|
||||
| Syscalls | `bool + std::string &err` | `open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` |
|
||||
| Resource Allocation | `bool + std::string &err` | Future: `PieceTable::Reserve(std::size_t cap, std::string &err)` |
|
||||
| Parsing/Validation | `bool + std::string &err` | `SwapManager::ReplayFile(Buffer &buf, const std::string &path, std::string &err)` |
|
||||
| State Changes | `void` | `Buffer::SetCursor(std::size_t x, std::size_t y)` |
|
||||
| Control Flow | `bool` (no err) | `Editor::SwitchTo(std::size_t index)` |
|
||||
|
||||
---
|
||||
|
||||
## 3. INCONSISTENCIES AND GAPS
|
||||
|
||||
### 3.1 PieceTable Memory Allocation (Severity: 6/10)
|
||||
|
||||
**Finding**: PieceTable methods that allocate memory (`Reserve`,
|
||||
`Append`, `Insert`, `Data`) do not report allocation failures.
|
||||
|
||||
**Impact**:
|
||||
|
||||
- Memory allocation failures are silent
|
||||
- `Data()` returns `nullptr` on failure, but callers may not check
|
||||
- Large file operations could fail without user notification
|
||||
|
||||
**Evidence**:
|
||||
|
||||
```cpp
|
||||
// PieceTable.h:71
|
||||
void Reserve(std::size_t newCapacity); // No error reporting
|
||||
|
||||
// PieceTable.h:89-93
|
||||
char *Data(); // Returns nullptr on allocation failure
|
||||
```
|
||||
|
||||
**Remediation Priority**: **Medium** - Memory allocation failures are
|
||||
rare on modern systems, but should be handled for robustness
|
||||
|
||||
**Recommended Fix**:
|
||||
|
||||
**Option 1: Add error parameter to fallible operations** (Preferred)
|
||||
|
||||
```cpp
|
||||
// PieceTable.h
|
||||
bool Reserve(std::size_t newCapacity, std::string &err);
|
||||
bool Append(const char *s, std::size_t len, std::string &err);
|
||||
bool Insert(std::size_t byte_offset, const char *text, std::size_t len, std::string &err);
|
||||
|
||||
// Returns nullptr on failure; check with HasMaterializationError()
|
||||
char *Data();
|
||||
bool HasMaterializationError() const;
|
||||
std::string GetMaterializationError() const;
|
||||
```
|
||||
|
||||
**Option 2: Use exceptions for allocation failures** (Not recommended)
|
||||
|
||||
PieceTable could throw `std::bad_alloc` on allocation failures, but this
|
||||
conflicts with the project's error handling philosophy and would require
|
||||
exception handling throughout the codebase.
|
||||
|
||||
**Option 3: Status quo with improved documentation** (Minimal change)
|
||||
|
||||
Document that `Data()` can return `nullptr` and callers must check. Add
|
||||
assertions in debug builds.
|
||||
|
||||
```cpp
|
||||
// PieceTable.h
|
||||
// Returns pointer to materialized buffer, or nullptr if materialization fails.
|
||||
// Callers MUST check for nullptr before dereferencing.
|
||||
char *Data();
|
||||
```
|
||||
|
||||
**Recommendation**: **Option 3** for now (document + assertions), *
|
||||
*Option 1** if memory allocation errors become a concern in production.
|
||||
|
||||
### 3.2 Internal Helper Functions (Severity: 4/10)
|
||||
|
||||
**Finding**: Some internal helper functions in Swap.cc and Buffer.cc use
|
||||
`bool` returns without error parameters.
|
||||
|
||||
**Examples**:
|
||||
|
||||
```cpp
|
||||
// Swap.cc:562
|
||||
static bool ensure_parent_dir(const std::string &path); // No error details
|
||||
|
||||
// Swap.cc:579
|
||||
static bool write_header(int fd); // No error details
|
||||
|
||||
// Buffer.cc:101
|
||||
static bool write_all_fd(int fd, const char *data, std::size_t len, std::string &err); // ✅ Good
|
||||
```
|
||||
|
||||
**Impact**: Limited - These are internal helpers called by functions
|
||||
that do report errors
|
||||
|
||||
**Remediation Priority**: **Low** - Callers already provide error
|
||||
context
|
||||
|
||||
**Recommended Fix**: Add error parameters to internal helpers for
|
||||
consistency
|
||||
|
||||
```cpp
|
||||
// Swap.cc
|
||||
static bool ensure_parent_dir(const std::string &path, std::string &err);
|
||||
static bool write_header(int fd, std::string &err);
|
||||
```
|
||||
|
||||
**Status**: **Deferred** - Low priority, callers already provide
|
||||
adequate error context
|
||||
|
||||
### 3.3 Editor Control Flow Methods (Severity: 2/10)
|
||||
|
||||
**Finding**: Editor methods like `SwitchTo()`, `CloseBuffer()` return
|
||||
`bool` without error details.
|
||||
|
||||
**Assessment**: ✅ **Appropriate** - These are control flow decisions
|
||||
where success/failure is sufficient
|
||||
|
||||
**Remediation**: **None needed** - Current pattern is correct for this
|
||||
use case
|
||||
|
||||
---
|
||||
|
||||
## 4. ERRORHANDLER INTEGRATION STATUS
|
||||
|
||||
### 4.1 Components with ErrorHandler Integration
|
||||
|
||||
✅ **Buffer** (Buffer.cc)
|
||||
|
||||
- `OpenFromFile()` - Reports file open, seek, read errors
|
||||
- `Save()` - Reports write errors
|
||||
- `SaveAs()` - Reports write errors
|
||||
|
||||
✅ **SwapManager** (Swap.cc)
|
||||
|
||||
- `report_error()` - All swap file errors reported
|
||||
- Background thread errors captured and logged
|
||||
- Errno captured for all syscalls
|
||||
|
||||
✅ **main** (main.cc)
|
||||
|
||||
- Top-level exception handler reports Critical errors
|
||||
- Both `std::exception` and unknown exceptions captured
|
||||
|
||||
### 4.2 Components Without ErrorHandler Integration
|
||||
|
||||
⚠️ **PieceTable** (PieceTable.cc)
|
||||
|
||||
- No error reporting mechanism
|
||||
- Memory allocation failures are silent
|
||||
|
||||
⚠️ **Editor** (Editor.cc)
|
||||
|
||||
- File operations delegate to Buffer (✅ covered)
|
||||
- Control flow methods don't need error reporting (✅ appropriate)
|
||||
|
||||
⚠️ **Command** (Command.cc)
|
||||
|
||||
- Commands use `Editor::SetStatus()` for user-facing messages
|
||||
- No ErrorHandler integration for command failures
|
||||
- **Assessment**: Commands are user-initiated actions; status messages
|
||||
are appropriate
|
||||
|
||||
---
|
||||
|
||||
## 5. DOCUMENTATION STATUS
|
||||
|
||||
### 5.1 Error Handling Conventions (DEVELOPER_GUIDE.md)
|
||||
|
||||
✅ **Added comprehensive section** covering:
|
||||
|
||||
- Three standard error propagation patterns
|
||||
- Pattern selection guidelines with decision tree
|
||||
- ErrorHandler integration requirements
|
||||
- Code examples for file I/O, syscalls, background threads, top-level
|
||||
handlers
|
||||
- Anti-patterns and best practices
|
||||
- Error log location and format
|
||||
- Migration guide for updating existing code
|
||||
|
||||
**Location**: `docs/DEVELOPER_GUIDE.md` section 7
|
||||
|
||||
### 5.2 API Documentation
|
||||
|
||||
⚠️ **Gap**: Individual function documentation in headers could be
|
||||
improved
|
||||
|
||||
**Recommendation**: Add brief comments to public APIs documenting error
|
||||
behavior
|
||||
|
||||
```cpp
|
||||
// Buffer.h
|
||||
// Opens a file and loads its content into the buffer.
|
||||
// Returns false on failure; err contains detailed error message.
|
||||
// Errors are logged to ErrorHandler.
|
||||
bool OpenFromFile(const std::string &path, std::string &err);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. REMEDIATION RECOMMENDATIONS
|
||||
|
||||
### 6.1 High Priority (Severity 7-10)
|
||||
|
||||
**None identified** - Critical error handling gaps were addressed in
|
||||
previous sessions:
|
||||
|
||||
- ✅ Top-level exception handler added (Severity 9/10)
|
||||
- ✅ Background thread error reporting added (Severity 9/10)
|
||||
- ✅ File I/O error checking added (Severity 8/10)
|
||||
- ✅ Errno capture added to swap operations (Severity 7/10)
|
||||
- ✅ Centralized error handling implemented (Severity 7/10)
|
||||
|
||||
### 6.2 Medium Priority (Severity 4-6)
|
||||
|
||||
#### 6.2.1 PieceTable Memory Allocation Error Handling (Severity: 6/10)
|
||||
|
||||
**Action**: Document that `Data()` can return `nullptr` and add debug
|
||||
assertions
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```cpp
|
||||
// PieceTable.h
|
||||
// Returns pointer to materialized buffer, or nullptr if materialization fails
|
||||
// due to memory allocation error. Callers MUST check for nullptr.
|
||||
char *Data();
|
||||
|
||||
// PieceTable.cc
|
||||
char *PieceTable::Data() {
|
||||
materialize();
|
||||
assert(materialized_ != nullptr && "PieceTable materialization failed");
|
||||
return materialized_;
|
||||
}
|
||||
```
|
||||
|
||||
**Effort**: Low (documentation + assertions)
|
||||
**Risk**: Low (no API changes)
|
||||
**Timeline**: Next maintenance cycle
|
||||
|
||||
#### 6.2.2 Add Error Parameters to Internal Helpers (Severity: 4/10)
|
||||
|
||||
**Action**: Add `std::string &err` parameters to `ensure_parent_dir()`
|
||||
and `write_header()`
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```cpp
|
||||
// Swap.cc
|
||||
static bool ensure_parent_dir(const std::string &path, std::string &err) {
|
||||
try {
|
||||
fs::path p(path);
|
||||
fs::path dir = p.parent_path();
|
||||
if (dir.empty())
|
||||
return true;
|
||||
if (!fs::exists(dir))
|
||||
fs::create_directories(dir);
|
||||
return true;
|
||||
} catch (const std::exception &e) {
|
||||
err = std::string("Failed to create directory: ") + e.what();
|
||||
return false;
|
||||
} catch (...) {
|
||||
err = "Failed to create directory: unknown error";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Effort**: Low (update 2 functions + call sites)
|
||||
**Risk**: Low (internal helpers only)
|
||||
**Timeline**: Next maintenance cycle
|
||||
|
||||
### 6.3 Low Priority (Severity 1-3)
|
||||
|
||||
#### 6.3.1 Add Function-Level Error Documentation (Severity: 3/10)
|
||||
|
||||
**Action**: Add brief comments to public APIs documenting error behavior
|
||||
|
||||
**Effort**: Medium (many functions to document)
|
||||
**Risk**: None (documentation only)
|
||||
**Timeline**: Ongoing as code is touched
|
||||
|
||||
#### 6.3.2 Add ErrorHandler Integration to Commands (Severity: 2/10)
|
||||
|
||||
**Action**: Consider logging command failures to ErrorHandler for
|
||||
diagnostics
|
||||
|
||||
**Assessment**: **Not recommended** - Commands are user-initiated
|
||||
actions; status messages are more appropriate than error logs
|
||||
|
||||
---
|
||||
|
||||
## 7. TESTING RECOMMENDATIONS
|
||||
|
||||
### 7.1 Error Handling Test Coverage
|
||||
|
||||
**Current State**:
|
||||
|
||||
- ✅ Swap file error handling tested (test_swap_edge_cases.cc)
|
||||
- ✅ Buffer I/O error handling tested (test_buffer_io.cc)
|
||||
- ⚠️ PieceTable allocation failure testing missing
|
||||
|
||||
**Recommendations**:
|
||||
|
||||
1. **Add PieceTable allocation failure tests** (if Option 1 from 3.1 is
|
||||
implemented)
|
||||
2. **Add ErrorHandler query tests** - Verify error logging and retrieval
|
||||
3. **Add errno capture tests** - Verify errno is captured correctly in
|
||||
syscall failures
|
||||
|
||||
### 7.2 Test Examples
|
||||
|
||||
```cpp
|
||||
// test_error_handler.cc
|
||||
TEST(ErrorHandler, LogsErrorsWithContext) {
|
||||
ErrorHandler::Instance().Error("TestComponent", "Test error", "test.txt");
|
||||
EXPECT_TRUE(ErrorHandler::Instance().HasErrors());
|
||||
EXPECT_EQ(ErrorHandler::Instance().GetErrorCount(), 1);
|
||||
std::string last = ErrorHandler::Instance().GetLastError();
|
||||
EXPECT_TRUE(last.find("Test error") != std::string::npos);
|
||||
EXPECT_TRUE(last.find("test.txt") != std::string::npos);
|
||||
}
|
||||
|
||||
// test_piece_table.cc (if Option 1 implemented)
|
||||
TEST(PieceTable, ReportsAllocationFailure) {
|
||||
PieceTable pt;
|
||||
std::string err;
|
||||
// Attempt to allocate huge buffer
|
||||
bool ok = pt.Reserve(SIZE_MAX, err);
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_FALSE(err.empty());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. MIGRATION CHECKLIST
|
||||
|
||||
For developers updating existing code to follow error handling
|
||||
conventions:
|
||||
|
||||
- [ ] Identify all error-prone operations (file I/O, syscalls,
|
||||
allocations)
|
||||
- [ ] Add `std::string &err` parameter if not present
|
||||
- [ ] Clear `err` at function start: `err.clear();`
|
||||
- [ ] Capture `errno` immediately after syscall failures:
|
||||
`int saved_errno = errno;`
|
||||
- [ ] Build detailed error messages with context (paths, operation
|
||||
details)
|
||||
- [ ] Call `ErrorHandler::Instance().Error()` at all error sites
|
||||
- [ ] Return `false` on failure, `true` on success
|
||||
- [ ] Update all call sites to handle the error parameter
|
||||
- [ ] Write unit tests that verify error handling
|
||||
- [ ] Update function documentation to describe error behavior
|
||||
|
||||
---
|
||||
|
||||
## 9. SUMMARY AND NEXT STEPS
|
||||
|
||||
### 9.1 Achievements
|
||||
|
||||
✅ **Standardized on `bool + std::string &err` pattern** for error-prone
|
||||
operations
|
||||
✅ **Documented comprehensive error handling conventions** in
|
||||
DEVELOPER_GUIDE.md
|
||||
✅ **Identified and prioritized remaining gaps** (PieceTable, internal
|
||||
helpers)
|
||||
✅ **Integrated ErrorHandler** into Buffer, SwapManager, and main
|
||||
✅ **Established clear pattern selection guidelines** for future
|
||||
development
|
||||
|
||||
### 9.2 Remaining Work
|
||||
|
||||
**Medium Priority**:
|
||||
|
||||
1. Document PieceTable `Data()` nullptr behavior and add assertions
|
||||
2. Add error parameters to internal helper functions
|
||||
|
||||
**Low Priority**:
|
||||
|
||||
3. Add function-level error documentation to public APIs
|
||||
4. Add ErrorHandler query tests
|
||||
|
||||
### 9.3 Conclusion
|
||||
|
||||
The kte codebase has achieved **strong error handling consistency** with
|
||||
the `bool + std::string &err` pattern used uniformly across critical I/O
|
||||
paths. The centralized ErrorHandler provides comprehensive logging and
|
||||
UI integration. Remaining gaps are minor and primarily affect edge
|
||||
cases (memory allocation failures) that are rare in practice.
|
||||
|
||||
**Overall Grade**: **B+ (8.5/10)**
|
||||
|
||||
**Strengths**:
|
||||
|
||||
- Consistent error propagation in Buffer and SwapManager
|
||||
- Comprehensive ErrorHandler integration
|
||||
- Excellent documentation in DEVELOPER_GUIDE.md
|
||||
- Errno capture for all syscalls
|
||||
- Top-level exception handling
|
||||
|
||||
**Areas for Improvement**:
|
||||
|
||||
- PieceTable memory allocation error handling
|
||||
- Internal helper function error propagation
|
||||
- Function-level API documentation
|
||||
|
||||
The error handling infrastructure is **production-ready** and provides a
|
||||
solid foundation for reliable operation and debugging.
|
||||
@@ -0,0 +1,601 @@
|
||||
# PieceTable Migration Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines the plan to remove GapBuffer support from kte and
|
||||
migrate to using a **single PieceTable per Buffer**, rather than the
|
||||
current vector-of-Lines architecture where each Line contains either a
|
||||
GapBuffer or PieceTable.
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### Text Storage
|
||||
|
||||
**Current Implementation:**
|
||||
|
||||
- `Buffer` contains `std::vector<Line> rows_`
|
||||
- Each `Line` wraps an `AppendBuffer` (type alias)
|
||||
- `AppendBuffer` is either `GapBuffer` (default) or `PieceTable` (via
|
||||
`KTE_USE_PIECE_TABLE`)
|
||||
- Each line is independently managed with its own buffer
|
||||
- Operations are line-based with coordinate pairs (row, col)
|
||||
|
||||
**Key Files:**
|
||||
|
||||
- `Buffer.h/cc` - Buffer class with vector of Lines
|
||||
- `AppendBuffer.h` - Type selector (GapBuffer vs PieceTable)
|
||||
- `GapBuffer.h/cc` - Per-line gap buffer implementation
|
||||
- `PieceTable.h/cc` - Per-line piece table implementation
|
||||
- `UndoSystem.h/cc` - Records operations with (row, col, text)
|
||||
- `UndoNode.h` - Undo operation types (Insert, Delete, Paste, Newline,
|
||||
DeleteRow)
|
||||
- `Command.cc` - High-level editing commands
|
||||
|
||||
### Current Buffer API
|
||||
|
||||
**Low-level editing operations (used by UndoSystem):**
|
||||
|
||||
```cpp
|
||||
void insert_text(int row, int col, std::string_view text);
|
||||
void delete_text(int row, int col, std::size_t len);
|
||||
void split_line(int row, int col);
|
||||
void join_lines(int row);
|
||||
void insert_row(int row, std::string_view text);
|
||||
void delete_row(int row);
|
||||
```
|
||||
|
||||
**Line access:**
|
||||
|
||||
```cpp
|
||||
std::vector<Line> &Rows();
|
||||
const std::vector<Line> &Rows() const;
|
||||
```
|
||||
|
||||
**Line API (Buffer::Line):**
|
||||
|
||||
```cpp
|
||||
std::size_t size() const;
|
||||
const char *Data() const;
|
||||
char operator[](std::size_t i) const;
|
||||
std::string substr(std::size_t pos, std::size_t len) const;
|
||||
std::size_t find(const std::string &needle, std::size_t pos) const;
|
||||
void erase(std::size_t pos, std::size_t len);
|
||||
void insert(std::size_t pos, const std::string &seg);
|
||||
Line &operator+=(const Line &other);
|
||||
Line &operator+=(const std::string &s);
|
||||
```
|
||||
|
||||
### Current PieceTable Limitations
|
||||
|
||||
The existing `PieceTable` class only supports:
|
||||
|
||||
- `Append(char/string)` - add to end
|
||||
- `Prepend(char/string)` - add to beginning
|
||||
- `Clear()` - empty the buffer
|
||||
- `Data()` / `Size()` - access content (materializes on demand)
|
||||
|
||||
**Missing capabilities needed for buffer-wide storage:**
|
||||
|
||||
- Insert at arbitrary byte position
|
||||
- Delete at arbitrary byte position
|
||||
- Line indexing and line-based queries
|
||||
- Position conversion (byte offset ↔ line/col)
|
||||
- Efficient line boundary tracking
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Design Overview
|
||||
|
||||
**Single PieceTable per Buffer:**
|
||||
|
||||
- `Buffer` contains one `PieceTable content_` (replaces
|
||||
`std::vector<Line> rows_`)
|
||||
- Text stored as continuous byte sequence with `\n` as line separators
|
||||
- Line index cached for efficient line-based operations
|
||||
- All operations work on byte offsets internally
|
||||
- Buffer provides line/column API as convenience layer
|
||||
|
||||
### Enhanced PieceTable Design
|
||||
|
||||
```cpp
|
||||
class PieceTable {
|
||||
public:
|
||||
// Existing API (keep for compatibility if needed)
|
||||
void Append(const char *s, std::size_t len);
|
||||
void Prepend(const char *s, std::size_t len);
|
||||
void Clear();
|
||||
const char *Data() const;
|
||||
std::size_t Size() const;
|
||||
|
||||
// NEW: Core byte-based editing operations
|
||||
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
||||
void Delete(std::size_t byte_offset, std::size_t len);
|
||||
|
||||
// NEW: Line-based queries
|
||||
std::size_t LineCount() const;
|
||||
std::string GetLine(std::size_t line_num) const;
|
||||
std::pair<std::size_t, std::size_t> GetLineRange(std::size_t line_num) const; // (start, end) byte offsets
|
||||
|
||||
// NEW: Position conversion
|
||||
std::pair<std::size_t, std::size_t> ByteOffsetToLineCol(std::size_t byte_offset) const;
|
||||
std::size_t LineColToByteOffset(std::size_t row, std::size_t col) const;
|
||||
|
||||
// NEW: Substring extraction
|
||||
std::string GetRange(std::size_t byte_offset, std::size_t len) const;
|
||||
|
||||
// NEW: Search support
|
||||
std::size_t Find(const std::string &needle, std::size_t start_offset) const;
|
||||
|
||||
private:
|
||||
// Existing members
|
||||
std::string original_;
|
||||
std::string add_;
|
||||
std::vector<Piece> pieces_;
|
||||
mutable std::string materialized_;
|
||||
mutable bool dirty_;
|
||||
std::size_t total_size_;
|
||||
|
||||
// NEW: Line index for efficient line operations
|
||||
struct LineInfo {
|
||||
std::size_t byte_offset; // absolute byte offset from buffer start
|
||||
std::size_t piece_idx; // which piece contains line start
|
||||
std::size_t offset_in_piece; // byte offset within that piece
|
||||
};
|
||||
mutable std::vector<LineInfo> line_index_;
|
||||
mutable bool line_index_dirty_;
|
||||
|
||||
// NEW: Line index management
|
||||
void RebuildLineIndex() const;
|
||||
void InvalidateLineIndex();
|
||||
};
|
||||
```
|
||||
|
||||
### Buffer API Changes
|
||||
|
||||
```cpp
|
||||
class Buffer {
|
||||
public:
|
||||
// NEW: Direct content access
|
||||
PieceTable &Content() { return content_; }
|
||||
const PieceTable &Content() const { return content_; }
|
||||
|
||||
// MODIFIED: Keep existing API but implement via PieceTable
|
||||
void insert_text(int row, int col, std::string_view text);
|
||||
void delete_text(int row, int col, std::size_t len);
|
||||
void split_line(int row, int col);
|
||||
void join_lines(int row);
|
||||
void insert_row(int row, std::string_view text);
|
||||
void delete_row(int row);
|
||||
|
||||
// MODIFIED: Line access - return line from PieceTable
|
||||
std::size_t Nrows() const { return content_.LineCount(); }
|
||||
std::string GetLine(std::size_t row) const { return content_.GetLine(row); }
|
||||
|
||||
// REMOVED: Rows() - no longer have vector of Lines
|
||||
// std::vector<Line> &Rows(); // REMOVE
|
||||
|
||||
private:
|
||||
// REMOVED: std::vector<Line> rows_;
|
||||
// NEW: Single piece table for all content
|
||||
PieceTable content_;
|
||||
|
||||
// Keep existing members
|
||||
std::size_t curx_, cury_, rx_;
|
||||
std::size_t nrows_; // cached from content_.LineCount()
|
||||
std::size_t rowoffs_, coloffs_;
|
||||
std::string filename_;
|
||||
bool is_file_backed_;
|
||||
bool dirty_;
|
||||
bool read_only_;
|
||||
bool mark_set_;
|
||||
std::size_t mark_curx_, mark_cury_;
|
||||
std::unique_ptr<UndoTree> undo_tree_;
|
||||
std::unique_ptr<UndoSystem> undo_sys_;
|
||||
std::uint64_t version_;
|
||||
bool syntax_enabled_;
|
||||
std::string filetype_;
|
||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||
kte::SwapRecorder *swap_rec_;
|
||||
};
|
||||
```
|
||||
|
||||
## Migration Phases
|
||||
|
||||
### Phase 1: Extend PieceTable (Foundation)
|
||||
|
||||
**Goal:** Add buffer-wide capabilities to PieceTable without breaking
|
||||
existing per-line usage.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Add line indexing infrastructure to PieceTable
|
||||
- Add `LineInfo` struct and `line_index_` member
|
||||
- Implement `RebuildLineIndex()` that scans pieces for '\n'
|
||||
characters
|
||||
- Implement `InvalidateLineIndex()` called by Insert/Delete
|
||||
|
||||
2. Implement core byte-based operations
|
||||
- `Insert(byte_offset, text, len)` - split piece at offset, insert
|
||||
new piece
|
||||
- `Delete(byte_offset, len)` - split pieces, remove/truncate as
|
||||
needed
|
||||
|
||||
3. Implement line-based query methods
|
||||
- `LineCount()` - return line_index_.size()
|
||||
- `GetLine(line_num)` - extract text between line boundaries
|
||||
- `GetLineRange(line_num)` - return (start, end) byte offsets
|
||||
|
||||
4. Implement position conversion
|
||||
- `ByteOffsetToLineCol(offset)` - binary search in line_index_
|
||||
- `LineColToByteOffset(row, col)` - lookup line start, add col
|
||||
|
||||
5. Implement utility methods
|
||||
- `GetRange(offset, len)` - extract substring
|
||||
- `Find(needle, start)` - search across pieces
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Write unit tests for new PieceTable methods
|
||||
- Test with multi-line content
|
||||
- Verify line index correctness after edits
|
||||
- Benchmark performance vs current line-based approach
|
||||
|
||||
**Estimated Effort:** 3-5 days
|
||||
|
||||
### Phase 2: Create Buffer Adapter Layer (Compatibility)
|
||||
|
||||
**Goal:** Create compatibility layer in Buffer to use PieceTable while
|
||||
maintaining existing API.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Add `PieceTable content_` member to Buffer (alongside existing
|
||||
`rows_`)
|
||||
2. Add compilation flag `KTE_USE_BUFFER_PIECE_TABLE` (like existing
|
||||
`KTE_USE_PIECE_TABLE`)
|
||||
3. Implement Buffer methods to delegate to content_:
|
||||
```cpp
|
||||
#ifdef KTE_USE_BUFFER_PIECE_TABLE
|
||||
void insert_text(int row, int col, std::string_view text) {
|
||||
std::size_t offset = content_.LineColToByteOffset(row, col);
|
||||
content_.Insert(offset, text.data(), text.size());
|
||||
}
|
||||
// ... similar for other methods
|
||||
#else
|
||||
// Existing line-based implementation
|
||||
#endif
|
||||
```
|
||||
4. Update file I/O to work with PieceTable
|
||||
- `OpenFromFile()` - load into content_ instead of rows_
|
||||
- `Save()` - serialize content_ instead of rows_
|
||||
5. Update `AsString()` to materialize from content_
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Run existing buffer correctness tests with new flag
|
||||
- Verify undo/redo still works
|
||||
- Test file I/O round-tripping
|
||||
- Test with existing command operations
|
||||
|
||||
**Estimated Effort:** 3-4 days
|
||||
|
||||
### Phase 3: Migrate Command Layer (High-level Operations)
|
||||
|
||||
**Goal:** Update commands that directly access Rows() to use new API.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Audit all usages of `buf.Rows()` in Command.cc
|
||||
2. Refactor helper functions:
|
||||
- `extract_region_text()` - use content_.GetRange()
|
||||
- `delete_region()` - convert to byte offsets, use content_.Delete()
|
||||
- `insert_text_at_cursor()` - convert position, use content_
|
||||
.Insert()
|
||||
3. Update commands that iterate over lines:
|
||||
- Use `buf.GetLine(i)` instead of `buf.Rows()[i]`
|
||||
- Update line count queries to use `buf.Nrows()`
|
||||
4. Update search/replace operations:
|
||||
- Modify `search_compute_matches()` to work with GetLine()
|
||||
- Update regex matching to work line-by-line or use content directly
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Test all editing commands (insert, delete, newline, backspace)
|
||||
- Test region operations (mark, copy, kill)
|
||||
- Test search and replace
|
||||
- Test word navigation and deletion
|
||||
- Run through common editing workflows
|
||||
|
||||
**Estimated Effort:** 4-6 days
|
||||
|
||||
### Phase 4: Update Renderer and Frontend (Display)
|
||||
|
||||
**Goal:** Ensure all renderers work with new Buffer structure.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Audit renderer implementations:
|
||||
- `TerminalRenderer.cc`
|
||||
- `ImGuiRenderer.cc`
|
||||
- `QtRenderer.cc`
|
||||
- `TestRenderer.cc`
|
||||
2. Update line access patterns:
|
||||
- Replace `buf.Rows()[y]` with `buf.GetLine(y)`
|
||||
- Handle string return instead of Line object
|
||||
3. Update syntax highlighting integration:
|
||||
- Ensure HighlighterEngine works with GetLine()
|
||||
- Update any line-based caching
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Test rendering in terminal
|
||||
- Test ImGui frontend (if enabled)
|
||||
- Test Qt frontend (if enabled)
|
||||
- Verify syntax highlighting displays correctly
|
||||
- Test scrolling and viewport updates
|
||||
|
||||
**Estimated Effort:** 2-3 days
|
||||
|
||||
### Phase 5: Remove Old Infrastructure (Cleanup) ✅ COMPLETED
|
||||
|
||||
**Goal:** Remove GapBuffer, AppendBuffer, and Line class completely.
|
||||
|
||||
**Status:** Completed on 2025-12-05
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. ✅ Remove conditional compilation:
|
||||
- Removed `#ifdef KTE_USE_BUFFER_PIECE_TABLE` (PieceTable is now the
|
||||
only way)
|
||||
- Removed `#ifdef KTE_USE_PIECE_TABLE`
|
||||
- Removed `AppendBuffer.h`
|
||||
2. ✅ Delete obsolete code:
|
||||
- Deleted `GapBuffer.h/cc`
|
||||
- Line class now uses PieceTable internally (kept for API
|
||||
compatibility)
|
||||
- `rows_` kept as mutable cache rebuilt from `content_` PieceTable
|
||||
3. ✅ Update CMakeLists.txt:
|
||||
- Removed GapBuffer from sources
|
||||
- Removed AppendBuffer.h from headers
|
||||
- Removed KTE_USE_PIECE_TABLE and KTE_USE_BUFFER_PIECE_TABLE options
|
||||
4. ✅ Clean up includes and dependencies
|
||||
5. ✅ Update documentation
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Full regression test suite
|
||||
- Verify clean compilation
|
||||
- Check for any lingering references
|
||||
|
||||
**Estimated Effort:** 1-2 days
|
||||
|
||||
### Phase 6: Performance Optimization (Polish)
|
||||
|
||||
**Goal:** Optimize the new implementation for real-world usage.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Profile common operations:
|
||||
- Measure line access patterns
|
||||
- Identify hot paths in editing
|
||||
- Benchmark against old implementation
|
||||
2. Optimize line index:
|
||||
- Consider incremental updates instead of full rebuild
|
||||
- Tune rebuild threshold
|
||||
- Cache frequently accessed lines
|
||||
3. Optimize piece table:
|
||||
- Tune piece coalescing heuristics
|
||||
- Consider piece count limits and consolidation
|
||||
4. Memory optimization:
|
||||
- Review materialization frequency
|
||||
- Consider lazy materialization strategies
|
||||
- Profile memory usage on large files
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Benchmark suite with various file sizes
|
||||
- Memory profiling
|
||||
- Real-world usage testing
|
||||
|
||||
**Estimated Effort:** 3-5 days
|
||||
|
||||
## Files Requiring Modification
|
||||
|
||||
### Core Files (Must Change)
|
||||
|
||||
- `PieceTable.h/cc` - Add new methods (Phase 1)
|
||||
- `Buffer.h/cc` - Replace rows_ with content_ (Phase 2)
|
||||
- `Command.cc` - Update line access (Phase 3)
|
||||
- `UndoSystem.cc` - May need updates for new Buffer API
|
||||
|
||||
### Renderer Files (Will Change)
|
||||
|
||||
- `TerminalRenderer.cc` - Update line access (Phase 4)
|
||||
- `ImGuiRenderer.cc` - Update line access (Phase 4)
|
||||
- `QtRenderer.cc` - Update line access (Phase 4)
|
||||
- `TestRenderer.cc` - Update line access (Phase 4)
|
||||
|
||||
### Files Removed (Phase 5 - Completed)
|
||||
|
||||
- `GapBuffer.h/cc` - ✅ Deleted
|
||||
- `AppendBuffer.h` - ✅ Deleted
|
||||
- `test_buffer_correctness.cc` - ✅ Deleted (obsolete GapBuffer
|
||||
comparison test)
|
||||
- `bench/BufferBench.cc` - ✅ Deleted (obsolete GapBuffer benchmarks)
|
||||
- `bench/PerformanceSuite.cc` - ✅ Deleted (obsolete GapBuffer
|
||||
benchmarks)
|
||||
- `Buffer::Line` class - ✅ Updated to use PieceTable internally (kept
|
||||
for API compatibility)
|
||||
|
||||
### Build Files
|
||||
|
||||
- `CMakeLists.txt` - Update sources (Phase 5)
|
||||
|
||||
### Documentation
|
||||
|
||||
- `README.md` - Update architecture notes
|
||||
- `docs/` - Update any architectural documentation
|
||||
- `REWRITE.md` - Note C++ now matches Rust design
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- **PieceTable Tests:** New file `test_piece_table.cc`
|
||||
- Test Insert/Delete at various positions
|
||||
- Test line indexing correctness
|
||||
- Test position conversion
|
||||
- Test with edge cases (empty, single line, large files)
|
||||
|
||||
- **Buffer Tests:** Extend `test_buffer_correctness.cc`
|
||||
- Test new Buffer API with PieceTable backend
|
||||
- Test file I/O round-tripping
|
||||
- Test multi-line operations
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- **Undo Tests:** `test_undo.cc` should still pass
|
||||
- Verify undo/redo across all operation types
|
||||
- Test undo tree navigation
|
||||
|
||||
- **Search Tests:** `test_search_correctness.cc` should still pass
|
||||
- Verify search across multiple lines
|
||||
- Test regex search
|
||||
|
||||
### Manual Testing
|
||||
|
||||
- Load and edit large files (>10MB)
|
||||
- Perform complex editing sequences
|
||||
- Test all keybindings and commands
|
||||
- Verify syntax highlighting
|
||||
- Test crash recovery (swap files)
|
||||
|
||||
### Regression Testing
|
||||
|
||||
- All existing tests must pass with new implementation
|
||||
- No observable behavior changes for users
|
||||
- Performance should be comparable or better
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### High Risk
|
||||
|
||||
- **Undo System Integration:** Undo records operations with
|
||||
row/col/text. Need to ensure compatibility or refactor.
|
||||
- *Mitigation:* Carefully preserve undo semantics, extensive testing
|
||||
|
||||
- **Performance Regression:** Line index rebuilding could be expensive
|
||||
on large files.
|
||||
- *Mitigation:* Profile early, optimize incrementally, consider
|
||||
caching strategies
|
||||
|
||||
### Medium Risk
|
||||
|
||||
- **Syntax Highlighting:** Highlighters may depend on line-based access
|
||||
patterns.
|
||||
- *Mitigation:* Review highlighter integration, test thoroughly
|
||||
|
||||
- **Renderer Updates:** Multiple renderers need updating, risk of
|
||||
inconsistency.
|
||||
- *Mitigation:* Update all renderers in same phase, test each
|
||||
|
||||
### Low Risk
|
||||
|
||||
- **Search/Replace:** Should work naturally with new GetLine() API.
|
||||
- *Mitigation:* Test thoroughly with existing test suite
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- ✓ All existing tests pass
|
||||
- ✓ All commands work identically to before
|
||||
- ✓ File I/O works correctly
|
||||
- ✓ Undo/redo functionality preserved
|
||||
- ✓ Syntax highlighting works
|
||||
- ✓ All frontends (terminal, ImGui, Qt) work
|
||||
|
||||
### Code Quality
|
||||
|
||||
- ✓ GapBuffer completely removed
|
||||
- ✓ No conditional compilation for buffer type
|
||||
- ✓ Clean, maintainable code
|
||||
- ✓ Good test coverage for new PieceTable methods
|
||||
|
||||
### Performance
|
||||
|
||||
- ✓ Editing operations at least as fast as current
|
||||
- ✓ Line access within 2x of current performance
|
||||
- ✓ Memory usage reasonable (no excessive materialization)
|
||||
- ✓ Large file handling acceptable (tested up to 100MB)
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
| Phase | Duration | Dependencies |
|
||||
|----------------------------|----------------|--------------|
|
||||
| Phase 1: Extend PieceTable | 3-5 days | None |
|
||||
| Phase 2: Buffer Adapter | 3-4 days | Phase 1 |
|
||||
| Phase 3: Command Layer | 4-6 days | Phase 2 |
|
||||
| Phase 4: Renderer Updates | 2-3 days | Phase 3 |
|
||||
| Phase 5: Cleanup | 1-2 days | Phase 4 |
|
||||
| Phase 6: Optimization | 3-5 days | Phase 5 |
|
||||
| **Total** | **16-25 days** | |
|
||||
|
||||
**Note:** Timeline assumes one developer working full-time. Actual
|
||||
duration may vary based on:
|
||||
|
||||
- Unforeseen integration issues
|
||||
- Performance optimization needs
|
||||
- Testing thoroughness
|
||||
- Code review iterations
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Alternative 1: Keep Line-based but unify GapBuffer/PieceTable
|
||||
|
||||
- Keep vector of Lines, but make each Line always use PieceTable
|
||||
- Remove GapBuffer, remove AppendBuffer selector
|
||||
- **Pros:** Smaller change, less risk
|
||||
- **Cons:** Doesn't achieve architectural goal, still have per-line
|
||||
overhead
|
||||
|
||||
### Alternative 2: Hybrid approach
|
||||
|
||||
- Use PieceTable for buffer, but maintain materialized Line objects as
|
||||
cache
|
||||
- **Pros:** Easier migration, maintains some compatibility
|
||||
- **Cons:** Complex dual representation, cache invalidation issues
|
||||
|
||||
### Alternative 3: Complete rewrite
|
||||
|
||||
- Follow REWRITE.md exactly, implement in Rust
|
||||
- **Pros:** Modern language, better architecture
|
||||
- **Cons:** Much larger effort, different project
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Proceed with planned migration** (single PieceTable per Buffer)
|
||||
because:
|
||||
|
||||
1. Aligns with long-term architecture vision (REWRITE.md)
|
||||
2. Removes unnecessary per-line buffer overhead
|
||||
3. Simplifies codebase (one text representation)
|
||||
4. Enables future optimizations (better undo, swap files, etc.)
|
||||
5. Reasonable effort (16-25 days) for significant improvement
|
||||
|
||||
**Suggested Approach:**
|
||||
|
||||
- Start with Phase 1 (extend PieceTable) in isolated branch
|
||||
- Thoroughly test new PieceTable functionality
|
||||
- Proceed incrementally through phases
|
||||
- Maintain working editor at end of each phase
|
||||
- Merge to main after Phase 4 (before cleanup) to get testing
|
||||
- Complete Phase 5-6 based on feedback
|
||||
|
||||
## References
|
||||
|
||||
- `REWRITE.md` - Rust architecture specification (lines 54-157)
|
||||
- Current buffer implementation: `Buffer.h/cc`
|
||||
- Current piece table: `PieceTable.h/cc`
|
||||
- Undo system: `UndoSystem.h/cc`, `UndoNode.h`
|
||||
- Commands: `Command.cc`
|
||||
@@ -0,0 +1,147 @@
|
||||
Swap files for kte — design plan
|
||||
================================
|
||||
|
||||
Goals
|
||||
-----
|
||||
|
||||
- Preserve user work across crashes, power failures, and OS kills.
|
||||
- Keep the editor responsive; avoid blocking the UI on disk I/O.
|
||||
- Bound recovery time and swap size.
|
||||
- Favor simple, robust primitives that work well on POSIX and macOS;
|
||||
keep Windows feasibility in mind.
|
||||
|
||||
Model overview
|
||||
--------------
|
||||
Per open buffer, maintain a swap journal in a per-user state directory:
|
||||
|
||||
- Path: `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp` (or
|
||||
`~/.local/state/kte/swap/...`)
|
||||
where `<encoded-path>` is the file path with separators replaced (e.g.
|
||||
`/home/kyle/tmp/test.txt` → `home!kyle!tmp!test.txt.swp`).
|
||||
Unnamed/unsaved
|
||||
buffers use a unique `unnamed-<pid>-<counter>.swp` name.
|
||||
- Format: append‑only journal of editing operations with periodic
|
||||
checkpoints.
|
||||
- Crash safety: only append, fsync as per policy; checkpoint via
|
||||
write‑to‑temp + fsync + atomic rename.
|
||||
|
||||
File format (v1)
|
||||
----------------
|
||||
Header (fixed 64 bytes):
|
||||
|
||||
- Magic: `KTE_SWP\0` (8 bytes)
|
||||
- Version: 1 (u32)
|
||||
- Flags: bitset (u32) — e.g., compression, checksums, endian.
|
||||
- Created time (u64)
|
||||
- Host info hash (u64) — optional, for telemetry/debug.
|
||||
- File identity: hash of canonical path (u64) and original file
|
||||
size+mtime (u64+u64) at start.
|
||||
- Reserved/padding.
|
||||
|
||||
Records (stream after header):
|
||||
|
||||
- Each record: [type u8][len u24][payload][crc32 u32]
|
||||
- Types:
|
||||
- `CHKPT` — full snapshot checkpoint of entire buffer content and
|
||||
minimal metadata (cursor pos, filetype). Payload optionally
|
||||
compressed. Written occasionally to cap replay time.
|
||||
- `INS` — insert at (row, col) text bytes (text may contain
|
||||
newlines). Encoded with varints.
|
||||
- `DEL` — delete length at (row, col). If spanning lines, semantics
|
||||
defined as in Buffer::delete_text.
|
||||
- `SPLIT`, `JOIN` — explicit structural ops (optional; can be
|
||||
expressed via INS/DEL).
|
||||
- `META` — update metadata (e.g., filetype, encoding hints).
|
||||
|
||||
Durability policy
|
||||
-----------------
|
||||
Configurable knobs (sane defaults in parentheses):
|
||||
|
||||
- Time‑based flush: group edits and flush every 150–300 ms (200 ms).
|
||||
- Operation count flush: after N ops (200).
|
||||
- Idle flush: on 500 ms idle lull, flush immediately.
|
||||
- Checkpoint cadence: after M KB of journal (512–2048 KB) or T seconds (
|
||||
30–120 s), whichever first.
|
||||
- fsync policy:
|
||||
- `always`: fsync every flush (safest, slowest).
|
||||
- `grouped` (default): fsync at most every 1–2 s or on
|
||||
idle/blur/quit.
|
||||
- `never`: rely on OS flush (fastest, riskier).
|
||||
- On POSIX, prefer `fdatasync` when available; fall back to `fsync`.
|
||||
|
||||
Performance & threading
|
||||
-----------------------
|
||||
|
||||
- Background writer thread per editor instance (shared) with a bounded
|
||||
MPSC queue of per‑buffer records.
|
||||
- Each Buffer has a small in‑memory journal buffer; UI thread enqueues
|
||||
ops (non‑blocking) and may coalesce adjacent inserts/deletes.
|
||||
- Writer batch‑writes records to the swap file, computes CRCs, and
|
||||
decides checkpoint boundaries.
|
||||
- Backpressure: if the queue grows beyond a high watermark, signal the
|
||||
UI to start coalescing more aggressively and slow enqueue (never block
|
||||
hard editing path; at worst drop optional `META`).
|
||||
|
||||
Recovery flow
|
||||
-------------
|
||||
|
||||
On opening a file:
|
||||
|
||||
1. Detect swap journal `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp`.
|
||||
2. Validate header, iterate records verifying CRCs.
|
||||
3. Compare recorded original file identity against actual file; if
|
||||
mismatch, warn user but allow recovery (content wins).
|
||||
4. Reconstruct buffer: start from the last good `CHKPT` (if any), then
|
||||
replay subsequent ops. If trailing partial record encountered (EOF
|
||||
mid‑record), truncate at last good offset.
|
||||
5. Present a choice: Recover (load recovered buffer; keep the swap file
|
||||
until user saves) or Discard (delete swap file and open clean file).
|
||||
|
||||
Stability & corruption mitigation
|
||||
---------------------------------
|
||||
|
||||
- Append‑only with per‑record CRC32 guards against torn writes.
|
||||
- Atomic checkpoint rotation: write `<encoded-path>.swp.tmp`, fsync,
|
||||
then rename over old `.swp`.
|
||||
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
|
||||
64–128 MB). Compaction creates a fresh file with a single checkpoint.
|
||||
- Low‑disk‑space behavior: on write failures, surface a non‑modal
|
||||
warning and temporarily fall back to in‑memory only; retry
|
||||
opportunistically.
|
||||
|
||||
Security considerations
|
||||
-----------------------
|
||||
|
||||
- Swap files mirror buffer content, which may be sensitive. Options:
|
||||
- Configurable location (same dir vs. `$XDG_STATE_HOME/kte/swap`).
|
||||
- Optional per‑file encryption (future work) using OS keychain.
|
||||
- Ensure permissions are 0600.
|
||||
|
||||
Interoperability & UX
|
||||
---------------------
|
||||
|
||||
- Use a distinctive directory (`$XDG_STATE_HOME/kte/swap`) to avoid
|
||||
conflicts with other editors’ `.swp` conventions.
|
||||
- Status bar indicator when swap is active; commands to purge/compact.
|
||||
- On save: do not delete swap immediately; keep until the buffer is
|
||||
clean and idle for a short grace period (allows undo of accidental
|
||||
external changes).
|
||||
|
||||
Implementation plan (staged)
|
||||
----------------------------
|
||||
|
||||
1. Minimal journal writer (append‑only INS/DEL) with grouped fsync;
|
||||
single per‑editor writer thread.
|
||||
2. Reader/recovery path with CRC validation and replay.
|
||||
3. Checkpoints + atomic rotation; compaction path.
|
||||
4. Config surface and UI prompts; telemetry counters.
|
||||
5. Optional compression and advanced coalescing.
|
||||
|
||||
Defaults balancing performance and stability
|
||||
-------------------------------------------
|
||||
|
||||
- Grouped flush with fsync every ~1 s or on idle/quit.
|
||||
- Checkpoint every 1 MB or 60 s.
|
||||
- Bounded queue and batch writes to minimize syscalls.
|
||||
- Immediate flush on critical events (buffer close, app quit, power
|
||||
source change on laptops if detectable).
|
||||
@@ -0,0 +1,163 @@
|
||||
### Unit testing plan (headless, no interactive frontend)
|
||||
|
||||
#### Principles
|
||||
- Headless-only: exercise core components directly (`PieceTable`, `Buffer`, `UndoSystem`, `OptimizedSearch`, and minimal `Editor` flows) without starting `kte` or `kge`.
|
||||
- Deterministic and fast: avoid timers, GUI, environment-specific behavior; prefer in-memory operations and temporary files.
|
||||
- Regression-focused: encode prior failures (save/newline mismatch, legacy `rows_` writes) as explicit tests to prevent recurrences.
|
||||
|
||||
#### Harness and execution
|
||||
- Single binary: use target `kte_tests` (already present) to compile and run all tests under `tests/` with the minimal in-tree framework (`tests/Test.h`, `tests/TestRunner.cc`).
|
||||
- No GUI/ncurses deps: link only engine sources (PieceTable/Buffer/Undo/Search/Undo* and syntax minimal set), not frontends.
|
||||
- How to build/run:
|
||||
- Debug profile:
|
||||
```
|
||||
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-debug -DBUILD_TESTS=ON && \
|
||||
cmake --build /Users/kyle/src/kte/cmake-build-debug --target kte_tests && \
|
||||
/Users/kyle/src/kte/cmake-build-debug/kte_tests
|
||||
```
|
||||
- Release profile:
|
||||
```
|
||||
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-release -DBUILD_TESTS=ON && \
|
||||
cmake --build /Users/kyle/src/kte/cmake-build-release --target kte_tests && \
|
||||
/Users/kyle/src/kte/cmake-build-release/kte_tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Test catalog (summary table)
|
||||
|
||||
The table below catalogs all unit tests defined in this plan. It is headless-only and maps directly to the suites A–H described later. “Implemented” reflects current coverage in `kte_tests`.
|
||||
|
||||
| Suite | ID | Name | Description (1‑line) | Headless | Implemented |
|
||||
|:-----:|:---:|:------------------------------------------|:-------------------------------------------------------------------------------------|:--------:|:-----------:|
|
||||
| A | 1 | SaveAs then Save (append) | New buffer → write two lines → `SaveAs` → append → `Save`; verify exact bytes. | Yes | ✓ |
|
||||
| A | 2 | Open existing then Save | Open seeded file, append, `Save`; verify overwrite bytes. | Yes | ✓ |
|
||||
| A | 3 | Open non-existent then SaveAs | Start from non-existent path, insert `hello, world\n`, `SaveAs`; verify bytes. | Yes | ✓ |
|
||||
| A | 4 | Trailing newline preservation | Verify saving preserves presence/absence of final `\n`. | Yes | Planned |
|
||||
| A | 5 | Empty buffer saves | Empty → `SaveAs` → 0 bytes; then insert `\n` → `Save` → 1 byte. | Yes | Planned |
|
||||
| A | 6 | Large file streaming | 1–4 MiB with periodic newlines; size and content integrity. | Yes | Planned |
|
||||
| A | 7 | Tilde expansion | `SaveAs` with `~/...`; re-open to confirm path/content. | Yes | Planned |
|
||||
| A | 8 | Error propagation | Save to unwritable path → expect failure and error message. | Yes | Planned |
|
||||
| B | 1 | Insert/Delete LineCount | Basic inserts/deletes and line counting sanity. | Yes | ✓ |
|
||||
| B | 2 | Line/Col conversions | `LineColToByteOffset` and reverse around boundaries. | Yes | ✓ |
|
||||
| B | 3 | Delete spanning newlines | Delete ranges that cross line breaks; verify bytes/lines. | Yes | Planned |
|
||||
| B | 4 | Split/Join equivalence | `split_line` followed by `join_lines` yields original bytes. | Yes | Planned |
|
||||
| B | 5 | Stream vs Data equivalence | `WriteToStream` matches `GetRange`/`Data()` after edits. | Yes | Planned |
|
||||
| B | 6 | UTF‑8 bytes stability | Multibyte sequences behave correctly (byte-based ops). | Yes | Planned |
|
||||
| C | 1 | insert_text/delete_text | Edits at start/middle/end; `Rows()` mirrors PieceTable. | Yes | Planned |
|
||||
| C | 2 | split_line/join_lines | Effects and snapshots across multiple positions. | Yes | Planned |
|
||||
| C | 3 | insert_row/delete_row | Replace paragraph by row ops; verify bytes/linecount. | Yes | Planned |
|
||||
| C | 4 | Cache invalidation | After each mutation, `Rows()` matches `LineCount()`. | Yes | Planned |
|
||||
| D | 1 | Grouped insert undo | Contiguous typing undone/redone as a group. | Yes | Planned |
|
||||
| D | 2 | Delete/Newline undo/redo | Backspace/Delete and Newline transitions across undo/redo. | Yes | Planned |
|
||||
| D | 3 | Mark saved & dirty | Dirty/save markers interact correctly with undo/redo. | Yes | Planned |
|
||||
| E | 1 | Search parity basic | `OptimizedSearch::find_all` vs `std::string` reference. | Yes | ✓ |
|
||||
| E | 2 | Large text search | ~1 MiB random text/patterns parity. | Yes | Planned |
|
||||
| F | 1 | Editor open & reload | Open via `Editor`, modify, reload, verify on-disk bytes. | Yes | Planned |
|
||||
| F | 2 | Read-only toggle | Toggle and verify enforcement/behavior of saves. | Yes | Planned |
|
||||
| F | 3 | Prompt lifecycle | Start/Accept/Cancel prompt doesn’t corrupt state. | Yes | Planned |
|
||||
| G | 1 | Saved only newline regression | Insert text + newline; `Save` includes both bytes. | Yes | Planned |
|
||||
| G | 2 | Backspace crash regression | PieceTable-backed delete/join path remains stable. | Yes | Planned |
|
||||
| G | 3 | Overwrite-confirm path | Saving over existing path succeeds and is correct. | Yes | Planned |
|
||||
| H | 1 | Many small edits | 10k small edits; final bytes correct within time bounds. | Yes | Planned |
|
||||
| H | 2 | Consolidation equivalence | After many edits, stream vs data produce identical bytes. | Yes | Planned |
|
||||
|
||||
Legend: Implemented = ✓, Planned = to be added per Coverage roadmap.
|
||||
|
||||
### Test suites and cases
|
||||
|
||||
#### A) Filesystem I/O via Buffer
|
||||
1) SaveAs then Save (append)
|
||||
- New buffer → `insert_text` two lines (explicit `\n`) → `SaveAs(tmp)` → insert a third line → `Save()`.
|
||||
- Assert file bytes equal exact expected string.
|
||||
2) Open existing then Save
|
||||
- Seed a file on disk; `OpenFromFile(path)` → append line → `Save()`.
|
||||
- Assert file bytes updated exactly.
|
||||
3) Open non-existent then SaveAs
|
||||
- `OpenFromFile(nonexistent)` → assert `IsFileBacked()==false` → insert `"hello, world\n"` → `SaveAs(path)`.
|
||||
- Read back exact bytes.
|
||||
4) Trailing newline preservation
|
||||
- Case (a) last line without `\n`; (b) last line with `\n` → save and verify bytes unchanged.
|
||||
5) Empty buffer saves
|
||||
- `SaveAs(tmp)` on empty buffer → 0-byte file. Then insert `"\n"` and `Save()` → 1-byte file.
|
||||
6) Large file streaming
|
||||
- Insert ~1–4 MiB of data with periodic newlines. `SaveAs` then `Save`; verify size matches `content_.Size()` and bytes integrity.
|
||||
7) Path normalization and tilde expansion
|
||||
- `SaveAs("~/.../file.txt")` → verify path expands to `$HOME` and file content round-trips with `OpenFromFile`.
|
||||
8) Error propagation (guarded)
|
||||
- Attempt save into a non-writable path; expect `Save/SaveAs` returns false with non-empty error. Mark as skipped in environments lacking such path.
|
||||
|
||||
#### B) PieceTable semantics
|
||||
1) Line counting and deletion across lines
|
||||
- Insert `"abc\n123\nxyz"` → 3 lines; delete middle line range → 2 lines; validate `GetLine` contents.
|
||||
2) Position conversions
|
||||
- Validate `LineColToByteOffset` and `ByteOffsetToLineCol` at start/end of lines and EOF, especially around `\n`.
|
||||
3) Delete spanning newlines
|
||||
- Remove a range that crosses line boundaries; verify resulting bytes, `LineCount` and line contents.
|
||||
4) Split/join equivalence
|
||||
- Split at various columns; then join adjacent lines; verify bytes equal original.
|
||||
5) WriteToStream vs materialized `Data()`
|
||||
- After multiple inserts/deletes (without forcing `Data()`), stream to `std::ostringstream`; compare with `GetRange(0, Size())`, then call `Data()` and re-compare.
|
||||
6) UTF-8 bytes stability
|
||||
- Insert multibyte sequences (e.g., `"héllo"`, `"中文"`, emoji) as raw bytes; ensure line counting and conversions behave (byte-based API; no crashes/corruption).
|
||||
|
||||
#### C) Buffer editing helpers and rows cache correctness
|
||||
1) `insert_text`/`delete_text`
|
||||
- Apply at start/middle/end of lines; immediately call `Rows()` and validate contents/lengths mirror PieceTable.
|
||||
2) `split_line` and `join_lines`
|
||||
- Verify content effects and `Rows()` snapshots for multiple positions and consecutive operations.
|
||||
3) `insert_row`/`delete_row`
|
||||
- Replace a paragraph by deleting N rows then inserting N′ rows; verify bytes and `LineCount`.
|
||||
4) Cache invalidation
|
||||
- After each mutation, fetch `Rows()`; assert `Nrows() == content.LineCount()` and no stale data remains.
|
||||
|
||||
#### D) UndoSystem semantics
|
||||
1) Grouped contiguous insert undo
|
||||
- Emulate typing at a single location via repeated `insert_text`; one `undo()` should remove the whole run; `redo()` restores it.
|
||||
2) Delete/newline undo/redo
|
||||
- Simulate backspace/delete (`delete_text` and `join_lines`) and newline (`split_line`); verify content transitions across `undo()`/`redo()`.
|
||||
3) Mark saved and dirty flag
|
||||
- After successful save, call `UndoSystem::mark_saved()` (via existing pathways) and ensure dirty state pairing behaves as intended (at least: `SetDirty(false)` plus save does not break undo/redo).
|
||||
|
||||
#### E) Search algorithms
|
||||
1) Parity with `std::string::find`
|
||||
- Use `OptimizedSearch::find_all` across edge cases (empty needle/text, overlaps like `"aaaaa"` vs `"aa"`, Unicode byte sequences). Compare to reference implementation.
|
||||
2) Large text
|
||||
- Random ASCII text ~1 MiB; random patterns; results match reference.
|
||||
|
||||
#### F) Editor non-interactive flows (no frontend)
|
||||
1) Open and reload
|
||||
- Through `Editor`, open file; modify the underlying `Buffer` directly; invoke reload (`Buffer::OpenFromFile` or `cmd_reload_buffer` if you bring `Command.cc` into the test target). Verify bytes match the on-disk file after reload.
|
||||
2) Read-only toggle
|
||||
- Toggle `Buffer::ToggleReadOnly()`; confirm flag value changes and that subsequent saves still execute when not read-only (or, if enforcement exists, that mutations are appropriately restricted).
|
||||
3) Prompt lifecycle (headless)
|
||||
- Exercise `StartPrompt` → `AcceptPrompt` → `CancelPrompt`; ensure state resets and does not corrupt buffer/editor state.
|
||||
|
||||
#### G) Regression tests for reported bugs
|
||||
1) “Saved only newline”
|
||||
- Build buffer content via `insert_text` followed by `split_line` for newline; `Save` then validate bytes include both the text and newline.
|
||||
2) Backspace crash path
|
||||
- Mimic backspace behavior using PieceTable-backed helpers (`delete_text`/`join_lines`); ensure no dependency on legacy `rows_` mutation and no memory issues.
|
||||
3) Overwrite-confirm path behavior
|
||||
- Start with non-file-backed buffer named to collide with an existing file; perform `SaveAs(existing_path)` and assert success and correctness on disk (unit test bypasses interactive confirm, validating underlying write path).
|
||||
|
||||
#### H) Performance/stress sanity
|
||||
1) Many small edits
|
||||
- 10k single-char inserts and interleaved deletes; assert final bytes; keep within conservative runtime bounds.
|
||||
2) Consolidation heuristics
|
||||
- After many edits, call both `WriteToStream` and `Data()` and verify identical bytes.
|
||||
|
||||
---
|
||||
|
||||
### Coverage roadmap
|
||||
- Phase 1 (already implemented and passing):
|
||||
- Buffer I/O basics (A.1–A.3), PieceTable basics (B.1–B.2), Search parity (E.1).
|
||||
- Phase 2 (add next):
|
||||
- Buffer I/O edge cases (A.4–A.7), deeper PieceTable ops (B.3–B.6), Buffer helpers and cache (C.1–C.4), Undo semantics (D.1–D.2), Regression set (G.1–G.3).
|
||||
- Phase 3:
|
||||
- Editor flows (F.1–F.3), performance/stress (H.1–H.2), and optional integration of `Command.cc` into the test target to exercise non-interactive command execution paths directly.
|
||||
|
||||
### Notes
|
||||
- Use per-test temp files under the repo root or a unique temp directory; ensure cleanup after assertions.
|
||||
- For HOME-dependent tests (tilde expansion), set `HOME` in the test process if not present or skip with a clear message.
|
||||
- On macOS Debug, a benign allocator warning may appear; rely on process exit code for pass/fail.
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# Swap journaling (crash recovery)
|
||||
|
||||
kte has a small “swap” system: an append-only per-buffer journal that
|
||||
records edits so they can be replayed after a crash.
|
||||
|
||||
This document describes the **currently implemented** swap system (stage
|
||||
2), as implemented in `Swap.h` / `Swap.cc`.
|
||||
|
||||
## What it is (and what it is not)
|
||||
|
||||
- The swap file is a **journal of editing operations** (currently
|
||||
inserts, deletes, and periodic full-buffer checkpoints).
|
||||
- It is written by a **single background writer thread** owned by
|
||||
`kte::SwapManager`.
|
||||
- It is intended for **best-effort crash recovery**.
|
||||
|
||||
kte automatically deletes/resets swap journals after a **clean save**
|
||||
and when
|
||||
closing a clean buffer, so old swap files do not accumulate under normal
|
||||
workflows. A best-effort prune also runs at startup to remove very old
|
||||
swap
|
||||
files.
|
||||
|
||||
## Automatic recovery prompt
|
||||
|
||||
When kte opens a file-backed buffer, it checks whether a corresponding
|
||||
swap journal exists.
|
||||
|
||||
- If a swap file exists and replay succeeds *and* produces different
|
||||
content than what is currently on disk, kte prompts:
|
||||
|
||||
```text
|
||||
Recover swap edits for <path>? (y/N, C-g cancel)
|
||||
```
|
||||
|
||||
- `y`: open the file and apply swap replay (buffer becomes dirty)
|
||||
- `Enter` (default) / any non-`y`: delete the swap file (
|
||||
best-effort)
|
||||
and open the file normally
|
||||
- `C-g`: cancel opening the file
|
||||
|
||||
- If a swap file exists but is unreadable/corrupt, kte prompts:
|
||||
|
||||
```text
|
||||
Swap file unreadable for <path>. Delete it? (y/N, C-g cancel)
|
||||
```
|
||||
|
||||
- `y`: delete the swap file (best-effort) and open the file normally
|
||||
- `Enter` (default): keep the swap file and open the file normally
|
||||
- `C-g`: cancel opening the file
|
||||
|
||||
## Where swap files live
|
||||
|
||||
Swap files are stored under an XDG-style per-user *state* directory:
|
||||
|
||||
- If `XDG_STATE_HOME` is set and non-empty:
|
||||
- `$XDG_STATE_HOME/kte/swap/…`
|
||||
- Otherwise, if `HOME` is set:
|
||||
- `~/.local/state/kte/swap/…`
|
||||
- Last resort fallback:
|
||||
- `<system-temp>/kte/state/kte/swap/…` (via
|
||||
`std::filesystem::temp_directory_path()`)
|
||||
|
||||
Swap files are always created with permissions `0600`.
|
||||
|
||||
### Swap file naming
|
||||
|
||||
For file-backed buffers, the swap filename is derived from the buffer’s
|
||||
path:
|
||||
|
||||
1. Take a canonical-ish path key (`std::filesystem::weakly_canonical`,
|
||||
else `absolute`, else the raw `Buffer::Filename()`).
|
||||
2. Encode it so it’s human-identifiable:
|
||||
- Strip one leading path separator (`/` or `\\`)
|
||||
- Replace path separators (`/` and `\\`) with `!`
|
||||
- Append `.swp`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
/home/kyle/tmp/test.txt -> home!kyle!tmp!test.txt.swp
|
||||
```
|
||||
|
||||
If the resulting name would be long (over ~200 characters), kte falls
|
||||
back to a shorter stable name:
|
||||
|
||||
```text
|
||||
<basename>.<fnv1a64(path-key-as-hex)>.swp
|
||||
```
|
||||
|
||||
For unnamed/unsaved buffers, kte uses:
|
||||
|
||||
```text
|
||||
unnamed-<pid>-<counter>.swp
|
||||
```
|
||||
|
||||
## Lifecycle (when swap is written)
|
||||
|
||||
`kte::SwapManager` is owned by `Editor` (see `Editor.cc`). Buffers are
|
||||
attached for journaling when they are added/opened.
|
||||
|
||||
- `SwapManager::Attach(Buffer*)` starts tracking a buffer and
|
||||
establishes its swap path.
|
||||
- `Buffer` emits swap events from its low-level edit APIs:
|
||||
- `Buffer::insert_text()` calls `SwapRecorder::OnInsert()`
|
||||
- `Buffer::delete_text()` calls `SwapRecorder::OnDelete()`
|
||||
- `Buffer::split_line()` / `join_lines()` are represented as
|
||||
insert/delete of `\n` (they do **not** emit `SPLIT`/`JOIN` records
|
||||
in stage 1).
|
||||
- `SwapManager::Detach(Buffer*)` flushes queued records, `fsync()`s, and
|
||||
closes the journal.
|
||||
- On `Save As` / filename changes,
|
||||
`SwapManager::NotifyFilenameChanged(Buffer&)` closes the existing
|
||||
journal and switches to a new path.
|
||||
- Note: the old swap file is currently left on disk (no
|
||||
cleanup/rotation yet).
|
||||
|
||||
## Durability and performance
|
||||
|
||||
Swap writing is best-effort and asynchronous:
|
||||
|
||||
- Records are queued from the UI/editing thread(s).
|
||||
- A background writer thread wakes at least every
|
||||
`SwapConfig::flush_interval_ms` (default `200ms`) to write any queued
|
||||
records.
|
||||
- `fsync()` is throttled to at most once per
|
||||
`SwapConfig::fsync_interval_ms` (default `1000ms`) per open swap file.
|
||||
- `SwapManager::Flush()` blocks until the queue is fully written; it is
|
||||
primarily used by tests and shutdown paths.
|
||||
|
||||
If a crash happens while writing, the swap file may end with a partial
|
||||
record. Replay detects truncation/CRC mismatch and fails safely.
|
||||
|
||||
## On-disk format (v1)
|
||||
|
||||
The file is:
|
||||
|
||||
1. A fixed-size 64-byte header
|
||||
2. Followed by a stream of records
|
||||
|
||||
All multi-byte integers in the swap file are **little-endian**.
|
||||
|
||||
### Header (64 bytes)
|
||||
|
||||
Layout (stage 1):
|
||||
|
||||
- `magic` (8 bytes): `KTE_SWP\0`
|
||||
- `version` (`u32`): currently `1`
|
||||
- `flags` (`u32`): currently `0`
|
||||
- `created_time` (`u64`): Unix seconds
|
||||
- remaining bytes are reserved/padding (currently zeroed)
|
||||
|
||||
### Record framing
|
||||
|
||||
Each record is:
|
||||
|
||||
```text
|
||||
[type: u8][len: u24][payload: len bytes][crc32: u32]
|
||||
```
|
||||
|
||||
- `len` is a 24-bit little-endian length of the payload (`0..0xFFFFFF`).
|
||||
- `crc32` is computed over the 4-byte record header (`type + len`)
|
||||
followed by the payload bytes.
|
||||
|
||||
### Record types
|
||||
|
||||
Type codes are defined in `SwapRecType` (`Swap.h`). Stage 1 primarily
|
||||
emits:
|
||||
|
||||
- `INS` (`1`): insert bytes at `(row, col)`
|
||||
- `DEL` (`2`): delete `len` bytes at `(row, col)`
|
||||
|
||||
Other type codes exist for forward compatibility (`SPLIT`, `JOIN`,
|
||||
`META`, `CHKPT`), but are not produced by the current `SwapRecorder`
|
||||
interface.
|
||||
|
||||
### Payload encoding (v1)
|
||||
|
||||
Every payload starts with:
|
||||
|
||||
```text
|
||||
[encver: u8]
|
||||
```
|
||||
|
||||
Currently `encver` must be `1`.
|
||||
|
||||
#### `INS` payload (encver = 1)
|
||||
|
||||
```text
|
||||
[encver: u8 = 1]
|
||||
[row: u32]
|
||||
[col: u32]
|
||||
[nbytes:u32]
|
||||
[bytes: nbytes]
|
||||
```
|
||||
|
||||
#### `DEL` payload (encver = 1)
|
||||
|
||||
```text
|
||||
[encver: u8 = 1]
|
||||
[row: u32]
|
||||
[col: u32]
|
||||
[len: u32]
|
||||
```
|
||||
|
||||
`row`/`col` are 0-based and are interpreted the same way as
|
||||
`Buffer::insert_text()` / `Buffer::delete_text()`.
|
||||
|
||||
## Replay / recovery
|
||||
|
||||
Swap replay is implemented as a low-level API:
|
||||
|
||||
-
|
||||
|
||||
`bool kte::SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)`
|
||||
|
||||
Behavior:
|
||||
|
||||
- The caller supplies an **already-open** `Buffer` (typically loaded
|
||||
from the on-disk file) and a swap path.
|
||||
- `ReplayFile()` validates header magic/version, then iterates records.
|
||||
- On a truncated file or CRC mismatch, it returns `false` and sets
|
||||
`err`.
|
||||
- On unknown record types, it ignores them (forward compatibility).
|
||||
- On failure, the buffer may have had a prefix of records applied;
|
||||
callers should treat this as “recovery failed”.
|
||||
|
||||
Important: if the buffer is currently attached to a `SwapManager`, you
|
||||
should suspend/disable recording during replay (or detach first),
|
||||
otherwise replayed edits would be re-journaled.
|
||||
|
||||
## Tests
|
||||
|
||||
Swap behavior and format are validated by unit tests:
|
||||
|
||||
- `tests/test_swap_writer.cc` (header, permissions, record CRC framing)
|
||||
- `tests/test_swap_replay.cc` (record replay and truncation handling)
|
||||
+85
-34
@@ -4,67 +4,118 @@ Syntax highlighting in kte
|
||||
Overview
|
||||
--------
|
||||
|
||||
kte provides lightweight syntax highlighting with a pluggable highlighter interface. The initial implementation targets C/C++ and focuses on speed and responsiveness.
|
||||
kte provides lightweight syntax highlighting with a pluggable
|
||||
highlighter interface. The initial implementation targets C/C++ and
|
||||
focuses on speed and responsiveness.
|
||||
|
||||
Core types
|
||||
----------
|
||||
|
||||
- `TokenKind` — token categories (keywords, types, strings, comments, numbers, preprocessor, operators, punctuation, identifiers, whitespace, etc.).
|
||||
- `HighlightSpan` — a half-open column range `[col_start, col_end)` with a `TokenKind`.
|
||||
- `LineHighlight` — a vector of `HighlightSpan` and the buffer `version` used to compute it.
|
||||
- `TokenKind` — token categories (keywords, types, strings, comments,
|
||||
numbers, preprocessor, operators, punctuation, identifiers,
|
||||
whitespace, etc.).
|
||||
- `HighlightSpan` — a half-open column range `[col_start, col_end)` with
|
||||
a `TokenKind`.
|
||||
- `LineHighlight` — a vector of `HighlightSpan` and the buffer `version`
|
||||
used to compute it.
|
||||
|
||||
Engine and caching
|
||||
------------------
|
||||
|
||||
- `HighlighterEngine` maintains a per-line cache of `LineHighlight` keyed by row and buffer version.
|
||||
- Cache invalidation occurs when the buffer version changes or when the buffer calls `InvalidateFrom(row)`, which clears cached lines and line states from `row` downward.
|
||||
- The engine supports both stateless and stateful highlighters. For stateful highlighters, it memoizes a simple per-line state and computes lines sequentially when necessary.
|
||||
- `HighlighterEngine` maintains a per-line cache of `LineHighlight`
|
||||
keyed by row and buffer version.
|
||||
- Cache invalidation occurs when the buffer version changes or when the
|
||||
buffer calls `InvalidateFrom(row)`, which clears cached lines and line
|
||||
states from `row` downward.
|
||||
- The engine supports both stateless and stateful highlighters. For
|
||||
stateful highlighters, it memoizes a simple per-line state and
|
||||
computes lines sequentially when necessary.
|
||||
|
||||
Stateful highlighters
|
||||
---------------------
|
||||
|
||||
- `LanguageHighlighter` is the base interface for stateless per-line tokenization.
|
||||
- `StatefulHighlighter` extends it with a `LineState` and the method `HighlightLineStateful(buf, row, prev_state, out)`.
|
||||
- The engine detects `StatefulHighlighter` via dynamic_cast and feeds each line the previous line’s state, caching the resulting state per line.
|
||||
- `LanguageHighlighter` is the base interface for stateless per-line
|
||||
tokenization.
|
||||
- `StatefulHighlighter` extends it with a `LineState` and the method
|
||||
`HighlightLineStateful(buf, row, prev_state, out)`.
|
||||
- The engine detects `StatefulHighlighter` via dynamic_cast and feeds
|
||||
each line the previous line’s state, caching the resulting state per
|
||||
line.
|
||||
|
||||
C/C++ highlighter
|
||||
-----------------
|
||||
|
||||
- `CppHighlighter` implements `StatefulHighlighter`.
|
||||
- Stateless constructs: line comments `//`, strings `"..."`, chars `'...'`, numbers, identifiers (keywords/types), preprocessor at beginning of line after leading whitespace, operators/punctuation, and whitespace.
|
||||
- Stateless constructs: line comments `//`, strings `"..."`, chars
|
||||
`'...'`, numbers, identifiers (keywords/types), preprocessor at
|
||||
beginning of line after leading whitespace, operators/punctuation, and
|
||||
whitespace.
|
||||
- Stateful constructs (v2):
|
||||
- Multi-line block comments `/* ... */` — the state records whether the next line continues a comment.
|
||||
- Raw strings `R"delim(... )delim"` — the state tracks whether we are inside a raw string and its delimiter `delim` until the closing sequence appears.
|
||||
- Multi-line block comments `/* ... */` — the state records whether
|
||||
the next line continues a comment.
|
||||
- Raw strings `R"delim(... )delim"` — the state tracks whether we
|
||||
are inside a raw string and its delimiter `delim` until the
|
||||
closing sequence appears.
|
||||
|
||||
Limitations and TODOs
|
||||
---------------------
|
||||
|
||||
- Raw string detection is intentionally simple and does not handle all corner cases of the C++ standard.
|
||||
- Preprocessor handling is line-based; continuation lines with `\\` are not yet tracked.
|
||||
- No semantic analysis; identifiers are classified via small keyword/type sets.
|
||||
- Additional languages (JSON, Markdown, Shell, Python, Go, Rust, Lisp, …) are planned.
|
||||
- Terminal color mapping is conservative to support 8/16-color terminals. Rich color-pair themes can be added later.
|
||||
- Raw string detection is intentionally simple and does not handle all
|
||||
corner cases of the C++ standard.
|
||||
- Preprocessor handling is line-based; continuation lines with `\\` are
|
||||
not yet tracked.
|
||||
- No semantic analysis; identifiers are classified via small
|
||||
keyword/type sets.
|
||||
- Additional languages (JSON, Markdown, Shell, Python, Go, Rust,
|
||||
Lisp, …) are planned.
|
||||
- Terminal color mapping is conservative to support 8/16-color
|
||||
terminals. Rich color-pair themes can be added later.
|
||||
|
||||
Renderer integration
|
||||
--------------------
|
||||
|
||||
- Terminal and GUI renderers request line spans via `Highlighter()->GetLine(buf, row, buf.Version())`.
|
||||
- Search highlight and cursor overlays take precedence over syntax colors.
|
||||
- Terminal and GUI renderers request line spans via
|
||||
`Highlighter()->GetLine(buf, row, buf.Version())`.
|
||||
- Search highlight and cursor overlays take precedence over syntax
|
||||
colors.
|
||||
|
||||
Renderer-side robustness
|
||||
------------------------
|
||||
|
||||
- Renderers defensively sanitize `HighlightSpan` data before use to
|
||||
ensure stability even if a highlighter misbehaves:
|
||||
- Clamp `col_start/col_end` to the line length and ensure
|
||||
`end >= start`.
|
||||
- Drop empty/invalid spans and sort by start.
|
||||
- Clip drawing to the horizontally visible region and the
|
||||
tab-expanded line length.
|
||||
- The highlighter engine returns `LineHighlight` by value to avoid
|
||||
cross-thread lifetime issues; renderers operate on a local copy for
|
||||
each frame.
|
||||
|
||||
Extensibility (Phase 4)
|
||||
-----------------------
|
||||
|
||||
- Public registration API: external code can register custom highlighters by filetype.
|
||||
- Use `HighlighterRegistry::Register("mylang", []{ return std::make_unique<MyHighlighter>(); });`
|
||||
- Registered factories are preferred over built-ins for the same filetype key.
|
||||
- Filetype keys are normalized via `HighlighterRegistry::Normalize()`.
|
||||
- Optional Tree-sitter adapter: disabled by default to keep dependencies minimal.
|
||||
- Enable with CMake option `-DKTE_ENABLE_TREESITTER=ON` and provide
|
||||
`-DTREESITTER_INCLUDE_DIR=...` and `-DTREESITTER_LIBRARY=...` if needed.
|
||||
- Register a Tree-sitter-backed highlighter for a language (example assumes you link a grammar):
|
||||
```c++
|
||||
extern "C" const TSLanguage* tree_sitter_c();
|
||||
kte::HighlighterRegistry::RegisterTreeSitter("c", &tree_sitter_c);
|
||||
```
|
||||
- Current adapter is a stub scaffold; it compiles and integrates cleanly when enabled, but
|
||||
intentionally emits no spans until Tree-sitter node-to-token mapping is implemented.
|
||||
- Public registration API: external code can register custom
|
||||
highlighters by filetype.
|
||||
- Use
|
||||
`HighlighterRegistry::Register("mylang", []{ return std::make_unique<MyHighlighter>(); });`
|
||||
- Registered factories are preferred over built-ins for the same
|
||||
filetype key.
|
||||
- Filetype keys are normalized via
|
||||
`HighlighterRegistry::Normalize()`.
|
||||
- Optional Tree-sitter adapter: disabled by default to keep dependencies
|
||||
minimal.
|
||||
- Enable with CMake option `-DKTE_ENABLE_TREESITTER=ON` and provide
|
||||
`-DTREESITTER_INCLUDE_DIR=...` and `-DTREESITTER_LIBRARY=...` if
|
||||
needed.
|
||||
- Register a Tree-sitter-backed highlighter for a language (example
|
||||
assumes you link a grammar):
|
||||
```c++
|
||||
extern "C" const TSLanguage* tree_sitter_c();
|
||||
kte::HighlighterRegistry::RegisterTreeSitter("c", &tree_sitter_c);
|
||||
```
|
||||
- Current adapter is a stub scaffold; it compiles and integrates
|
||||
cleanly when enabled, but
|
||||
intentionally emits no spans until Tree-sitter node-to-token
|
||||
mapping is implemented.
|
||||
|
||||
+9
-4
@@ -23,29 +23,34 @@ Current themes (alphabetically):
|
||||
- **gruvbox** — Retro groove color scheme (light/dark variants)
|
||||
- **kanagawa-paper** — Inspired by traditional Japanese art
|
||||
- **lcars** — Star Trek LCARS interface style
|
||||
- **leuchtturm** — Modern, clean theme (light/dark variants)
|
||||
- **nord** — Arctic, north-bluish color palette
|
||||
- **old-book** — Sepia-toned vintage book aesthetic (light/dark
|
||||
variants)
|
||||
- **orbital** — Space-themed dark palette
|
||||
- **plan9** — Minimalist Plan 9 from Bell Labs inspired
|
||||
- **solarized** — Ethan Schoonover's Solarized (light/dark variants)
|
||||
- **tufte** — Edward Tufte-inspired minimalist theme (light/dark variants)
|
||||
- **weyland-yutani** — Alien franchise corporate aesthetic
|
||||
- **zenburn** — Low-contrast, easy-on-the-eyes theme
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
Themes are configured via `$HOME/.config/kte/kge.ini`:
|
||||
Themes are configured via `$HOME/.config/kte/kge.toml`:
|
||||
|
||||
```ini
|
||||
theme = nord
|
||||
background = dark
|
||||
```toml
|
||||
[appearance]
|
||||
theme = "nord"
|
||||
background = "dark"
|
||||
```
|
||||
|
||||
- `theme` — The theme name (e.g., "nord", "gruvbox", "solarized")
|
||||
- `background` — Either "dark" or "light" (for themes supporting both
|
||||
variants)
|
||||
|
||||
Legacy `kge.ini` format is also supported (see CONFIG.md).
|
||||
|
||||
Themes can also be switched at runtime using the `:theme <name>`
|
||||
command.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs =
|
||||
inputs@{ self, nixpkgs, ... }:
|
||||
outputs = { self, nixpkgs, ... }:
|
||||
let
|
||||
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
@@ -16,5 +15,23 @@
|
||||
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; };
|
||||
kge = (pkgsFor system).callPackage ./default.nix { graphical = 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -11884,4 +11884,4 @@ static const unsigned int DefaultFontRegularCompressedData[72616 / 4] =
|
||||
0x0070a96e,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user