Compare commits
198
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 | ||
|
|
35ef74910d | ||
|
|
b17672d440 | ||
|
|
cb1d16ebbc | ||
|
|
cbbde43dc2 | ||
|
|
45b2b88623 | ||
|
|
389dcf9cc7 | ||
|
|
c98d9e717a | ||
|
|
c864af7daa | ||
|
|
64022766c5 | ||
|
|
d706b6db44 | ||
|
|
bce9b3b33e | ||
|
|
56dc904432 | ||
|
|
a8197939f8 | ||
|
|
9f722ec2bb | ||
|
|
094020dab5 | ||
|
|
09e4cd7ec6 | ||
|
|
49fa7ff8a7 | ||
|
|
38915484ac | ||
|
|
87b1e6f502 | ||
|
|
ae822083c2 | ||
|
|
0c93d619c8 | ||
|
|
483ff18b0d | ||
|
|
cd33e8feb1 | ||
|
|
0bfe75fbf0 | ||
|
|
d15b241140 | ||
|
|
ceef6af3ae | ||
|
|
e62cf3ee28 |
Generated
+3
@@ -141,6 +141,9 @@
|
|||||||
<pair source="c++m" header="" fileNamingConvention="NONE" />
|
<pair source="c++m" header="" fileNamingConvention="NONE" />
|
||||||
</extensions>
|
</extensions>
|
||||||
</files>
|
</files>
|
||||||
|
<codeStyleSettings language="Markdown">
|
||||||
|
<option name="RIGHT_MARGIN" value="72" />
|
||||||
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="ObjectiveC">
|
<codeStyleSettings language="ObjectiveC">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="INDENT_SIZE" value="8" />
|
<option name="INDENT_SIZE" value="8" />
|
||||||
|
|||||||
Generated
+1
@@ -1,5 +1,6 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<state>
|
<state>
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="sccl" />
|
||||||
</state>
|
</state>
|
||||||
</component>
|
</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/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" 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/=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/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@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" />
|
<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/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@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/=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/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@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" />
|
<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"?>
|
<?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">
|
<component name="FacetManager">
|
||||||
<facet type="Python" name="Python facet">
|
<facet type="Python" name="Python facet">
|
||||||
<configuration sdkName="" />
|
<configuration sdkName="" />
|
||||||
|
|||||||
+2
-48
@@ -1,48 +1,2 @@
|
|||||||
# Project Guidelines
|
Read AGENTS.md at the repository root before anything else. It is the only
|
||||||
|
project guide; this file exists because Junie cannot import it.
|
||||||
kte is Kyle's Text Editor — a simple, fast text editor written in C++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)`.
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 +0,0 @@
|
|||||||
/*
|
|
||||||
* AppendBuffer.h - selector header to choose GapBuffer or PieceTable
|
|
||||||
*/
|
|
||||||
#ifndef KTE_APPENDBUFFER_H
|
|
||||||
#define KTE_APPENDBUFFER_H
|
|
||||||
|
|
||||||
#ifdef KTE_USE_PIECE_TABLE
|
|
||||||
#include "PieceTable.h"
|
|
||||||
using AppendBuffer = PieceTable;
|
|
||||||
#else
|
|
||||||
#include "GapBuffer.h"
|
|
||||||
using AppendBuffer = GapBuffer;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif // KTE_APPENDBUFFER_H
|
|
||||||
@@ -1,22 +1,75 @@
|
|||||||
/*
|
/*
|
||||||
* Buffer.h - editor buffer representing an open document
|
* Buffer.h - editor buffer representing an open document
|
||||||
|
*
|
||||||
|
* Buffer is the central document model in kte. Each Buffer represents one open file
|
||||||
|
* or scratch document and manages:
|
||||||
|
*
|
||||||
|
* - Content storage: Uses PieceTable for efficient text operations
|
||||||
|
* - Cursor state: Current position (curx_, cury_)
|
||||||
|
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
|
||||||
|
* - File backing: Optional association with a file on disk
|
||||||
|
* - Undo/Redo: Integrated UndoSystem for operation history
|
||||||
|
* - Syntax highlighting: Optional HighlighterEngine for language-aware coloring
|
||||||
|
* - Swap/crash recovery: Integration with SwapRecorder for journaling
|
||||||
|
* - Dirty tracking: Modification state for save prompts
|
||||||
|
*
|
||||||
|
* Key concepts:
|
||||||
|
*
|
||||||
|
* 1. Cursor coordinates:
|
||||||
|
* - (curx_, cury_): Logical character position in the document
|
||||||
|
*
|
||||||
|
* 2. File backing:
|
||||||
|
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
|
||||||
|
* - File identity tracking detects external modifications
|
||||||
|
*
|
||||||
|
* 3. Content access:
|
||||||
|
* - Nrows(), GetLineString(): per-line access
|
||||||
|
* - GetLineView(): line access via string_view (zero-copy within a piece)
|
||||||
|
* - Direct PieceTable access for new editing operations
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_BUFFER_H
|
#pragma once
|
||||||
#define KTE_BUFFER_H
|
#include <atomic>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <filesystem>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
#include "AppendBuffer.h"
|
#include "PieceTable.h"
|
||||||
#include "UndoSystem.h"
|
#include "UndoSystem.h"
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <memory>
|
#include "syntax/HighlighterEngine.h"
|
||||||
#include "HighlighterEngine.h"
|
|
||||||
#include "Highlight.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 {
|
class Buffer {
|
||||||
public:
|
public:
|
||||||
@@ -38,6 +91,14 @@ public:
|
|||||||
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
||||||
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
|
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
|
||||||
|
|
||||||
|
// External modification detection.
|
||||||
|
// Returns true if the file on disk differs from the last observed identity recorded
|
||||||
|
// on open/save.
|
||||||
|
[[nodiscard]] bool ExternallyModifiedOnDisk() const;
|
||||||
|
|
||||||
|
// Refresh the stored on-disk identity to match current stat (used after open/save).
|
||||||
|
void RefreshOnDiskIdentity();
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
[[nodiscard]] std::size_t Curx() const
|
[[nodiscard]] std::size_t Curx() const
|
||||||
{
|
{
|
||||||
@@ -51,15 +112,9 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::size_t Rx() const
|
|
||||||
{
|
|
||||||
return rx_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::size_t Nrows() const
|
[[nodiscard]] std::size_t Nrows() const
|
||||||
{
|
{
|
||||||
return nrows_;
|
return content_LineCount_();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -75,190 +130,37 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Line wrapper backed by AppendBuffer (GapBuffer/PieceTable)
|
// A line's text without its trailing newline.
|
||||||
class Line {
|
[[nodiscard]] std::string GetLineString(std::size_t row) const
|
||||||
public:
|
|
||||||
Line() = default;
|
|
||||||
|
|
||||||
|
|
||||||
explicit Line(const char *s)
|
|
||||||
{
|
{
|
||||||
assign_from(s ? std::string(s) : std::string());
|
return content_.GetLine(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
explicit Line(const std::string &s)
|
[[nodiscard]] std::pair<std::size_t, std::size_t> GetLineRange(std::size_t row) const
|
||||||
{
|
{
|
||||||
assign_from(s);
|
return content_.GetLineRange(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Line(const Line &other) = default;
|
// 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;
|
||||||
|
|
||||||
Line &operator=(const Line &other) = default;
|
// The whole text as one contiguous view (materializes an edited buffer).
|
||||||
|
// Invalid after the next edit.
|
||||||
Line(Line &&other) noexcept = default;
|
[[nodiscard]] std::string_view ContentView() const
|
||||||
|
|
||||||
Line &operator=(Line &&other) noexcept = default;
|
|
||||||
|
|
||||||
// capacity helpers
|
|
||||||
void Clear()
|
|
||||||
{
|
{
|
||||||
buf_.Clear();
|
return content_.ContentView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// size/access
|
// A copy of the whole text, assembled from the pieces (no materialized
|
||||||
[[nodiscard]] std::size_t size() const
|
// copy is kept).
|
||||||
{
|
[[nodiscard]] std::string Bytes() 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
|
|
||||||
{
|
|
||||||
return rows_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::vector<Line> &Rows()
|
|
||||||
{
|
|
||||||
return rows_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] const std::string &Filename() const
|
[[nodiscard]] const std::string &Filename() const
|
||||||
@@ -273,6 +175,7 @@ public:
|
|||||||
{
|
{
|
||||||
filename_ = name;
|
filename_ = name;
|
||||||
is_file_backed_ = false;
|
is_file_backed_ = false;
|
||||||
|
is_virtual_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -282,6 +185,23 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// A virtual buffer (e.g. "+HELP+") has a display name that is not a path:
|
||||||
|
// it is never journaled, and saving it asks for a file name.
|
||||||
|
[[nodiscard]] bool IsVirtual() const
|
||||||
|
{
|
||||||
|
return is_virtual_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Identity of this document, stable for its lifetime, including when the
|
||||||
|
// Buffer object is moved (e.g. by the editor's buffer vector). Copies get
|
||||||
|
// a new id. Use it, not the object's address, to key per-buffer caches.
|
||||||
|
[[nodiscard]] std::uint64_t Id() const
|
||||||
|
{
|
||||||
|
return id_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool Dirty() const
|
[[nodiscard]] bool Dirty() const
|
||||||
{
|
{
|
||||||
return dirty_;
|
return dirty_;
|
||||||
@@ -314,12 +234,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetRenderX(const std::size_t rx)
|
|
||||||
{
|
|
||||||
rx_ = rx;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetOffsets(const std::size_t row, const std::size_t col)
|
void SetOffsets(const std::size_t row, const std::size_t col)
|
||||||
{
|
{
|
||||||
rowoffs_ = row;
|
rowoffs_ = row;
|
||||||
@@ -329,13 +243,17 @@ public:
|
|||||||
|
|
||||||
void SetDirty(bool d)
|
void SetDirty(bool d)
|
||||||
{
|
{
|
||||||
|
// The version tracks content, not the dirty flag: every text mutation
|
||||||
|
// bumps it (see edited_at_), including undo/redo back to the saved state.
|
||||||
dirty_ = d;
|
dirty_ = d;
|
||||||
if (d) {
|
|
||||||
++version_;
|
|
||||||
if (highlighter_) {
|
|
||||||
highlighter_->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Whole-buffer content change (load, reload, replace): bump the version
|
||||||
|
// and drop all cached highlighting.
|
||||||
|
void MarkContentChanged()
|
||||||
|
{
|
||||||
|
edited_at_(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -372,25 +290,209 @@ 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;
|
[[nodiscard]] std::string AsString() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
[[nodiscard]] std::size_t clamp_row_(std::size_t y) const
|
||||||
|
{
|
||||||
|
const std::size_t n = Nrows();
|
||||||
|
return (n == 0) ? 0 : (y < n ? y : n - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
// Syntax highlighting integration (per-buffer)
|
// Syntax highlighting integration (per-buffer)
|
||||||
[[nodiscard]] std::uint64_t Version() const { return version_; }
|
[[nodiscard]] std::uint64_t Version() const
|
||||||
|
{
|
||||||
|
return version_;
|
||||||
|
}
|
||||||
|
|
||||||
void SetSyntaxEnabled(bool on) { syntax_enabled_ = on; }
|
|
||||||
[[nodiscard]] bool SyntaxEnabled() const { return syntax_enabled_; }
|
|
||||||
|
|
||||||
void SetFiletype(const std::string &ft) { filetype_ = ft; }
|
// Edit mode (code vs writing)
|
||||||
[[nodiscard]] const std::string &Filetype() const { return filetype_; }
|
[[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool SyntaxEnabled() const
|
||||||
|
{
|
||||||
|
return syntax_enabled_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] const std::string &Filetype() const
|
||||||
|
{
|
||||||
|
return filetype_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] kte::HighlighterEngine *Highlighter()
|
||||||
|
{
|
||||||
|
return highlighter_.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] const kte::HighlighterEngine *Highlighter() const
|
||||||
|
{
|
||||||
|
return highlighter_.get();
|
||||||
|
}
|
||||||
|
|
||||||
kte::HighlighterEngine *Highlighter() { return highlighter_.get(); }
|
|
||||||
const kte::HighlighterEngine *Highlighter() const { return highlighter_.get(); }
|
|
||||||
|
|
||||||
void EnsureHighlighter()
|
void EnsureHighlighter()
|
||||||
{
|
{
|
||||||
if (!highlighter_) highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
if (!highlighter_)
|
||||||
|
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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().
|
// Raw, low-level editing APIs used by UndoSystem apply().
|
||||||
// These must NOT trigger undo recording. They also do not move the cursor.
|
// These must NOT trigger undo recording. They also do not move the cursor.
|
||||||
void insert_text(int row, int col, std::string_view text);
|
void insert_text(int row, int col, std::string_view text);
|
||||||
@@ -405,34 +507,134 @@ public:
|
|||||||
|
|
||||||
void delete_row(int row);
|
void delete_row(int row);
|
||||||
|
|
||||||
|
// Insert the bytes of storage spans (see TextSpan.h) at (row, col)
|
||||||
|
// without copying them into storage again. Used by undo for large texts.
|
||||||
|
void insert_spans(int row, int col, const std::vector<TextSpan> &spans);
|
||||||
|
|
||||||
|
// Replace the entire buffer content with raw bytes.
|
||||||
|
// 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)
|
// Undo system accessors (created per-buffer)
|
||||||
UndoSystem *Undo();
|
[[nodiscard]] UndoSystem *Undo();
|
||||||
|
|
||||||
[[nodiscard]] const UndoSystem *Undo() const;
|
[[nodiscard]] const UndoSystem *Undo() const;
|
||||||
|
|
||||||
|
#if defined(KTE_TESTS)
|
||||||
|
// Test-only: return the raw buffer bytes (including newlines) as a string.
|
||||||
|
|
||||||
|
// Every line without its newline (tests only).
|
||||||
|
[[nodiscard]] std::vector<std::string> LinesForTests() const;
|
||||||
|
|
||||||
|
[[nodiscard]] std::string BytesForTests() const;
|
||||||
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct FileIdentity {
|
||||||
|
bool valid = false;
|
||||||
|
std::uint64_t mtime_ns = 0;
|
||||||
|
std::uint64_t size = 0;
|
||||||
|
std::uint64_t dev = 0;
|
||||||
|
std::uint64_t ino = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] static bool stat_identity(const std::string &path, FileIdentity &out);
|
||||||
|
|
||||||
|
[[nodiscard]] bool current_disk_identity(FileIdentity &out) const;
|
||||||
|
|
||||||
|
mutable FileIdentity on_disk_identity_{};
|
||||||
|
|
||||||
// State mirroring original C struct (without undo_tree)
|
// State mirroring original C struct (without undo_tree)
|
||||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
|
||||||
std::size_t nrows_ = 0; // number of rows
|
|
||||||
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
|
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
|
||||||
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_;
|
std::string filename_;
|
||||||
bool is_file_backed_ = false;
|
bool is_file_backed_ = false;
|
||||||
|
bool is_virtual_ = false;
|
||||||
|
std::uint64_t id_ = NextBufferId();
|
||||||
|
|
||||||
|
static std::uint64_t NextBufferId()
|
||||||
|
{
|
||||||
|
static std::atomic<std::uint64_t> next{1};
|
||||||
|
return next.fetch_add(1);
|
||||||
|
}
|
||||||
bool dirty_ = false;
|
bool dirty_ = false;
|
||||||
bool read_only_ = false;
|
bool read_only_ = false;
|
||||||
bool mark_set_ = false;
|
bool mark_set_ = false;
|
||||||
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
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
|
// Per-buffer undo state
|
||||||
std::unique_ptr<struct UndoTree> undo_tree_;
|
std::unique_ptr<struct UndoTree> undo_tree_;
|
||||||
std::unique_ptr<UndoSystem> undo_sys_;
|
std::unique_ptr<UndoSystem> undo_sys_;
|
||||||
|
|
||||||
|
// Edit mode (code vs writing)
|
||||||
|
EditMode edit_mode_ = EditMode::Code;
|
||||||
|
bool edit_mode_detected_ = false; // true after initial auto-detection
|
||||||
|
|
||||||
// Syntax/highlighting state
|
// Syntax/highlighting state
|
||||||
std::uint64_t version_ = 0; // increment on edits
|
std::uint64_t version_ = 0; // increment on edits
|
||||||
|
|
||||||
|
// Record a content change starting at `row`: bump the version and let the
|
||||||
|
// highlighter drop cached rows from `row` down (rows above are unaffected).
|
||||||
|
void edited_at_(int row)
|
||||||
|
{
|
||||||
|
++version_;
|
||||||
|
if (highlighter_)
|
||||||
|
highlighter_->OnEdit(row < 0 ? 0 : row, version_);
|
||||||
|
}
|
||||||
bool syntax_enabled_ = true;
|
bool syntax_enabled_ = true;
|
||||||
|
bool syntax_user_override_ = false; // true once user explicitly set syntax state via a command
|
||||||
std::string filetype_;
|
std::string filetype_;
|
||||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||||
|
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
||||||
|
kte::SwapRecorder *swap_rec_ = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_BUFFER_H
|
|
||||||
|
|||||||
+382
-71
@@ -3,16 +3,30 @@ project(kte)
|
|||||||
|
|
||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(KTE_VERSION "1.1.2")
|
set(KTE_VERSION "1.13.0")
|
||||||
|
|
||||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||||
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
||||||
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.")
|
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
||||||
option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" ON)
|
|
||||||
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
||||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
||||||
|
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
||||||
|
option(KTE_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" 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 ()
|
||||||
|
|
||||||
if (CMAKE_HOST_UNIX)
|
if (CMAKE_HOST_UNIX)
|
||||||
message(STATUS "Build system is POSIX.")
|
message(STATUS "Build system is POSIX.")
|
||||||
@@ -20,6 +34,10 @@ else ()
|
|||||||
message(STATUS "Build system is NOT POSIX.")
|
message(STATUS "Build system is NOT POSIX.")
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
|
add_compile_options(
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
if (MSVC)
|
if (MSVC)
|
||||||
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
|
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
|
||||||
else ()
|
else ()
|
||||||
@@ -27,37 +45,146 @@ else ()
|
|||||||
"-Wall"
|
"-Wall"
|
||||||
"-Wextra"
|
"-Wextra"
|
||||||
"-Werror"
|
"-Werror"
|
||||||
|
"-pedantic"
|
||||||
|
"-Wno-unused-function"
|
||||||
|
"-Wno-unused-parameter"
|
||||||
|
"$<$<CONFIG:RELEASE>:-O2>"
|
||||||
"$<$<CONFIG:DEBUG>:-g>"
|
"$<$<CONFIG:DEBUG>:-g>"
|
||||||
"$<$<CONFIG:RELEASE>:-O2>")
|
)
|
||||||
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
||||||
add_compile_options("-stdlib=libc++")
|
add_compile_options("-stdlib=libc++")
|
||||||
|
add_link_options("-stdlib=libc++")
|
||||||
else ()
|
else ()
|
||||||
# nothing special for gcc at the moment
|
# nothing special for gcc at the moment
|
||||||
endif ()
|
endif ()
|
||||||
endif ()
|
endif ()
|
||||||
add_compile_definitions(KGE_PLATFORM=${CMAKE_HOST_SYSTEM_NAME})
|
add_compile_definitions(KGE_PLATFORM=${CMAKE_HOST_SYSTEM_NAME})
|
||||||
add_compile_definitions(KTE_VERSION_STR="v${KTE_VERSION}")
|
add_compile_definitions(KTE_VERSION_STR="v${KTE_VERSION}")
|
||||||
|
if (KTE_ENABLE_TREESITTER)
|
||||||
|
add_compile_definitions(KTE_ENABLE_TREESITTER)
|
||||||
|
endif ()
|
||||||
|
|
||||||
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
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)
|
include(cmake/imgui.cmake)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
# NCurses for terminal mode
|
# NCurses for terminal mode
|
||||||
set(CURSES_NEED_NCURSES)
|
set(CURSES_NEED_NCURSES TRUE)
|
||||||
set(CURSES_NEED_WIDE)
|
set(CURSES_NEED_WIDE TRUE)
|
||||||
|
|
||||||
|
# macOS ships only a narrow-char ncurses in the SDK, and Homebrew's wide-char
|
||||||
|
# build is keg-only, so FindCurses can't see it without a hint.
|
||||||
|
if (APPLE)
|
||||||
|
find_program(BREW_EXECUTABLE brew)
|
||||||
|
if (BREW_EXECUTABLE)
|
||||||
|
execute_process(COMMAND "${BREW_EXECUTABLE}" --prefix ncurses
|
||||||
|
OUTPUT_VARIABLE HOMEBREW_NCURSES_PREFIX
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||||
|
ERROR_QUIET)
|
||||||
|
if (HOMEBREW_NCURSES_PREFIX AND IS_DIRECTORY "${HOMEBREW_NCURSES_PREFIX}")
|
||||||
|
message(STATUS "Using Homebrew ncursesw: ${HOMEBREW_NCURSES_PREFIX}")
|
||||||
|
list(PREPEND CMAKE_PREFIX_PATH "${HOMEBREW_NCURSES_PREFIX}")
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
find_package(Curses REQUIRED)
|
find_package(Curses REQUIRED)
|
||||||
include_directories(${CURSES_INCLUDE_DIR})
|
|
||||||
|
# FindCurses exports CURSES_INCLUDE_DIRS; the singular CURSES_INCLUDE_DIR was
|
||||||
|
# never set, so this silently expanded to nothing and the compiler fell back
|
||||||
|
# to whatever curses header happened to be on the default include path.
|
||||||
|
include_directories(${CURSES_INCLUDE_DIRS})
|
||||||
|
|
||||||
|
# The wide-char entry points we rely on (get_wch, etc.) are declared only when
|
||||||
|
# NCURSES_WIDECHAR is set; the header defaults it to 0 unless a feature-test
|
||||||
|
# macro says otherwise. We always link the wide library, so ask for them.
|
||||||
|
add_compile_definitions(NCURSES_WIDECHAR=1)
|
||||||
|
|
||||||
|
# On Alpine Linux, CMake's FindCurses looks in wrong paths
|
||||||
|
# Manually find the correct ncurses library
|
||||||
|
if (EXISTS "/etc/alpine-release")
|
||||||
|
find_library(NCURSESW_LIB NAMES ncursesw PATHS /usr/lib /lib REQUIRED)
|
||||||
|
set(CURSES_LIBRARIES ${NCURSESW_LIB})
|
||||||
|
message(STATUS "Alpine Linux detected, using ncurses at: ${NCURSESW_LIB}")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
set(SYNTAX_SOURCES
|
||||||
|
syntax/GoHighlighter.cc
|
||||||
|
syntax/CppHighlighter.cc
|
||||||
|
syntax/JsonHighlighter.cc
|
||||||
|
syntax/ErlangHighlighter.cc
|
||||||
|
syntax/MarkdownHighlighter.cc
|
||||||
|
syntax/TreeSitterHighlighter.cc
|
||||||
|
syntax/LispHighlighter.cc
|
||||||
|
syntax/HighlighterEngine.cc
|
||||||
|
syntax/RustHighlighter.cc
|
||||||
|
syntax/HighlighterRegistry.cc
|
||||||
|
syntax/SqlHighlighter.cc
|
||||||
|
syntax/NullHighlighter.cc
|
||||||
|
syntax/ForthHighlighter.cc
|
||||||
|
syntax/PythonHighlighter.cc
|
||||||
|
syntax/ShellHighlighter.cc
|
||||||
|
)
|
||||||
|
|
||||||
|
if (KTE_ENABLE_TREESITTER)
|
||||||
|
list(APPEND SYNTAX_SOURCES
|
||||||
|
TreeSitterHighlighter.cc)
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
set(FONT_SOURCES
|
||||||
|
fonts/Font.cc
|
||||||
|
fonts/FontRegistry.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
|
set(COMMON_SOURCES
|
||||||
GapBuffer.cc
|
|
||||||
PieceTable.cc
|
PieceTable.cc
|
||||||
Buffer.cc
|
Buffer.cc
|
||||||
Editor.cc
|
Editor.cc
|
||||||
Command.cc
|
Command.cc
|
||||||
HelpText.cc
|
HelpText.cc
|
||||||
KKeymap.cc
|
KKeymap.cc
|
||||||
|
Swap.cc
|
||||||
|
ErrorHandler.cc
|
||||||
|
SyscallWrappers.cc
|
||||||
|
ErrorRecovery.cc
|
||||||
|
RegexGuard.cc
|
||||||
|
RegexEngine.cc
|
||||||
|
SearchHighlight.cc
|
||||||
TerminalInputHandler.cc
|
TerminalInputHandler.cc
|
||||||
TerminalRenderer.cc
|
TerminalRenderer.cc
|
||||||
TerminalFrontend.cc
|
TerminalFrontend.cc
|
||||||
@@ -67,28 +194,85 @@ set(COMMON_SOURCES
|
|||||||
UndoNode.cc
|
UndoNode.cc
|
||||||
UndoTree.cc
|
UndoTree.cc
|
||||||
UndoSystem.cc
|
UndoSystem.cc
|
||||||
HighlighterEngine.cc
|
|
||||||
CppHighlighter.cc
|
${SYNTAX_SOURCES}
|
||||||
HighlighterRegistry.cc
|
)
|
||||||
NullHighlighter.cc
|
|
||||||
JsonHighlighter.cc
|
set(SYNTAX_HEADERS
|
||||||
MarkdownHighlighter.cc
|
syntax/GoHighlighter.h
|
||||||
ShellHighlighter.cc
|
syntax/HighlighterEngine.h
|
||||||
GoHighlighter.cc
|
syntax/ShellHighlighter.h
|
||||||
PythonHighlighter.cc
|
syntax/MarkdownHighlighter.h
|
||||||
RustHighlighter.cc
|
syntax/LispHighlighter.h
|
||||||
LispHighlighter.cc
|
syntax/SqlHighlighter.h
|
||||||
|
syntax/ForthHighlighter.h
|
||||||
|
syntax/JsonHighlighter.h
|
||||||
|
syntax/TreeSitterHighlighter.h
|
||||||
|
syntax/NullHighlighter.h
|
||||||
|
syntax/CppHighlighter.h
|
||||||
|
syntax/ErlangHighlighter.h
|
||||||
|
syntax/LanguageHighlighter.h
|
||||||
|
syntax/RustHighlighter.h
|
||||||
|
syntax/PythonHighlighter.h
|
||||||
|
)
|
||||||
|
|
||||||
|
if (KTE_ENABLE_TREESITTER)
|
||||||
|
list(APPEND THEME_HEADERS
|
||||||
|
TreeSitterHighlighter.h)
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
set(THEME_HEADERS
|
||||||
|
themes/ThemeHelpers.h
|
||||||
|
themes/EInk.h
|
||||||
|
themes/Gruvbox.h
|
||||||
|
themes/Solarized.h
|
||||||
|
themes/Plan9.h
|
||||||
|
themes/Nord.h
|
||||||
|
themes/Everforest.h
|
||||||
|
themes/KanagawaPaper.h
|
||||||
|
themes/LCARS.h
|
||||||
|
themes/OldBook.h
|
||||||
|
themes/Amber.h
|
||||||
|
themes/Orbital.h
|
||||||
|
themes/WeylandYutani.h
|
||||||
|
themes/Zenburn.h
|
||||||
|
)
|
||||||
|
|
||||||
|
set(FONT_HEADERS
|
||||||
|
fonts/Font.h
|
||||||
|
fonts/FontRegistry.h
|
||||||
|
fonts/FontRegistry.h
|
||||||
|
fonts/FontList.h
|
||||||
|
fonts/B612Mono.h
|
||||||
|
fonts/BrassMono.h
|
||||||
|
fonts/CrimsonPro.h
|
||||||
|
fonts/ETBook.h
|
||||||
|
fonts/BrassMonoCode.h
|
||||||
|
fonts/FiraCode.h
|
||||||
|
fonts/Go.h
|
||||||
|
fonts/IBMPlexMono.h
|
||||||
|
fonts/Idealist.h
|
||||||
|
fonts/Inconsolata.h
|
||||||
|
fonts/InconsolataExpanded.h
|
||||||
|
fonts/Iosevka.h
|
||||||
|
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
|
set(COMMON_HEADERS
|
||||||
GapBuffer.h
|
|
||||||
PieceTable.h
|
PieceTable.h
|
||||||
Buffer.h
|
Buffer.h
|
||||||
Editor.h
|
Editor.h
|
||||||
AppendBuffer.h
|
|
||||||
Command.h
|
Command.h
|
||||||
HelpText.h
|
HelpText.h
|
||||||
KKeymap.h
|
KKeymap.h
|
||||||
|
Swap.h
|
||||||
InputHandler.h
|
InputHandler.h
|
||||||
TerminalInputHandler.h
|
TerminalInputHandler.h
|
||||||
Renderer.h
|
Renderer.h
|
||||||
@@ -98,24 +282,33 @@ set(COMMON_HEADERS
|
|||||||
TestInputHandler.h
|
TestInputHandler.h
|
||||||
TestRenderer.h
|
TestRenderer.h
|
||||||
TestFrontend.h
|
TestFrontend.h
|
||||||
|
TextSpan.h
|
||||||
|
RegexEngine.h
|
||||||
|
SearchHighlight.h
|
||||||
UndoNode.h
|
UndoNode.h
|
||||||
UndoTree.h
|
UndoTree.h
|
||||||
UndoSystem.h
|
UndoSystem.h
|
||||||
Highlight.h
|
Highlight.h
|
||||||
LanguageHighlighter.h
|
|
||||||
HighlighterEngine.h
|
${SYNTAX_HEADERS}
|
||||||
CppHighlighter.h
|
|
||||||
HighlighterRegistry.h
|
|
||||||
NullHighlighter.h
|
|
||||||
JsonHighlighter.h
|
|
||||||
MarkdownHighlighter.h
|
|
||||||
ShellHighlighter.h
|
|
||||||
GoHighlighter.h
|
|
||||||
PythonHighlighter.h
|
|
||||||
RustHighlighter.h
|
|
||||||
LispHighlighter.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
|
# kte (terminal-first) executable
|
||||||
add_executable(kte
|
add_executable(kte
|
||||||
main.cc
|
main.cc
|
||||||
@@ -123,15 +316,29 @@ add_executable(kte
|
|||||||
${COMMON_HEADERS}
|
${COMMON_HEADERS}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (KTE_USE_PIECE_TABLE)
|
|
||||||
target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1)
|
|
||||||
endif ()
|
|
||||||
if (KTE_UNDO_DEBUG)
|
if (KTE_UNDO_DEBUG)
|
||||||
target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1)
|
target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
target_link_libraries(kte ${CURSES_LIBRARIES})
|
target_link_libraries(kte ${CURSES_LIBRARIES})
|
||||||
|
|
||||||
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
|
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||||
|
target_link_options(kte PRIVATE -static)
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (KTE_ENABLE_TREESITTER)
|
||||||
|
# Users can provide their own tree-sitter include/lib via cache variables
|
||||||
|
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
|
||||||
|
set(TREESITTER_LIBRARY "" CACHE FILEPATH "Path to tree-sitter library (.a/.dylib)")
|
||||||
|
if (TREESITTER_INCLUDE_DIR)
|
||||||
|
target_include_directories(kte PRIVATE ${TREESITTER_INCLUDE_DIR})
|
||||||
|
endif ()
|
||||||
|
if (TREESITTER_LIBRARY)
|
||||||
|
target_link_libraries(kte ${TREESITTER_LIBRARY})
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
install(TARGETS kte
|
install(TARGETS kte
|
||||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
)
|
)
|
||||||
@@ -140,58 +347,120 @@ install(TARGETS kte
|
|||||||
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
||||||
|
|
||||||
if (BUILD_TESTS)
|
if (BUILD_TESTS)
|
||||||
# test_undo executable for testing undo/redo system
|
# Unified unit test runner
|
||||||
add_executable(test_undo
|
add_executable(kte_tests
|
||||||
test_undo.cc
|
tests/TestRunner.cc
|
||||||
${COMMON_SOURCES}
|
tests/Test.h
|
||||||
${COMMON_HEADERS}
|
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)
|
# Allow test-only introspection hooks (guarded in headers) without affecting production builds.
|
||||||
target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1)
|
target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
|
||||||
|
|
||||||
|
# Allow tests to include project headers like "Buffer.h"
|
||||||
|
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
|
# Keep tests free of ncurses/GUI deps
|
||||||
|
if (KTE_ENABLE_TREESITTER)
|
||||||
|
if (TREESITTER_INCLUDE_DIR)
|
||||||
|
target_include_directories(kte_tests PRIVATE ${TREESITTER_INCLUDE_DIR})
|
||||||
|
endif ()
|
||||||
|
if (TREESITTER_LIBRARY)
|
||||||
|
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
|
||||||
|
endif ()
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
if (KTE_UNDO_DEBUG)
|
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||||
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1)
|
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||||
|
target_link_options(kte_tests PRIVATE -static)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
|
enable_testing()
|
||||||
target_link_libraries(test_undo ${CURSES_LIBRARIES})
|
add_test(NAME kte_tests COMMAND kte_tests)
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
if (${BUILD_GUI})
|
if (BUILD_GUI)
|
||||||
target_sources(kte PRIVATE
|
# ImGui::CreateContext();
|
||||||
Font.h
|
# ImGuiIO& io = ImGui::GetIO();
|
||||||
GUIConfig.cc
|
|
||||||
GUIConfig.h
|
# // Set custom ini filename path to ~/.config/kte/imgui.ini
|
||||||
GUIRenderer.cc
|
# if (const char* home = std::getenv("HOME")) {
|
||||||
GUIRenderer.h
|
# static std::string ini_path = std::string(home) + "/.config/kte/imgui.ini";
|
||||||
GUIInputHandler.cc
|
# io.IniFilename = ini_path.c_str();
|
||||||
GUIInputHandler.h
|
# }
|
||||||
GUIFrontend.cc
|
|
||||||
GUIFrontend.h)
|
# io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
|
||||||
target_compile_definitions(kte PRIVATE KTE_BUILD_GUI=1)
|
# io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
|
||||||
target_link_libraries(kte imgui)
|
# Do not enable GUI in the terminal-first 'kte' binary; GUI is built as separate 'kge'.
|
||||||
|
# This avoids referencing GUI classes from kte and keeps dependencies minimal.
|
||||||
|
|
||||||
# kge (GUI-first) executable
|
# kge (GUI-first) executable
|
||||||
add_executable(kge
|
add_executable(kge
|
||||||
main.cc
|
main.cc
|
||||||
${COMMON_SOURCES}
|
${COMMON_SOURCES}
|
||||||
|
${GUI_SOURCES}
|
||||||
${COMMON_HEADERS}
|
${COMMON_HEADERS}
|
||||||
GUIConfig.cc
|
${GUI_HEADERS}
|
||||||
GUIConfig.h
|
|
||||||
GUIRenderer.cc
|
)
|
||||||
GUIRenderer.h
|
|
||||||
GUIInputHandler.cc
|
|
||||||
GUIInputHandler.h
|
|
||||||
GUIFrontend.cc
|
|
||||||
GUIFrontend.h)
|
|
||||||
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
|
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
|
||||||
if (KTE_UNDO_DEBUG)
|
if (KTE_UNDO_DEBUG)
|
||||||
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
|
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
|
||||||
endif ()
|
endif ()
|
||||||
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
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
|
# On macOS, build kge as a proper .app bundle
|
||||||
if (APPLE)
|
if (APPLE)
|
||||||
# Define the icon file
|
# Define the icon file
|
||||||
@@ -210,12 +479,18 @@ if (${BUILD_GUI})
|
|||||||
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
|
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
|
||||||
@ONLY)
|
@ONLY)
|
||||||
|
|
||||||
|
# Ensure proper macOS bundle properties and RPATH so our bundled
|
||||||
|
# frameworks are preferred over system/Homebrew ones.
|
||||||
set_target_properties(kge PROPERTIES
|
set_target_properties(kge PROPERTIES
|
||||||
MACOSX_BUNDLE TRUE
|
MACOSX_BUNDLE TRUE
|
||||||
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
|
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
|
||||||
MACOSX_BUNDLE_BUNDLE_NAME "kge"
|
MACOSX_BUNDLE_BUNDLE_NAME "kge"
|
||||||
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
|
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_dependencies(kge kte)
|
||||||
add_custom_command(TARGET kge POST_BUILD
|
add_custom_command(TARGET kge POST_BUILD
|
||||||
@@ -239,4 +514,40 @@ if (${BUILD_GUI})
|
|||||||
# Install kge man page only when GUI is built
|
# Install kge man page only when GUI is built
|
||||||
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
||||||
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
|
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
|
||||||
|
if (UNIX AND NOT APPLE)
|
||||||
|
install(FILES kge.desktop DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/applications)
|
||||||
|
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/pixmaps)
|
||||||
|
install(FILES kge.iconset/icon_16x16.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/16x16/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
install(FILES kge.iconset/icon_32x32.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/32x32/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
install(FILES kge.iconset/icon_128x128.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/128x128/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
install(FILES kge.iconset/icon_256x256.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/256x256/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
install(FILES kge.iconset/icon_512x512.png
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/512x512/apps
|
||||||
|
RENAME kge.png)
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
# Optional post-build bundle fixup (can also be run from scripts).
|
||||||
|
# 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 ()
|
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).
|
||||||
+2818
-906
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* Command.h - command model and registry for editor actions
|
* Command.h - command model and registry for editor actions
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_COMMAND_H
|
#pragma once
|
||||||
#define KTE_COMMAND_H
|
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -28,14 +27,17 @@ enum class CommandId {
|
|||||||
SearchReplace, // begin search & replace (two-step prompt)
|
SearchReplace, // begin search & replace (two-step prompt)
|
||||||
OpenFileStart, // begin open-file prompt
|
OpenFileStart, // begin open-file prompt
|
||||||
VisualFilePickerToggle,
|
VisualFilePickerToggle,
|
||||||
|
// GUI-only: toggle/show a visual font selector dialog
|
||||||
// Buffers
|
// Buffers
|
||||||
BufferSwitchStart, // begin buffer switch prompt
|
BufferSwitchStart, // begin buffer switch prompt
|
||||||
|
BufferNew, // create a new empty, unnamed buffer (C-k i)
|
||||||
BufferClose,
|
BufferClose,
|
||||||
BufferNext,
|
BufferNext,
|
||||||
BufferPrev,
|
BufferPrev,
|
||||||
// Editing
|
// Editing
|
||||||
InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
|
InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
|
||||||
Newline, // insert a newline at cursor
|
Newline, // insert a newline at cursor
|
||||||
|
SmartNewline, // insert a newline with auto-indent (Shift-Enter)
|
||||||
Backspace, // delete char before cursor (may join lines)
|
Backspace, // delete char before cursor (may join lines)
|
||||||
DeleteChar, // delete char at cursor (may join lines)
|
DeleteChar, // delete char at cursor (may join lines)
|
||||||
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
|
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
|
||||||
@@ -45,6 +47,7 @@ enum class CommandId {
|
|||||||
MoveFileStart, // move to beginning of file
|
MoveFileStart, // move to beginning of file
|
||||||
MoveFileEnd, // move to end of file
|
MoveFileEnd, // move to end of file
|
||||||
ToggleMark, // toggle mark at cursor
|
ToggleMark, // toggle mark at cursor
|
||||||
|
VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /)
|
||||||
JumpToMark, // jump to mark, set mark to previous cursor
|
JumpToMark, // jump to mark, set mark to previous cursor
|
||||||
KillRegion, // kill region between mark and cursor (to kill ring)
|
KillRegion, // kill region between mark and cursor (to kill ring)
|
||||||
CopyRegion, // copy region to kill ring (Alt-w)
|
CopyRegion, // copy region to kill ring (Alt-w)
|
||||||
@@ -58,6 +61,8 @@ enum class CommandId {
|
|||||||
MoveEnd,
|
MoveEnd,
|
||||||
PageUp,
|
PageUp,
|
||||||
PageDown,
|
PageDown,
|
||||||
|
ScrollUp, // scroll viewport up (towards beginning) without moving cursor
|
||||||
|
ScrollDown, // scroll viewport down (towards end) without moving cursor
|
||||||
WordPrev,
|
WordPrev,
|
||||||
WordNext,
|
WordNext,
|
||||||
DeleteWordPrev, // delete previous word (ESC BACKSPACE)
|
DeleteWordPrev, // delete previous word (ESC BACKSPACE)
|
||||||
@@ -89,15 +94,30 @@ enum class CommandId {
|
|||||||
ShowHelp, // open +HELP+ buffer with manual text (C-k h)
|
ShowHelp, // open +HELP+ buffer with manual text (C-k h)
|
||||||
// Meta
|
// Meta
|
||||||
UnknownKCommand, // arg: single character that was not recognized after C-k
|
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
|
// Generic command prompt
|
||||||
CommandPromptStart, // begin generic command prompt (C-k ;)
|
CommandPromptStart, // begin generic command prompt (C-k ;)
|
||||||
// Theme by name
|
// Theme by name
|
||||||
ThemeSetByName,
|
ThemeSetByName,
|
||||||
|
// Font by name (GUI)
|
||||||
|
FontSetByName,
|
||||||
|
// Font size (GUI)
|
||||||
|
FontSetSize,
|
||||||
// Background mode (GUI)
|
// Background mode (GUI)
|
||||||
BackgroundSet,
|
BackgroundSet,
|
||||||
// Syntax highlighting
|
// Syntax highlighting
|
||||||
Syntax, // ":syntax on|off|reload"
|
Syntax, // ":syntax on|off|reload"
|
||||||
SetOption, // generic ":set key=value" (v1: filetype=<lang>)
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -123,6 +143,9 @@ struct Command {
|
|||||||
CommandHandler handler;
|
CommandHandler handler;
|
||||||
// Public commands are exposed in the ": " prompt (C-k ;)
|
// Public commands are exposed in the ": " prompt (C-k ;)
|
||||||
bool isPublic = false;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -150,5 +173,3 @@ void InstallDefaultCommands();
|
|||||||
bool Execute(Editor &ed, CommandId id, const std::string &arg = std::string(), int count = 0);
|
bool Execute(Editor &ed, CommandId id, const std::string &arg = std::string(), int count = 0);
|
||||||
|
|
||||||
bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0);
|
bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0);
|
||||||
|
|
||||||
#endif // KTE_COMMAND_H
|
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
#include "CppHighlighter.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include <cctype>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
static bool is_digit(char c) { return c >= '0' && c <= '9'; }
|
|
||||||
|
|
||||||
CppHighlighter::CppHighlighter()
|
|
||||||
{
|
|
||||||
const char *kw[] = {
|
|
||||||
"if","else","for","while","do","switch","case","default","break","continue",
|
|
||||||
"return","goto","struct","class","namespace","using","template","typename",
|
|
||||||
"public","private","protected","virtual","override","const","constexpr","auto",
|
|
||||||
"static","inline","operator","new","delete","try","catch","throw","friend",
|
|
||||||
"enum","union","extern","volatile","mutable","noexcept","sizeof","this"
|
|
||||||
};
|
|
||||||
for (auto s: kw) keywords_.insert(s);
|
|
||||||
const char *types[] = {
|
|
||||||
"int","long","short","char","signed","unsigned","float","double","void",
|
|
||||||
"bool","wchar_t","size_t","ptrdiff_t","uint8_t","uint16_t","uint32_t","uint64_t",
|
|
||||||
"int8_t","int16_t","int32_t","int64_t"
|
|
||||||
};
|
|
||||||
for (auto s: types) types_.insert(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CppHighlighter::is_ident_start(char c) { return std::isalpha(static_cast<unsigned char>(c)) || c == '_'; }
|
|
||||||
bool CppHighlighter::is_ident_char(char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '_'; }
|
|
||||||
|
|
||||||
void CppHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
// Stateless entry simply delegates to stateful with a clean previous state
|
|
||||||
StatefulHighlighter::LineState prev;
|
|
||||||
(void)HighlightLineStateful(buf, row, prev, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatefulHighlighter::LineState CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
|
||||||
int row,
|
|
||||||
const LineState &prev,
|
|
||||||
std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
StatefulHighlighter::LineState state = prev;
|
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return state;
|
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
if (s.empty()) return state;
|
|
||||||
|
|
||||||
auto push = [&](int a, int b, TokenKind k){ if (b> a) out.push_back({a,b,k}); };
|
|
||||||
int n = static_cast<int>(s.size());
|
|
||||||
int bol = 0; while (bol < n && (s[bol] == ' ' || s[bol] == '\t')) ++bol;
|
|
||||||
int i = 0;
|
|
||||||
|
|
||||||
// Continue multi-line raw string from previous line
|
|
||||||
if (state.in_raw_string) {
|
|
||||||
std::string needle = ")" + state.raw_delim + "\"";
|
|
||||||
auto pos = s.find(needle);
|
|
||||||
if (pos == std::string::npos) {
|
|
||||||
push(0, n, TokenKind::String);
|
|
||||||
state.in_raw_string = true;
|
|
||||||
return state;
|
|
||||||
} else {
|
|
||||||
int end = static_cast<int>(pos + needle.size());
|
|
||||||
push(0, end, TokenKind::String);
|
|
||||||
i = end;
|
|
||||||
state.in_raw_string = false;
|
|
||||||
state.raw_delim.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue multi-line block comment from previous line
|
|
||||||
if (state.in_block_comment) {
|
|
||||||
int j = i;
|
|
||||||
while (i + 1 < n) {
|
|
||||||
if (s[i] == '*' && s[i+1] == '/') { i += 2; push(j, i, TokenKind::Comment); state.in_block_comment = false; break; }
|
|
||||||
++i;
|
|
||||||
}
|
|
||||||
if (state.in_block_comment) { push(j, n, TokenKind::Comment); return state; }
|
|
||||||
}
|
|
||||||
|
|
||||||
while (i < n) {
|
|
||||||
char c = s[i];
|
|
||||||
// Preprocessor at beginning of line (after leading whitespace)
|
|
||||||
if (i == bol && c == '#') { push(0, n, TokenKind::Preproc); break; }
|
|
||||||
|
|
||||||
// Whitespace
|
|
||||||
if (c == ' ' || c == '\t') {
|
|
||||||
int j = i+1; while (j < n && (s[j] == ' ' || s[j] == '\t')) ++j; push(i,j,TokenKind::Whitespace); i=j; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Line comment
|
|
||||||
if (c == '/' && i+1 < n && s[i+1] == '/') { push(i, n, TokenKind::Comment); break; }
|
|
||||||
|
|
||||||
// Block comment
|
|
||||||
if (c == '/' && i+1 < n && s[i+1] == '*') {
|
|
||||||
int j = i+2;
|
|
||||||
bool closed = false;
|
|
||||||
while (j + 1 <= n) {
|
|
||||||
if (j + 1 < n && s[j] == '*' && s[j+1] == '/') { j += 2; closed = true; break; }
|
|
||||||
++j;
|
|
||||||
}
|
|
||||||
if (closed) { push(i, j, TokenKind::Comment); i = j; continue; }
|
|
||||||
// Spill to next lines
|
|
||||||
push(i, n, TokenKind::Comment);
|
|
||||||
state.in_block_comment = true;
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raw string start: very simple detection: R"delim(
|
|
||||||
if (c == 'R' && i+1 < n && s[i+1] == '"') {
|
|
||||||
int k = i + 2;
|
|
||||||
std::string delim;
|
|
||||||
while (k < n && s[k] != '(') { delim.push_back(s[k]); ++k; }
|
|
||||||
if (k < n && s[k] == '(') {
|
|
||||||
int body_start = k + 1;
|
|
||||||
std::string needle = ")" + delim + "\"";
|
|
||||||
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
|
||||||
if (pos == std::string::npos) {
|
|
||||||
push(i, n, TokenKind::String);
|
|
||||||
state.in_raw_string = true;
|
|
||||||
state.raw_delim = delim;
|
|
||||||
return state;
|
|
||||||
} else {
|
|
||||||
int end = static_cast<int>(pos + needle.size());
|
|
||||||
push(i, end, TokenKind::String);
|
|
||||||
i = end;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If malformed, just treat 'R' as identifier fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular string literal
|
|
||||||
if (c == '"') {
|
|
||||||
int j = i+1; bool esc=false; while (j < n) { char d = s[j++]; if (esc) { esc=false; continue; } if (d == '\\') { esc=true; continue; } if (d == '"') break; }
|
|
||||||
push(i, j, TokenKind::String); i = j; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Char literal
|
|
||||||
if (c == '\'') {
|
|
||||||
int j = i+1; bool esc=false; while (j < n) { char d = s[j++]; if (esc) { esc=false; continue; } if (d == '\\') { esc=true; continue; } if (d == '\'') break; }
|
|
||||||
push(i, j, TokenKind::Char); i = j; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number literal (simple)
|
|
||||||
if (is_digit(c) || (c == '.' && i+1 < n && is_digit(s[i+1]))) {
|
|
||||||
int j = i+1; while (j < n && (std::isalnum(static_cast<unsigned char>(s[j])) || s[j]=='.' || s[j]=='x' || s[j]=='X' || s[j]=='b' || s[j]=='B' || s[j]=='_')) ++j;
|
|
||||||
push(i, j, TokenKind::Number); i = j; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identifier / keyword / type
|
|
||||||
if (is_ident_start(c)) {
|
|
||||||
int j = i+1; while (j < n && is_ident_char(s[j])) ++j; std::string id = s.substr(i, j-i);
|
|
||||||
TokenKind k = TokenKind::Identifier; if (keywords_.count(id)) k = TokenKind::Keyword; else if (types_.count(id)) k = TokenKind::Type; push(i, j, k); i = j; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Operators and punctuation (single char for now)
|
|
||||||
TokenKind kind = TokenKind::Operator;
|
|
||||||
if (std::ispunct(static_cast<unsigned char>(c)) && c != '_' && c != '#') {
|
|
||||||
if (c==';' || c==',' || c=='(' || c==')' || c=='{' || c=='}' || c=='[' || c==']') kind = TokenKind::Punctuation;
|
|
||||||
push(i, i+1, kind); ++i; continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
push(i, i+1, TokenKind::Default); ++i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// CppHighlighter.h - minimal stateless C/C++ line highlighter
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <regex>
|
|
||||||
#include <string>
|
|
||||||
#include <unordered_set>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "LanguageHighlighter.h"
|
|
||||||
|
|
||||||
class Buffer;
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
class CppHighlighter final : public StatefulHighlighter {
|
|
||||||
public:
|
|
||||||
CppHighlighter();
|
|
||||||
~CppHighlighter() override = default;
|
|
||||||
|
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
|
||||||
LineState HighlightLineStateful(const Buffer &buf,
|
|
||||||
int row,
|
|
||||||
const LineState &prev,
|
|
||||||
std::vector<HighlightSpan> &out) const override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::unordered_set<std::string> keywords_;
|
|
||||||
std::unordered_set<std::string> types_;
|
|
||||||
|
|
||||||
static bool is_ident_start(char c);
|
|
||||||
static bool is_ident_char(char c);
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
+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 <algorithm>
|
||||||
#include <utility>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "HighlighterRegistry.h"
|
#include "ErrorHandler.h"
|
||||||
#include "CppHighlighter.h"
|
#include "syntax/CppHighlighter.h"
|
||||||
#include "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
|
void
|
||||||
@@ -30,20 +102,22 @@ Editor::SetStatus(const std::string &message)
|
|||||||
Buffer *
|
Buffer *
|
||||||
Editor::CurrentBuffer()
|
Editor::CurrentBuffer()
|
||||||
{
|
{
|
||||||
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
|
auto &bufs = Buffers();
|
||||||
|
if (bufs.empty() || curbuf_ >= bufs.size()) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
return &buffers_[curbuf_];
|
return &bufs[curbuf_];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const Buffer *
|
const Buffer *
|
||||||
Editor::CurrentBuffer() const
|
Editor::CurrentBuffer() const
|
||||||
{
|
{
|
||||||
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
|
const auto &bufs = Buffers();
|
||||||
|
if (bufs.empty() || curbuf_ >= bufs.size()) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
return &buffers_[curbuf_];
|
return &bufs[curbuf_];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -78,8 +152,9 @@ Editor::DisplayNameFor(const Buffer &buf) const
|
|||||||
|
|
||||||
// Prepare list of other buffer paths
|
// Prepare list of other buffer paths
|
||||||
std::vector<std::vector<std::filesystem::path> > others;
|
std::vector<std::vector<std::filesystem::path> > others;
|
||||||
others.reserve(buffers_.size());
|
const auto &bufs = Buffers();
|
||||||
for (const auto &b: buffers_) {
|
others.reserve(bufs.size());
|
||||||
|
for (const auto &b: bufs) {
|
||||||
if (&b == &buf)
|
if (&b == &buf)
|
||||||
continue;
|
continue;
|
||||||
if (b.Filename().empty())
|
if (b.Filename().empty())
|
||||||
@@ -122,61 +197,96 @@ Editor::DisplayNameFor(const Buffer &buf) const
|
|||||||
std::size_t
|
std::size_t
|
||||||
Editor::AddBuffer(const Buffer &buf)
|
Editor::AddBuffer(const Buffer &buf)
|
||||||
{
|
{
|
||||||
buffers_.push_back(buf);
|
auto &bufs = Buffers();
|
||||||
if (buffers_.size() == 1) {
|
// 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;
|
curbuf_ = 0;
|
||||||
}
|
}
|
||||||
return buffers_.size() - 1;
|
return bufs.size() - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
std::size_t
|
std::size_t
|
||||||
Editor::AddBuffer(Buffer &&buf)
|
Editor::AddBuffer(Buffer &&buf)
|
||||||
{
|
{
|
||||||
buffers_.push_back(std::move(buf));
|
auto &bufs = Buffers();
|
||||||
if (buffers_.size() == 1) {
|
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;
|
curbuf_ = 0;
|
||||||
}
|
}
|
||||||
return buffers_.size() - 1;
|
return bufs.size() - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
Editor::OpenFile(const std::string &path, std::string &err)
|
Editor::OpenFile(const std::string &path, std::string &err)
|
||||||
{
|
{
|
||||||
// If there is exactly one unnamed, empty, clean buffer, reuse it instead
|
// A file that is already open is switched to, not opened again: two
|
||||||
// of creating a new one.
|
// buffers for one file would share (and corrupt) one swap journal, and
|
||||||
if (buffers_.size() == 1) {
|
// closing either would delete the other's journal.
|
||||||
Buffer &cur = buffers_[curbuf_];
|
if (const std::size_t open_idx = FindOpenBuffer(path); open_idx != static_cast<std::size_t>(-1)) {
|
||||||
|
SwitchTo(open_idx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the current buffer is an unnamed, empty, clean scratch buffer, reuse
|
||||||
|
// it instead of creating a new one.
|
||||||
|
auto &bufs_ref = Buffers();
|
||||||
|
if (!bufs_ref.empty() && curbuf_ < bufs_ref.size()) {
|
||||||
|
Buffer &cur = bufs_ref[curbuf_];
|
||||||
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
||||||
const bool clean = !cur.Dirty();
|
const bool clean = !cur.Dirty();
|
||||||
const auto &rows = cur.Rows();
|
const std::size_t nrows = cur.Nrows();
|
||||||
const bool rows_empty = rows.empty();
|
const bool rows_empty = (nrows == 0);
|
||||||
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0);
|
const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
|
||||||
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
||||||
bool ok = cur.OpenFromFile(path, err);
|
bool ok = cur.OpenFromFile(path, err);
|
||||||
if (!ok) return false;
|
if (!ok)
|
||||||
// Setup highlighting using registry (extension + shebang)
|
return false;
|
||||||
cur.EnsureHighlighter();
|
// Ensure swap recorder is attached for this buffer
|
||||||
std::string first = "";
|
if (Swap()) {
|
||||||
const auto &rows = cur.Rows();
|
Swap()->Attach(&cur);
|
||||||
if (!rows.empty()) first = static_cast<std::string>(rows[0]);
|
cur.SetSwapRecorder(Swap()->RecorderFor(&cur));
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
Swap()->NotifyFilenameChanged(cur);
|
||||||
if (!ft.empty()) {
|
|
||||||
cur.SetFiletype(ft);
|
|
||||||
cur.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = cur.Highlighter()) {
|
|
||||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cur.SetFiletype("");
|
|
||||||
cur.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = cur.Highlighter()) {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Highlighting by extension or shebang
|
||||||
|
cur.ApplyDetectedFiletype();
|
||||||
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
|
CancelPrompt();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,43 +295,323 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
if (!b.OpenFromFile(path, err)) {
|
if (!b.OpenFromFile(path, err)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
||||||
b.EnsureHighlighter();
|
// final location (vector) because swap manager keys off Buffer*.
|
||||||
std::string first = "";
|
// Highlighting by extension or shebang
|
||||||
{
|
b.ApplyDetectedFiletype();
|
||||||
const auto &rows = b.Rows();
|
|
||||||
if (!rows.empty()) first = static_cast<std::string>(rows[0]);
|
|
||||||
}
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
|
||||||
if (!ft.empty()) {
|
|
||||||
b.SetFiletype(ft);
|
|
||||||
b.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = b.Highlighter()) {
|
|
||||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.SetFiletype("");
|
|
||||||
b.SetSyntaxEnabled(true);
|
|
||||||
if (auto *eng = b.Highlighter()) {
|
|
||||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
|
||||||
eng->InvalidateFrom(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add as a new buffer and switch to it
|
// Add as a new buffer and switch to it
|
||||||
std::size_t idx = AddBuffer(std::move(b));
|
std::size_t idx = AddBuffer(std::move(b));
|
||||||
|
if (Swap()) {
|
||||||
|
Swap()->NotifyFilenameChanged(Buffers()[idx]);
|
||||||
|
}
|
||||||
SwitchTo(idx);
|
SwitchTo(idx);
|
||||||
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
|
CancelPrompt();
|
||||||
return true;
|
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
|
bool
|
||||||
Editor::SwitchTo(std::size_t index)
|
Editor::SwitchTo(std::size_t index)
|
||||||
{
|
{
|
||||||
if (index >= buffers_.size()) {
|
auto &bufs = Buffers();
|
||||||
|
if (index >= bufs.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
curbuf_ = index;
|
curbuf_ = index;
|
||||||
|
// Robustness: ensure a valid highlighter is installed when switching buffers
|
||||||
|
Buffer &b = bufs[curbuf_];
|
||||||
|
if (b.SyntaxEnabled() && !(b.Highlighter() && b.Highlighter()->HasHighlighter()))
|
||||||
|
b.InstallFiletypeHighlighter();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,14 +619,36 @@ Editor::SwitchTo(std::size_t index)
|
|||||||
bool
|
bool
|
||||||
Editor::CloseBuffer(std::size_t index)
|
Editor::CloseBuffer(std::size_t index)
|
||||||
{
|
{
|
||||||
if (index >= buffers_.size()) {
|
auto &bufs = Buffers();
|
||||||
|
if (index >= bufs.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
if (Swap()) {
|
||||||
if (buffers_.empty()) {
|
// 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;
|
curbuf_ = 0;
|
||||||
} else if (curbuf_ >= buffers_.size()) {
|
} else if (curbuf_ >= bufs.size()) {
|
||||||
curbuf_ = buffers_.size() - 1;
|
curbuf_ = bufs.size() - 1;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -246,16 +658,76 @@ void
|
|||||||
Editor::Reset()
|
Editor::Reset()
|
||||||
{
|
{
|
||||||
rows_ = cols_ = 0;
|
rows_ = cols_ = 0;
|
||||||
mode_ = 0;
|
|
||||||
kill_ = 0;
|
kill_ = 0;
|
||||||
no_kill_ = 0;
|
|
||||||
dirtyex_ = 0;
|
|
||||||
msg_.clear();
|
msg_.clear();
|
||||||
msgtm_ = 0;
|
msgtm_ = 0;
|
||||||
uarg_ = 0;
|
uarg_ = 0;
|
||||||
ucount_ = 0;
|
ucount_ = 0;
|
||||||
quit_requested_ = false;
|
quit_requested_ = false;
|
||||||
quit_confirm_pending_ = 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;
|
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,15 +1,52 @@
|
|||||||
/*
|
/*
|
||||||
* Editor.h - top-level editor state and buffer management
|
* Editor.h - top-level editor state and buffer management
|
||||||
|
*
|
||||||
|
* Editor is the top-level coordinator in kte. It manages:
|
||||||
|
*
|
||||||
|
* - Buffer collection: Multiple open documents (buffers_), current buffer selection
|
||||||
|
* - UI state: Dimensions, status messages, prompts, search state
|
||||||
|
* - Kill ring: Shared clipboard for cut/copy/paste operations across buffers
|
||||||
|
* - Universal argument: Repeat count mechanism (C-u)
|
||||||
|
* - Mode flags: Editor modes (normal, k-command, search, prompt, etc.)
|
||||||
|
* - Swap/crash recovery: SwapManager integration for journaling
|
||||||
|
* - File operations: Opening files, managing pending opens, recovery prompts
|
||||||
|
*
|
||||||
|
* Key responsibilities:
|
||||||
|
*
|
||||||
|
* 1. Buffer lifecycle:
|
||||||
|
* - AddBuffer(): Add new buffers to the collection
|
||||||
|
* - OpenFile(): Load files into buffers
|
||||||
|
* - SwitchTo(): Change active buffer
|
||||||
|
* - CloseBuffer(): Remove buffers with dirty checks
|
||||||
|
*
|
||||||
|
* 2. UI coordination:
|
||||||
|
* - SetDimensions(): Terminal/window size for viewport calculations
|
||||||
|
* - SetStatus(): Status line messages with timestamps
|
||||||
|
* - Prompt system: Multi-step prompts for file open, buffer switch, etc.
|
||||||
|
* - Search state: Active search, query, match position, origin tracking
|
||||||
|
*
|
||||||
|
* 3. Shared editor state:
|
||||||
|
* - Kill ring: Circular buffer of killed text (max 60 entries)
|
||||||
|
* - Universal argument: C-u digit collection for command repetition
|
||||||
|
* - Mode tracking: Current input mode (normal, k-command, ESC, prompt)
|
||||||
|
*
|
||||||
|
* 4. Integration points:
|
||||||
|
* - Commands operate on Editor and current Buffer
|
||||||
|
* - Frontend (Terminal/GUI) queries Editor for rendering
|
||||||
|
* - SwapManager journals all buffer modifications
|
||||||
|
*
|
||||||
|
* Design note: Editor owns the buffer collection but doesn't directly edit content.
|
||||||
|
* Commands modify buffers through Buffer's API, and Editor coordinates the UI state.
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_EDITOR_H
|
#pragma once
|
||||||
#define KTE_EDITOR_H
|
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <deque>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
|
#include "Swap.h"
|
||||||
|
|
||||||
|
|
||||||
class Editor {
|
class Editor {
|
||||||
@@ -32,16 +69,13 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Mode and flags (mirroring legacy fields)
|
[[nodiscard]] std::size_t ContentRows() const
|
||||||
void SetMode(int m)
|
|
||||||
{
|
{
|
||||||
mode_ = m;
|
// Always compute from current rows_ to avoid stale values.
|
||||||
}
|
// Reserve 1 row for status line.
|
||||||
|
if (rows_ == 0)
|
||||||
|
return 1;
|
||||||
[[nodiscard]] int Mode() const
|
return std::max<std::size_t>(1, rows_ - 1);
|
||||||
{
|
|
||||||
return mode_;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -57,18 +91,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetNoKill(bool on)
|
|
||||||
{
|
|
||||||
no_kill_ = on ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] bool NoKill() const
|
|
||||||
{
|
|
||||||
return no_kill_ != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Kill ring API
|
// Kill ring API
|
||||||
void KillRingClear()
|
void KillRingClear()
|
||||||
{
|
{
|
||||||
@@ -117,18 +139,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetDirtyEx(int d)
|
|
||||||
{
|
|
||||||
dirtyex_ = d;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int DirtyEx() const
|
|
||||||
{
|
|
||||||
return dirtyex_;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void SetUniversalArg(int uarg, int ucount)
|
void SetUniversalArg(int uarg, int ucount)
|
||||||
{
|
{
|
||||||
uarg_ = uarg;
|
uarg_ = uarg;
|
||||||
@@ -142,11 +152,19 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] int UCount() const
|
// --- Universal argument control (C-u) ---
|
||||||
{
|
// Begin or extend a universal argument (like ke's uarg_start)
|
||||||
return ucount_;
|
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
|
// Status message storage. Rendering is renderer-dependent; the editor
|
||||||
// merely stores the current message and its timestamp.
|
// merely stores the current message and its timestamp.
|
||||||
@@ -172,6 +190,18 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void SetNewWindowRequested(bool on)
|
||||||
|
{
|
||||||
|
new_window_requested_ = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool NewWindowRequested() const
|
||||||
|
{
|
||||||
|
return new_window_requested_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void SetQuitConfirmPending(bool on)
|
void SetQuitConfirmPending(bool on)
|
||||||
{
|
{
|
||||||
quit_confirm_pending_ = on;
|
quit_confirm_pending_ = on;
|
||||||
@@ -184,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
|
[[nodiscard]] std::time_t StatusTime() const
|
||||||
{
|
{
|
||||||
return msgtm_;
|
return msgtm_;
|
||||||
@@ -410,7 +465,7 @@ public:
|
|||||||
// Buffers
|
// Buffers
|
||||||
[[nodiscard]] std::size_t BufferCount() const
|
[[nodiscard]] std::size_t BufferCount() const
|
||||||
{
|
{
|
||||||
return buffers_.size();
|
return Buffers().size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -420,6 +475,19 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Clamp curbuf_ to valid range. Call when the shared buffer list may
|
||||||
|
// have been modified by another editor (e.g., buffer closed in another window).
|
||||||
|
void ValidateBufferIndex()
|
||||||
|
{
|
||||||
|
const auto &bufs = Buffers();
|
||||||
|
if (bufs.empty()) {
|
||||||
|
curbuf_ = 0;
|
||||||
|
} else if (curbuf_ >= bufs.size()) {
|
||||||
|
curbuf_ = bufs.size() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Buffer *CurrentBuffer();
|
Buffer *CurrentBuffer();
|
||||||
|
|
||||||
const Buffer *CurrentBuffer() const;
|
const Buffer *CurrentBuffer() const;
|
||||||
@@ -436,6 +504,35 @@ public:
|
|||||||
|
|
||||||
bool OpenFile(const std::string &path, std::string &err);
|
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
|
// Buffer switching/closing
|
||||||
bool SwitchTo(std::size_t index);
|
bool SwitchTo(std::size_t index);
|
||||||
|
|
||||||
@@ -447,13 +544,38 @@ public:
|
|||||||
// Direct access when needed (try to prefer methods above)
|
// Direct access when needed (try to prefer methods above)
|
||||||
[[nodiscard]] const std::vector<Buffer> &Buffers() const
|
[[nodiscard]] const std::vector<Buffer> &Buffers() const
|
||||||
{
|
{
|
||||||
return buffers_;
|
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
std::vector<Buffer> &Buffers()
|
std::vector<Buffer> &Buffers()
|
||||||
{
|
{
|
||||||
return buffers_;
|
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Share another editor's buffer list. When set, this editor operates on
|
||||||
|
// the provided vector instead of its own. Pass nullptr to detach.
|
||||||
|
// Buffers hold recorders owned by the SwapManager that attached them, so
|
||||||
|
// editors sharing buffers must also share that manager (shared_swap);
|
||||||
|
// otherwise closing one window destroys recorders the other still uses.
|
||||||
|
// Index of the open buffer whose file is `path` (after the same
|
||||||
|
// normalization OpenFromFile applies), or size_t(-1) if none.
|
||||||
|
[[nodiscard]] std::size_t FindOpenBuffer(const std::string &path) const;
|
||||||
|
|
||||||
|
|
||||||
|
void SetSharedBuffers(std::vector<Buffer> *shared, kte::SwapManager *shared_swap = nullptr)
|
||||||
|
{
|
||||||
|
shared_buffers_ = shared;
|
||||||
|
shared_swap_ = shared ? shared_swap : nullptr;
|
||||||
|
curbuf_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Swap manager access (for advanced integrations/tests)
|
||||||
|
[[nodiscard]] kte::SwapManager *Swap()
|
||||||
|
{
|
||||||
|
return shared_swap_ ? shared_swap_ : swap_.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -482,25 +604,35 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct PendingOpen {
|
||||||
|
std::string path;
|
||||||
|
std::size_t line1{0}; // 1-based; 0 = none
|
||||||
|
};
|
||||||
|
|
||||||
std::size_t rows_ = 0, cols_ = 0;
|
std::size_t rows_ = 0, cols_ = 0;
|
||||||
int mode_ = 0;
|
|
||||||
int kill_ = 0; // KILL CHAIN
|
int kill_ = 0; // KILL CHAIN
|
||||||
int no_kill_ = 0; // don't kill in delete_row
|
|
||||||
int dirtyex_ = 0;
|
|
||||||
std::string msg_;
|
std::string msg_;
|
||||||
std::time_t msgtm_ = 0;
|
std::time_t msgtm_ = 0;
|
||||||
int uarg_ = 0, ucount_ = 0; // C-u support
|
int uarg_ = 0, ucount_ = 0; // C-u support
|
||||||
|
|
||||||
std::vector<Buffer> buffers_;
|
std::vector<Buffer> buffers_;
|
||||||
|
std::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of buffers_
|
||||||
std::size_t curbuf_ = 0; // index into buffers_
|
std::size_t curbuf_ = 0; // index into buffers_
|
||||||
|
|
||||||
|
// Swap journaling manager (lifetime = editor)
|
||||||
|
std::unique_ptr<kte::SwapManager> swap_;
|
||||||
|
kte::SwapManager *shared_swap_ = nullptr; // set with shared_buffers_; not owned
|
||||||
|
|
||||||
// Kill ring (Emacs-like)
|
// Kill ring (Emacs-like)
|
||||||
std::vector<std::string> kill_ring_;
|
std::vector<std::string> kill_ring_;
|
||||||
std::size_t kill_ring_max_ = 60;
|
std::size_t kill_ring_max_ = 60;
|
||||||
|
|
||||||
// Quit state
|
// Quit state
|
||||||
bool quit_requested_ = false;
|
bool quit_requested_ = false;
|
||||||
|
bool new_window_requested_ = false;
|
||||||
bool quit_confirm_pending_ = false;
|
bool quit_confirm_pending_ = false;
|
||||||
|
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close
|
||||||
|
bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
bool search_active_ = false;
|
bool search_active_ = false;
|
||||||
@@ -519,6 +651,19 @@ private:
|
|||||||
std::string prompt_text_;
|
std::string prompt_text_;
|
||||||
std::string pending_overwrite_path_;
|
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)
|
// GUI-only state (safe no-op in terminal builds)
|
||||||
bool file_picker_visible_ = false;
|
bool file_picker_visible_ = false;
|
||||||
std::string file_picker_dir_;
|
std::string file_picker_dir_;
|
||||||
@@ -552,5 +697,3 @@ private:
|
|||||||
std::string replace_find_tmp_;
|
std::string replace_find_tmp_;
|
||||||
std::string replace_with_tmp_;
|
std::string replace_with_tmp_;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_EDITOR_H
|
|
||||||
|
|||||||
+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
-6
@@ -1,9 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* Frontend.h - top-level container that couples Input + Renderer and runs the loop
|
* Frontend.h - top-level container that couples Input + Renderer and runs the loop
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_FRONTEND_H
|
#pragma once
|
||||||
#define KTE_FRONTEND_H
|
|
||||||
|
|
||||||
|
|
||||||
class Editor;
|
class Editor;
|
||||||
class InputHandler;
|
class InputHandler;
|
||||||
@@ -14,7 +12,7 @@ public:
|
|||||||
virtual ~Frontend() = default;
|
virtual ~Frontend() = default;
|
||||||
|
|
||||||
// Initialize the frontend (create window/terminal, etc.)
|
// 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.
|
// Execute one iteration (poll input, dispatch, draw). Set running=false to exit.
|
||||||
virtual void Step(Editor &ed, bool &running) = 0;
|
virtual void Step(Editor &ed, bool &running) = 0;
|
||||||
@@ -22,5 +20,3 @@ public:
|
|||||||
// Shutdown/cleanup
|
// Shutdown/cleanup
|
||||||
virtual void Shutdown() = 0;
|
virtual void Shutdown() = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_FRONTEND_H
|
|
||||||
|
|||||||
+153
-12
@@ -1,11 +1,49 @@
|
|||||||
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
#include "GUIConfig.h"
|
#include "GUIConfig.h"
|
||||||
|
|
||||||
|
// toml++ for TOML config parsing
|
||||||
|
#if defined(__clang__)
|
||||||
|
# pragma clang diagnostic push
|
||||||
|
# pragma clang diagnostic ignored "-Weverything"
|
||||||
|
#elif defined(__GNUC__)
|
||||||
|
# pragma GCC diagnostic push
|
||||||
|
# pragma GCC diagnostic ignored "-Wall"
|
||||||
|
# pragma GCC diagnostic ignored "-Wextra"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "ext/tomlplusplus/toml.hpp"
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(__clang__)
|
||||||
|
# pragma clang diagnostic pop
|
||||||
|
#elif defined(__GNUC__)
|
||||||
|
# pragma GCC diagnostic pop
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
// Reject values that would overflow later arithmetic (columns * font size)
|
||||||
|
// or that are not usable: "size = inf" is valid TOML.
|
||||||
|
static bool
|
||||||
|
valid_dimension(long long v)
|
||||||
|
{
|
||||||
|
return v > 0 && v <= 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
valid_font_size(double v)
|
||||||
|
{
|
||||||
|
return std::isfinite(v) && v >= 4.0 && v <= 200.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
static void
|
static void
|
||||||
trim(std::string &s)
|
trim(std::string &s)
|
||||||
@@ -19,37 +57,124 @@ trim(std::string &s)
|
|||||||
|
|
||||||
|
|
||||||
static std::string
|
static std::string
|
||||||
default_config_path()
|
config_dir()
|
||||||
{
|
{
|
||||||
const char *home = std::getenv("HOME");
|
const char *home = std::getenv("HOME");
|
||||||
if (!home || !*home)
|
if (!home || !*home)
|
||||||
return {};
|
return {};
|
||||||
std::string path(home);
|
return std::string(home) + "/.config/kte";
|
||||||
path += "/.config/kte/kge.ini";
|
|
||||||
return path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
GUIConfig
|
GUIConfig
|
||||||
GUIConfig::Load()
|
GUIConfig::Load()
|
||||||
{
|
{
|
||||||
GUIConfig cfg; // defaults already set
|
GUIConfig cfg;
|
||||||
const std::string path = default_config_path();
|
std::string dir = config_dir();
|
||||||
|
if (dir.empty())
|
||||||
|
return cfg;
|
||||||
|
|
||||||
if (!path.empty()) {
|
// Try TOML first
|
||||||
cfg.LoadFromFile(path);
|
std::string toml_path = dir + "/kge.toml";
|
||||||
|
if (cfg.LoadFromTOML(toml_path))
|
||||||
|
return cfg;
|
||||||
|
|
||||||
|
// Fall back to legacy INI
|
||||||
|
std::string ini_path = dir + "/kge.ini";
|
||||||
|
if (cfg.LoadFromINI(ini_path)) {
|
||||||
|
std::cerr << "kge: loaded legacy kge.ini; consider migrating to kge.toml\n";
|
||||||
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
GUIConfig::LoadFromFile(const std::string &path)
|
GUIConfig::LoadFromTOML(const std::string &path)
|
||||||
|
{
|
||||||
|
if (!std::filesystem::exists(path))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
toml::table tbl;
|
||||||
|
try {
|
||||||
|
tbl = toml::parse_file(path);
|
||||||
|
} catch (const toml::parse_error &err) {
|
||||||
|
std::cerr << "kge: TOML parse error in " << path << ": " << err.what() << "\n";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [window]
|
||||||
|
if (auto win = tbl["window"].as_table()) {
|
||||||
|
if (auto v = (*win)["fullscreen"].value<bool>())
|
||||||
|
fullscreen = *v;
|
||||||
|
if (auto v = (*win)["columns"].value<int64_t>()) {
|
||||||
|
if (valid_dimension(*v)) columns = static_cast<int>(*v);
|
||||||
|
}
|
||||||
|
if (auto v = (*win)["rows"].value<int64_t>()) {
|
||||||
|
if (valid_dimension(*v)) rows = static_cast<int>(*v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [font]
|
||||||
|
bool explicit_code_font = false;
|
||||||
|
bool explicit_writing_font = false;
|
||||||
|
if (auto sec = tbl["font"].as_table()) {
|
||||||
|
if (auto v = (*sec)["name"].value<std::string>())
|
||||||
|
font = *v;
|
||||||
|
if (auto v = (*sec)["size"].value<double>()) {
|
||||||
|
if (valid_font_size(*v)) font_size = static_cast<float>(*v);
|
||||||
|
}
|
||||||
|
if (auto v = (*sec)["code"].value<std::string>()) {
|
||||||
|
code_font = *v;
|
||||||
|
explicit_code_font = true;
|
||||||
|
}
|
||||||
|
if (auto v = (*sec)["writing"].value<std::string>()) {
|
||||||
|
writing_font = *v;
|
||||||
|
explicit_writing_font = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [appearance]
|
||||||
|
if (auto sec = tbl["appearance"].as_table()) {
|
||||||
|
if (auto v = (*sec)["theme"].value<std::string>())
|
||||||
|
theme = *v;
|
||||||
|
if (auto v = (*sec)["background"].value<std::string>()) {
|
||||||
|
std::string bg = *v;
|
||||||
|
std::transform(bg.begin(), bg.end(), bg.begin(), [](unsigned char c) {
|
||||||
|
return (char) std::tolower(c);
|
||||||
|
});
|
||||||
|
if (bg == "light" || bg == "dark")
|
||||||
|
background = bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [editor]
|
||||||
|
if (auto sec = tbl["editor"].as_table()) {
|
||||||
|
if (auto v = (*sec)["syntax"].value<bool>())
|
||||||
|
syntax = *v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default code_font to the main font if not explicitly set
|
||||||
|
if (!explicit_code_font)
|
||||||
|
code_font = font;
|
||||||
|
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
|
||||||
|
writing_font = font;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
GUIConfig::LoadFromINI(const std::string &path)
|
||||||
{
|
{
|
||||||
std::ifstream in(path);
|
std::ifstream in(path);
|
||||||
if (!in.good())
|
if (!in.good())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
bool explicit_code_font = false;
|
||||||
|
bool explicit_writing_font = false;
|
||||||
|
|
||||||
std::string line;
|
std::string line;
|
||||||
while (std::getline(in, line)) {
|
while (std::getline(in, line)) {
|
||||||
// Remove comments starting with '#' or ';'
|
// Remove comments starting with '#' or ';'
|
||||||
@@ -85,23 +210,31 @@ GUIConfig::LoadFromFile(const std::string &path)
|
|||||||
try {
|
try {
|
||||||
v = std::stoi(val);
|
v = std::stoi(val);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
if (v > 0)
|
if (valid_dimension(v))
|
||||||
columns = v;
|
columns = v;
|
||||||
} else if (key == "rows") {
|
} else if (key == "rows") {
|
||||||
int v = rows;
|
int v = rows;
|
||||||
try {
|
try {
|
||||||
v = std::stoi(val);
|
v = std::stoi(val);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
if (v > 0)
|
if (valid_dimension(v))
|
||||||
rows = v;
|
rows = v;
|
||||||
} else if (key == "font_size" || key == "fontsize") {
|
} else if (key == "font_size" || key == "fontsize") {
|
||||||
float v = font_size;
|
float v = font_size;
|
||||||
try {
|
try {
|
||||||
v = std::stof(val);
|
v = std::stof(val);
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
if (v > 0.0f) {
|
if (valid_font_size(v)) {
|
||||||
font_size = v;
|
font_size = v;
|
||||||
}
|
}
|
||||||
|
} else if (key == "font") {
|
||||||
|
font = val;
|
||||||
|
} else if (key == "code_font") {
|
||||||
|
code_font = val;
|
||||||
|
explicit_code_font = true;
|
||||||
|
} else if (key == "writing_font") {
|
||||||
|
writing_font = val;
|
||||||
|
explicit_writing_font = true;
|
||||||
} else if (key == "theme") {
|
} else if (key == "theme") {
|
||||||
theme = val;
|
theme = val;
|
||||||
} else if (key == "background" || key == "bg") {
|
} else if (key == "background" || key == "bg") {
|
||||||
@@ -124,5 +257,13 @@ GUIConfig::LoadFromFile(const std::string &path)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If code_font was not explicitly set, default it to the main font
|
||||||
|
// so that the edit-mode font switcher doesn't immediately switch away
|
||||||
|
// from the font loaded during Init.
|
||||||
|
if (!explicit_code_font)
|
||||||
|
code_font = font;
|
||||||
|
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
|
||||||
|
writing_font = font;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-10
@@ -1,8 +1,9 @@
|
|||||||
/*
|
/*
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_GUI_CONFIG_H
|
#pragma once
|
||||||
#define KTE_GUI_CONFIG_H
|
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -16,20 +17,25 @@ public:
|
|||||||
int columns = 80;
|
int columns = 80;
|
||||||
int rows = 42;
|
int rows = 42;
|
||||||
float font_size = (float) KTE_FONT_SIZE;
|
float font_size = (float) KTE_FONT_SIZE;
|
||||||
|
std::string font = "default";
|
||||||
std::string theme = "nord";
|
std::string theme = "nord";
|
||||||
// Background mode for themes that support light/dark variants
|
// Background mode for themes that support light/dark variants
|
||||||
// Values: "dark" (default), "light"
|
// Values: "dark" (default), "light"
|
||||||
std::string background = "dark";
|
std::string background = "dark";
|
||||||
|
|
||||||
// Default syntax highlighting state for GUI (kge): on/off
|
// Default syntax highlighting state for GUI (kge): on/off
|
||||||
// Accepts: on/off/true/false/yes/no/1/0 in the ini file.
|
bool syntax = true;
|
||||||
bool syntax = true; // default: enabled
|
|
||||||
|
|
||||||
// Load from default path: $HOME/.config/kte/kge.ini
|
// Per-mode font defaults
|
||||||
|
std::string code_font = "default";
|
||||||
|
std::string writing_font = "crimsonpro";
|
||||||
|
|
||||||
|
// Load from default paths: try kge.toml first, fall back to kge.ini
|
||||||
static GUIConfig Load();
|
static GUIConfig Load();
|
||||||
|
|
||||||
// Load from explicit path. Returns true if file existed and was parsed.
|
// Load from explicit TOML path. Returns true if file existed and was parsed.
|
||||||
bool LoadFromFile(const std::string &path);
|
bool LoadFromTOML(const std::string &path);
|
||||||
};
|
|
||||||
|
|
||||||
#endif // KTE_GUI_CONFIG_H
|
// Load from explicit INI path (legacy). Returns true if file existed and was parsed.
|
||||||
|
bool LoadFromINI(const std::string &path);
|
||||||
|
};
|
||||||
|
|||||||
-319
@@ -1,319 +0,0 @@
|
|||||||
#include <cstdio>
|
|
||||||
#include <string>
|
|
||||||
#include <cstring>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
#include <SDL.h>
|
|
||||||
#include <SDL_opengl.h>
|
|
||||||
#include <imgui.h>
|
|
||||||
#include <backends/imgui_impl_sdl2.h>
|
|
||||||
#include <backends/imgui_impl_opengl3.h>
|
|
||||||
|
|
||||||
#include "Editor.h"
|
|
||||||
#include "Command.h"
|
|
||||||
#include "GUIFrontend.h"
|
|
||||||
#include "Font.h" // embedded default font (DefaultFontRegular)
|
|
||||||
#include "GUIConfig.h"
|
|
||||||
#include "GUITheme.h"
|
|
||||||
#include "HighlighterRegistry.h"
|
|
||||||
#include "NullHighlighter.h"
|
|
||||||
|
|
||||||
|
|
||||||
#ifndef KTE_FONT_SIZE
|
|
||||||
#define KTE_FONT_SIZE 16.0f
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static const char *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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
#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();
|
|
||||||
(void) io;
|
|
||||||
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
|
|
||||||
|
|
||||||
// Initialize GUI font from embedded default (use configured size or compiled default)
|
|
||||||
LoadGuiFont_(nullptr, (float) cfg.font_size);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute pending mapped inputs (drain queue)
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start a new ImGui frame
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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*/, float size_px)
|
|
||||||
{
|
|
||||||
const ImGuiIO &io = ImGui::GetIO();
|
|
||||||
io.Fonts->Clear();
|
|
||||||
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
|
|
||||||
DefaultFontBoldCompressedData,
|
|
||||||
DefaultFontBoldCompressedSize,
|
|
||||||
size_px);
|
|
||||||
if (!font) {
|
|
||||||
font = io.Fonts->AddFontDefault();
|
|
||||||
}
|
|
||||||
(void) font;
|
|
||||||
io.Fonts->Build();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// No runtime font reload or system font resolution in this simplified build.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
/*
|
|
||||||
* GUIFrontend - couples GUIInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
|
|
||||||
*/
|
|
||||||
#ifndef KTE_GUI_FRONTEND_H
|
|
||||||
#define KTE_GUI_FRONTEND_H
|
|
||||||
|
|
||||||
#include "Frontend.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);
|
|
||||||
|
|
||||||
GUIInputHandler input_{};
|
|
||||||
GUIRenderer renderer_{};
|
|
||||||
SDL_Window *window_ = nullptr;
|
|
||||||
SDL_GLContext gl_ctx_ = nullptr;
|
|
||||||
int width_ = 1280;
|
|
||||||
int height_ = 800;
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif // KTE_GUI_FRONTEND_H
|
|
||||||
-758
@@ -1,758 +0,0 @@
|
|||||||
#include <algorithm>
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <limits>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include <imgui.h>
|
|
||||||
#include <regex>
|
|
||||||
|
|
||||||
#include "GUIRenderer.h"
|
|
||||||
#include "Highlight.h"
|
|
||||||
#include "GUITheme.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include "Command.h"
|
|
||||||
#include "Editor.h"
|
|
||||||
|
|
||||||
|
|
||||||
// Version string expected to be provided by build system as KTE_VERSION_STR
|
|
||||||
#ifndef KTE_VERSION_STR
|
|
||||||
# define KTE_VERSION_STR "dev"
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// ImGui compatibility: some bundled ImGui versions (or builds without docking)
|
|
||||||
// don't define ImGuiWindowFlags_NoDocking. Treat it as 0 in that case.
|
|
||||||
#ifndef ImGuiWindowFlags_NoDocking
|
|
||||||
# define ImGuiWindowFlags_NoDocking 0
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
void
|
|
||||||
GUIRenderer::Draw(Editor &ed)
|
|
||||||
{
|
|
||||||
// Make the editor window occupy the entire GUI container/viewport
|
|
||||||
ImGuiViewport *vp = ImGui::GetMainViewport();
|
|
||||||
// On HiDPI/Retina, snap to integer pixels to prevent any draw vs hit-test
|
|
||||||
// mismatches that can appear on the very first maximized frame.
|
|
||||||
ImVec2 main_pos = vp->Pos;
|
|
||||||
ImVec2 main_sz = vp->Size;
|
|
||||||
main_pos.x = std::floor(main_pos.x + 0.5f);
|
|
||||||
main_pos.y = std::floor(main_pos.y + 0.5f);
|
|
||||||
main_sz.x = std::floor(main_sz.x + 0.5f);
|
|
||||||
main_sz.y = std::floor(main_sz.y + 0.5f);
|
|
||||||
ImGui::SetNextWindowPos(main_pos);
|
|
||||||
ImGui::SetNextWindowSize(main_sz);
|
|
||||||
|
|
||||||
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar
|
|
||||||
| ImGuiWindowFlags_NoScrollbar
|
|
||||||
| ImGuiWindowFlags_NoScrollWithMouse
|
|
||||||
| ImGuiWindowFlags_NoResize
|
|
||||||
| ImGuiWindowFlags_NoMove
|
|
||||||
| ImGuiWindowFlags_NoCollapse
|
|
||||||
| ImGuiWindowFlags_NoSavedSettings
|
|
||||||
| ImGuiWindowFlags_NoBringToFrontOnFocus
|
|
||||||
| ImGuiWindowFlags_NoNavFocus;
|
|
||||||
|
|
||||||
// Reduce padding so the buffer content uses the whole area
|
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
|
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 6.f));
|
|
||||||
|
|
||||||
ImGui::Begin("kte", nullptr, flags);
|
|
||||||
|
|
||||||
const Buffer *buf = ed.CurrentBuffer();
|
|
||||||
if (!buf) {
|
|
||||||
ImGui::TextUnformatted("[no buffer]");
|
|
||||||
} else {
|
|
||||||
const auto &lines = buf->Rows();
|
|
||||||
// Reserve space for status bar at bottom
|
|
||||||
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false,
|
|
||||||
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
|
||||||
// Detect click-to-move inside this scroll region
|
|
||||||
ImVec2 list_origin = ImGui::GetCursorScreenPos();
|
|
||||||
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
|
|
||||||
std::size_t cy = buf->Cury();
|
|
||||||
std::size_t cx = buf->Curx();
|
|
||||||
const float line_h = ImGui::GetTextLineHeight();
|
|
||||||
const float row_h = ImGui::GetTextLineHeightWithSpacing();
|
|
||||||
const float space_w = ImGui::CalcTextSize(" ").x;
|
|
||||||
// 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.
|
|
||||||
// This prevents clicks/wheel from being immediately overridden by stale offsets.
|
|
||||||
bool forced_scroll = false;
|
|
||||||
{
|
|
||||||
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
|
|
||||||
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
|
|
||||||
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 buf_rowoffs = static_cast<long>(buf->Rowoffs());
|
|
||||||
const long buf_coloffs = static_cast<long>(buf->Coloffs());
|
|
||||||
const long scroll_top = static_cast<long>(scroll_y / row_h);
|
|
||||||
const long scroll_left = static_cast<long>(scroll_x / space_w);
|
|
||||||
|
|
||||||
// Detect programmatic change (e.g., keyboard navigation ensured visibility)
|
|
||||||
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
|
|
||||||
ImGui::SetScrollY(static_cast<float>(buf_rowoffs) * row_h);
|
|
||||||
scroll_y = ImGui::GetScrollY();
|
|
||||||
forced_scroll = true;
|
|
||||||
}
|
|
||||||
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) {
|
|
||||||
ImGui::SetScrollX(static_cast<float>(buf_coloffs) * space_w);
|
|
||||||
scroll_x = ImGui::GetScrollX();
|
|
||||||
forced_scroll = true;
|
|
||||||
}
|
|
||||||
// If user scrolled, update buffer offsets accordingly
|
|
||||||
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y) {
|
|
||||||
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) {
|
|
||||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
|
||||||
mbuf->SetOffsets(mbuf->Rowoffs(),
|
|
||||||
static_cast<std::size_t>(std::max(0L, scroll_left)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update trackers for next frame
|
|
||||||
prev_buf_rowoffs = static_cast<long>(buf->Rowoffs());
|
|
||||||
prev_buf_coloffs = static_cast<long>(buf->Coloffs());
|
|
||||||
prev_scroll_y = ImGui::GetScrollY();
|
|
||||||
prev_scroll_x = ImGui::GetScrollX();
|
|
||||||
}
|
|
||||||
// Synchronize cursor and scrolling.
|
|
||||||
// Ensure the cursor is visible even on the first frame or when it didn't move,
|
|
||||||
// unless we already forced scrolling from Buffer::Rowoffs this frame.
|
|
||||||
{
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
if (!forced_scroll) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Handle mouse click before rendering to avoid dependent on drawn items
|
|
||||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
|
||||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
|
||||||
// Compute viewport-relative row so (0) is top row of the visible area
|
|
||||||
float vy_f = (mp.y - list_origin.y - scroll_y) / row_h;
|
|
||||||
long vy = static_cast<long>(vy_f);
|
|
||||||
if (vy < 0)
|
|
||||||
vy = 0;
|
|
||||||
|
|
||||||
// Clamp vy within visible content height to avoid huge jumps
|
|
||||||
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
|
|
||||||
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
|
|
||||||
float child_h = (cr_max.y - cr_min.y);
|
|
||||||
long vis_rows = static_cast<long>(child_h / row_h);
|
|
||||||
if (vis_rows < 1)
|
|
||||||
vis_rows = 1;
|
|
||||||
if (vy >= vis_rows)
|
|
||||||
vy = vis_rows - 1;
|
|
||||||
|
|
||||||
// Translate viewport row to buffer row using Buffer::Rowoffs
|
|
||||||
std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy);
|
|
||||||
if (by >= lines.size()) {
|
|
||||||
if (!lines.empty())
|
|
||||||
by = lines.size() - 1;
|
|
||||||
else
|
|
||||||
by = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute desired pixel X inside the viewport content (subtract horizontal scroll)
|
|
||||||
float px = (mp.x - list_origin.x - scroll_x);
|
|
||||||
if (px < 0.0f)
|
|
||||||
px = 0.0f;
|
|
||||||
|
|
||||||
// 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 pixel X to a render-column target including horizontal col offset
|
|
||||||
// Use our own tab expansion of width 8 to match command layer logic.
|
|
||||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
|
||||||
const std::size_t tabw = 8;
|
|
||||||
// We iterate source columns computing absolute rendered column (rx_abs) from 0,
|
|
||||||
// then translate to viewport-space by subtracting Coloffs.
|
|
||||||
std::size_t coloffs = buf->Coloffs();
|
|
||||||
std::size_t rx_abs = 0; // absolute rendered column
|
|
||||||
std::size_t i = 0; // source column iterator
|
|
||||||
|
|
||||||
// Fast-forward i until rx_abs >= coloffs to align with leftmost visible column
|
|
||||||
if (!line_clicked.empty() && coloffs > 0) {
|
|
||||||
while (i < line_clicked.size() && rx_abs < coloffs) {
|
|
||||||
if (line_clicked[i] == '\t') {
|
|
||||||
rx_abs += (tabw - (rx_abs % tabw));
|
|
||||||
} else {
|
|
||||||
rx_abs += 1;
|
|
||||||
}
|
|
||||||
++i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now search for closest source column to clicked px within/after viewport
|
|
||||||
std::size_t best_col = i; // default to first visible column
|
|
||||||
float best_dist = std::numeric_limits<float>::infinity();
|
|
||||||
while (true) {
|
|
||||||
// For i in [current..size], evaluate candidate including the implicit end position
|
|
||||||
std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0;
|
|
||||||
float rx_px = static_cast<float>(rx_view) * space_w;
|
|
||||||
float dist = std::fabs(px - rx_px);
|
|
||||||
if (dist <= best_dist) {
|
|
||||||
best_dist = dist;
|
|
||||||
best_col = i;
|
|
||||||
}
|
|
||||||
if (i == line_clicked.size())
|
|
||||||
break;
|
|
||||||
// advance to next source column
|
|
||||||
if (line_clicked[i] == '\t') {
|
|
||||||
rx_abs += (tabw - (rx_abs % tabw));
|
|
||||||
} else {
|
|
||||||
rx_abs += 1;
|
|
||||||
}
|
|
||||||
++i;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Cache current horizontal offset in rendered columns
|
|
||||||
const std::size_t coloffs_now = buf->Coloffs();
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
|
|
||||||
std::size_t rx = 0;
|
|
||||||
std::size_t s = 0;
|
|
||||||
while (s < upto_src_exclusive && s < line.size()) {
|
|
||||||
if (line[s] == '\t')
|
|
||||||
rx += (tabw - (rx % tabw));
|
|
||||||
else
|
|
||||||
rx += 1;
|
|
||||||
++s;
|
|
||||||
}
|
|
||||||
return rx;
|
|
||||||
};
|
|
||||||
// Draw background highlights (under text)
|
|
||||||
if (search_mode && !hl_src_ranges.empty()) {
|
|
||||||
// Current match emphasis
|
|
||||||
bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i;
|
|
||||||
std::size_t cur_x = has_current ? ed.SearchMatchX() : 0;
|
|
||||||
std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
|
||||||
for (const auto &rg: hl_src_ranges) {
|
|
||||||
std::size_t sx = rg.first, ex = rg.second;
|
|
||||||
std::size_t rx_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,
|
|
||||||
line_pos.y + line_h);
|
|
||||||
// Choose color: current match stronger
|
|
||||||
bool is_current = has_current && sx == cur_x && ex == cur_end;
|
|
||||||
ImU32 col = is_current
|
|
||||||
? IM_COL32(255, 220, 120, 140)
|
|
||||||
: IM_COL32(200, 200, 0, 90);
|
|
||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Emit entire line to an expanded buffer (tabs -> spaces)
|
|
||||||
for (std::size_t src = 0; src < line.size(); ++src) {
|
|
||||||
char c = line[src];
|
|
||||||
if (c == '\t') {
|
|
||||||
std::size_t adv = (tabw - (rx_abs_draw % tabw));
|
|
||||||
expanded.append(adv, ' ');
|
|
||||||
rx_abs_draw += adv;
|
|
||||||
} else {
|
|
||||||
expanded.push_back(c);
|
|
||||||
rx_abs_draw += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw syntax-colored runs (text above background highlights)
|
|
||||||
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;
|
|
||||||
std::size_t vx0 = (rx_s > coloffs_now) ? (rx_s - coloffs_now) : 0;
|
|
||||||
std::size_t vx1 = (rx_e > coloffs_now) ? (rx_e - coloffs_now) : 0;
|
|
||||||
if (vx0 >= expanded.size()) continue;
|
|
||||||
vx1 = std::min<std::size_t>(vx1, expanded.size());
|
|
||||||
if (vx1 <= vx0) continue;
|
|
||||||
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
|
|
||||||
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
|
|
||||||
ImGui::GetWindowDrawList()->AddText(p, col, expanded.c_str() + vx0, expanded.c_str() + vx1);
|
|
||||||
}
|
|
||||||
// We drew text via draw list (no layout advance). Manually advance the cursor to the next line.
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + line_h));
|
|
||||||
} else {
|
|
||||||
// No syntax: draw as one run
|
|
||||||
ImGui::TextUnformatted(expanded.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::EndChild();
|
|
||||||
|
|
||||||
// Status bar spanning full width
|
|
||||||
ImGui::Separator();
|
|
||||||
|
|
||||||
// Compute full content width and draw a filled background rectangle
|
|
||||||
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);
|
|
||||||
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();
|
|
||||||
std::string ptext = ed.PromptText();
|
|
||||||
auto kind = ed.CurrentPromptKind();
|
|
||||||
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
|
|
||||||
kind == Editor::PromptKind::Chdir) {
|
|
||||||
const char *home_c = std::getenv("HOME");
|
|
||||||
if (home_c && *home_c) {
|
|
||||||
std::string home(home_c);
|
|
||||||
if (ptext.rfind(home, 0) == 0) {
|
|
||||||
std::string rest = ptext.substr(home.size());
|
|
||||||
if (rest.empty())
|
|
||||||
ptext = "~";
|
|
||||||
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
|
|
||||||
ptext = std::string("~") + rest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
float pad = 6.f;
|
|
||||||
float left_x = p0.x + pad;
|
|
||||||
float right_x = p1.x - pad;
|
|
||||||
float max_px = std::max(0.0f, right_x - left_x);
|
|
||||||
|
|
||||||
std::string prefix;
|
|
||||||
if (kind == Editor::PromptKind::Command) {
|
|
||||||
prefix = ": ";
|
|
||||||
} else if (!label.empty()) {
|
|
||||||
prefix = label + ": ";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compose showing right-end of filename portion when too long for space
|
|
||||||
std::string final_msg;
|
|
||||||
ImVec2 prefix_sz = ImGui::CalcTextSize(prefix.c_str());
|
|
||||||
float avail_px = std::max(0.0f, max_px - prefix_sz.x);
|
|
||||||
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind ==
|
|
||||||
Editor::PromptKind::Chdir) && avail_px > 0.0f) {
|
|
||||||
// Trim from left until it fits by pixel width
|
|
||||||
std::string tail = ptext;
|
|
||||||
ImVec2 tail_sz = ImGui::CalcTextSize(tail.c_str());
|
|
||||||
if (tail_sz.x > avail_px) {
|
|
||||||
// Remove leading chars until it fits
|
|
||||||
// Use a simple loop; text lengths are small here
|
|
||||||
size_t start = 0;
|
|
||||||
// To avoid O(n^2) worst-case, remove chunks
|
|
||||||
while (start < tail.size()) {
|
|
||||||
// Estimate how many chars to skip based on ratio
|
|
||||||
float ratio = tail_sz.x / avail_px;
|
|
||||||
size_t skip = ratio > 1.5f
|
|
||||||
? std::min(tail.size() - start,
|
|
||||||
(size_t) std::max<size_t>(
|
|
||||||
1, (size_t) (tail.size() / 4)))
|
|
||||||
: 1;
|
|
||||||
start += skip;
|
|
||||||
std::string candidate = tail.substr(start);
|
|
||||||
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
|
|
||||||
if (cand_sz.x <= avail_px) {
|
|
||||||
tail = candidate;
|
|
||||||
tail_sz = cand_sz;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (ImGui::CalcTextSize(tail.c_str()).x > avail_px && !tail.empty()) {
|
|
||||||
// As a last resort, ensure fit by chopping exactly
|
|
||||||
// binary reduce
|
|
||||||
size_t lo = 0, hi = tail.size();
|
|
||||||
while (lo < hi) {
|
|
||||||
size_t mid = (lo + hi) / 2;
|
|
||||||
std::string cand = tail.substr(mid);
|
|
||||||
if (ImGui::CalcTextSize(cand.c_str()).x <= avail_px)
|
|
||||||
hi = mid;
|
|
||||||
else
|
|
||||||
lo = mid + 1;
|
|
||||||
}
|
|
||||||
tail = tail.substr(lo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final_msg = prefix + tail;
|
|
||||||
} else {
|
|
||||||
final_msg = prefix + ptext;
|
|
||||||
}
|
|
||||||
|
|
||||||
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::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;
|
|
||||||
left.reserve(256);
|
|
||||||
left += "kge"; // GUI app name
|
|
||||||
left += " ";
|
|
||||||
left += KTE_VERSION_STR;
|
|
||||||
std::string fname;
|
|
||||||
try {
|
|
||||||
fname = ed.DisplayNameFor(*buf);
|
|
||||||
} catch (...) {
|
|
||||||
fname = buf->Filename();
|
|
||||||
try {
|
|
||||||
fname = std::filesystem::path(fname).filename().string();
|
|
||||||
} catch (...) {}
|
|
||||||
}
|
|
||||||
left += " ";
|
|
||||||
// Insert buffer position prefix "[x/N] " before filename
|
|
||||||
{
|
|
||||||
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 += fname;
|
|
||||||
if (buf->Dirty())
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build right text (cursor/mark)
|
|
||||||
int row1 = static_cast<int>(buf->Cury()) + 1;
|
|
||||||
int col1 = static_cast<int>(buf->Curx()) + 1;
|
|
||||||
bool have_mark = buf->MarkSet();
|
|
||||||
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
|
|
||||||
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
|
|
||||||
char rbuf[128];
|
|
||||||
if (have_mark)
|
|
||||||
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: %d,%d", row1, col1, mrow1, mcol1);
|
|
||||||
else
|
|
||||||
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1);
|
|
||||||
std::string right = rbuf;
|
|
||||||
|
|
||||||
// Middle message: if a prompt is active, show "Label: text"; otherwise show status
|
|
||||||
std::string msg;
|
|
||||||
if (ed.PromptActive()) {
|
|
||||||
msg = ed.PromptLabel();
|
|
||||||
if (!msg.empty())
|
|
||||||
msg += ": ";
|
|
||||||
msg += ed.PromptText();
|
|
||||||
} else {
|
|
||||||
msg = ed.Status();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measurements
|
|
||||||
ImVec2 left_sz = ImGui::CalcTextSize(left.c_str());
|
|
||||||
ImVec2 right_sz = ImGui::CalcTextSize(right.c_str());
|
|
||||||
float pad = 6.f;
|
|
||||||
float left_x = p0.x + pad;
|
|
||||||
float right_x = p1.x - pad - right_sz.x;
|
|
||||||
if (right_x < left_x + left_sz.x + pad) {
|
|
||||||
// Not enough room; clip left to fit
|
|
||||||
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::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::TextUnformatted(left.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw right
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
|
|
||||||
p0.y + (bar_h - right_sz.y) * 0.5f));
|
|
||||||
ImGui::TextUnformatted(right.c_str());
|
|
||||||
|
|
||||||
// Draw middle message centered in remaining space
|
|
||||||
if (!msg.empty()) {
|
|
||||||
float mid_left = left_x + left_sz.x + pad;
|
|
||||||
float mid_right = std::max(right_x - pad, mid_left);
|
|
||||||
float mid_w = std::max(0.0f, mid_right - mid_left);
|
|
||||||
if (mid_w > 1.0f) {
|
|
||||||
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::TextUnformatted(msg.c_str());
|
|
||||||
ImGui::PopClipRect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Advance cursor to after the bar to keep layout consistent
|
|
||||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::End();
|
|
||||||
ImGui::PopStyleVar(3);
|
|
||||||
|
|
||||||
// --- Visual File Picker overlay (GUI only) ---
|
|
||||||
if (ed.FilePickerVisible()) {
|
|
||||||
// Centered popup-style window that always fits within the current viewport
|
|
||||||
ImGuiViewport *vp2 = ImGui::GetMainViewport();
|
|
||||||
|
|
||||||
// Desired size, min size, and margins
|
|
||||||
const ImVec2 want(800.0f, 500.0f);
|
|
||||||
const ImVec2 min_sz(240.0f, 160.0f);
|
|
||||||
const float margin = 20.0f; // space from viewport edges
|
|
||||||
|
|
||||||
// Compute the maximum allowed size (viewport minus margins) and make sure it's not negative
|
|
||||||
ImVec2 max_sz(std::max(32.0f, vp2->Size.x - 2.0f * margin),
|
|
||||||
std::max(32.0f, vp2->Size.y - 2.0f * margin));
|
|
||||||
|
|
||||||
// Clamp desired size to [min_sz, max_sz]
|
|
||||||
ImVec2 size(std::min(want.x, max_sz.x), std::min(want.y, max_sz.y));
|
|
||||||
size.x = std::max(size.x, std::min(min_sz.x, max_sz.x));
|
|
||||||
size.y = std::max(size.y, std::min(min_sz.y, max_sz.y));
|
|
||||||
|
|
||||||
// Center within the viewport using the final size
|
|
||||||
ImVec2 pos(vp2->Pos.x + std::max(margin, (vp2->Size.x - size.x) * 0.5f),
|
|
||||||
vp2->Pos.y + std::max(margin, (vp2->Size.y - size.y) * 0.5f));
|
|
||||||
|
|
||||||
// On HiDPI displays (macOS Retina), ensure integer pixel alignment to avoid
|
|
||||||
// potential hit-test vs draw mismatches from sub-pixel positions.
|
|
||||||
pos.x = std::floor(pos.x + 0.5f);
|
|
||||||
pos.y = std::floor(pos.y + 0.5f);
|
|
||||||
size.x = std::floor(size.x + 0.5f);
|
|
||||||
size.y = std::floor(size.y + 0.5f);
|
|
||||||
|
|
||||||
ImGui::SetNextWindowPos(pos, ImGuiCond_Always);
|
|
||||||
ImGui::SetNextWindowSize(size, ImGuiCond_Always);
|
|
||||||
ImGuiWindowFlags wflags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse |
|
|
||||||
ImGuiWindowFlags_NoDocking;
|
|
||||||
bool open = true;
|
|
||||||
if (ImGui::Begin("File Picker", &open, wflags)) {
|
|
||||||
// Current directory
|
|
||||||
std::string curdir = ed.FilePickerDir();
|
|
||||||
if (curdir.empty()) {
|
|
||||||
try {
|
|
||||||
curdir = std::filesystem::current_path().string();
|
|
||||||
} catch (...) {
|
|
||||||
curdir = ".";
|
|
||||||
}
|
|
||||||
ed.SetFilePickerDir(curdir);
|
|
||||||
}
|
|
||||||
ImGui::TextUnformatted(curdir.c_str());
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (ImGui::Button("Up")) {
|
|
||||||
try {
|
|
||||||
std::filesystem::path p(curdir);
|
|
||||||
if (p.has_parent_path()) {
|
|
||||||
ed.SetFilePickerDir(p.parent_path().string());
|
|
||||||
}
|
|
||||||
} catch (...) {}
|
|
||||||
}
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (ImGui::Button("Close")) {
|
|
||||||
ed.SetFilePickerVisible(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::Separator();
|
|
||||||
|
|
||||||
// Header
|
|
||||||
ImGui::TextUnformatted("Name");
|
|
||||||
ImGui::Separator();
|
|
||||||
|
|
||||||
// Scrollable list
|
|
||||||
ImGui::BeginChild("picker-list", ImVec2(0, 0), true);
|
|
||||||
|
|
||||||
// Build entries: directories first then files, alphabetical
|
|
||||||
struct Entry {
|
|
||||||
std::string name;
|
|
||||||
std::filesystem::path path;
|
|
||||||
bool is_dir;
|
|
||||||
};
|
|
||||||
std::vector<Entry> entries;
|
|
||||||
entries.reserve(256);
|
|
||||||
// Optional parent entry
|
|
||||||
try {
|
|
||||||
std::filesystem::path base(curdir);
|
|
||||||
std::error_code ec;
|
|
||||||
for (auto it = std::filesystem::directory_iterator(base, ec);
|
|
||||||
!ec && it != std::filesystem::directory_iterator(); it.increment(ec)) {
|
|
||||||
const auto &p = it->path();
|
|
||||||
std::string nm;
|
|
||||||
try {
|
|
||||||
nm = p.filename().string();
|
|
||||||
} catch (...) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (nm == "." || nm == "..")
|
|
||||||
continue;
|
|
||||||
bool is_dir = false;
|
|
||||||
std::error_code ec2;
|
|
||||||
is_dir = it->is_directory(ec2);
|
|
||||||
entries.push_back({nm, p, is_dir});
|
|
||||||
}
|
|
||||||
} catch (...) {
|
|
||||||
// ignore listing errors; show empty
|
|
||||||
}
|
|
||||||
std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) {
|
|
||||||
if (a.is_dir != b.is_dir)
|
|
||||||
return a.is_dir && !b.is_dir;
|
|
||||||
return a.name < b.name;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Draw rows
|
|
||||||
int idx = 0;
|
|
||||||
for (const auto &e: entries) {
|
|
||||||
ImGui::PushID(idx++); // ensure unique/stable IDs even if names repeat
|
|
||||||
std::string label;
|
|
||||||
label.reserve(e.name.size() + 4);
|
|
||||||
if (e.is_dir)
|
|
||||||
label += "[";
|
|
||||||
label += e.name;
|
|
||||||
if (e.is_dir)
|
|
||||||
label += "]";
|
|
||||||
|
|
||||||
// Render selectable row
|
|
||||||
ImGui::Selectable(label.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick);
|
|
||||||
|
|
||||||
// Activate based strictly on hover + mouse, to avoid any off-by-one due to click routing
|
|
||||||
if (ImGui::IsItemHovered()) {
|
|
||||||
if (e.is_dir && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
|
|
||||||
// Enter directory on double-click
|
|
||||||
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.SetFilePickerVisible(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::PopID();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::EndChild();
|
|
||||||
}
|
|
||||||
ImGui::End();
|
|
||||||
if (!open) {
|
|
||||||
ed.SetFilePickerVisible(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/*
|
|
||||||
* GUIRenderer - ImGui-based renderer for GUI mode
|
|
||||||
*/
|
|
||||||
#ifndef KTE_GUI_RENDERER_H
|
|
||||||
#define KTE_GUI_RENDERER_H
|
|
||||||
|
|
||||||
#include "Renderer.h"
|
|
||||||
|
|
||||||
class GUIRenderer final : public Renderer {
|
|
||||||
public:
|
|
||||||
GUIRenderer() = default;
|
|
||||||
|
|
||||||
~GUIRenderer() override = default;
|
|
||||||
|
|
||||||
void Draw(Editor &ed) override;
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif // KTE_GUI_RENDERER_H
|
|
||||||
+513
-852
File diff suppressed because it is too large
Load Diff
-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_)
|
|
||||||
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)
|
|
||||||
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) {
|
|
||||||
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)
|
|
||||||
return;
|
|
||||||
ensureCapacityFor(len);
|
|
||||||
if (size_ > 0) {
|
|
||||||
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)
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
-80
@@ -1,80 +0,0 @@
|
|||||||
/*
|
|
||||||
* GapBuffer.h - C++ replacement for abuf append/prepend buffer utilities
|
|
||||||
*/
|
|
||||||
#ifndef KTE_GAPBUFFER_H
|
|
||||||
#define KTE_GAPBUFFER_H
|
|
||||||
|
|
||||||
#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
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif // KTE_GAPBUFFER_H
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#include "GoHighlighter.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include <cctype>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
|
||||||
static bool is_ident_start(char c){ return std::isalpha(static_cast<unsigned char>(c)) || c=='_'; }
|
|
||||||
static bool is_ident_char(char c){ return std::isalnum(static_cast<unsigned char>(c)) || c=='_'; }
|
|
||||||
|
|
||||||
GoHighlighter::GoHighlighter()
|
|
||||||
{
|
|
||||||
const char* kw[] = {"break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"};
|
|
||||||
for (auto s: kw) kws_.insert(s);
|
|
||||||
const char* tp[] = {"bool","byte","complex64","complex128","error","float32","float64","int","int8","int16","int32","int64","rune","string","uint","uint8","uint16","uint32","uint64","uintptr"};
|
|
||||||
for (auto s: tp) types_.insert(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
void GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
int n = static_cast<int>(s.size());
|
|
||||||
int i = 0;
|
|
||||||
int bol=0; while (bol<n && (s[bol]==' '||s[bol]=='\t')) ++bol;
|
|
||||||
// line comment
|
|
||||||
while (i < n) {
|
|
||||||
char c = s[i];
|
|
||||||
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
|
||||||
if (c=='/' && i+1<n && s[i+1]=='/') { push(out,i,n,TokenKind::Comment); break; }
|
|
||||||
if (c=='/' && i+1<n && s[i+1]=='*') {
|
|
||||||
int j=i+2; bool closed=false; while (j+1<=n) { if (j+1<n && s[j]=='*' && s[j+1]=='/') { j+=2; closed=true; break; } ++j; }
|
|
||||||
if (!closed) { push(out,i,n,TokenKind::Comment); break; } else { push(out,i,j,TokenKind::Comment); i=j; continue; }
|
|
||||||
}
|
|
||||||
if (c=='"' || c=='`') {
|
|
||||||
char q=c; int j=i+1; bool esc=false; if (q=='`') { while (j<n && s[j] != '`') ++j; if (j<n) ++j; }
|
|
||||||
else { while (j<n){ char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break;} }
|
|
||||||
push(out,i,j,TokenKind::String); i=j; continue;
|
|
||||||
}
|
|
||||||
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='x'||s[j]=='X'||s[j]=='_')) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
|
|
||||||
if (is_ident_start(c)) { int j=i+1; while (j<n && is_ident_char(s[j])) ++j; std::string id=s.substr(i,j-i); TokenKind k=TokenKind::Identifier; if (kws_.count(id)) k=TokenKind::Keyword; else if (types_.count(id)) k=TokenKind::Type; push(out,i,j,k); i=j; continue; }
|
|
||||||
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Operator; if (c==';'||c==','||c=='('||c==')'||c=='{'||c=='}'||c=='['||c==']') k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
|
|
||||||
push(out,i,i+1,TokenKind::Default); ++i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// GoHighlighter.h - simple Go highlighter
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "LanguageHighlighter.h"
|
|
||||||
#include <unordered_set>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
class GoHighlighter final : public LanguageHighlighter {
|
|
||||||
public:
|
|
||||||
GoHighlighter();
|
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
|
||||||
private:
|
|
||||||
std::unordered_set<std::string> kws_;
|
|
||||||
std::unordered_set<std::string> types_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
+34
-4
@@ -22,9 +22,12 @@ HelpText::Text()
|
|||||||
" C-k ' Toggle read-only\n"
|
" C-k ' Toggle read-only\n"
|
||||||
" C-k - Unindent region (mark required)\n"
|
" C-k - Unindent region (mark required)\n"
|
||||||
" C-k = Indent region (mark required)\n"
|
" C-k = Indent region (mark required)\n"
|
||||||
|
" C-k / Toggle visual line mode\n"
|
||||||
" C-k ; Command prompt (:\\ )\n"
|
" C-k ; Command prompt (:\\ )\n"
|
||||||
|
" C-k SPACE Toggle mark\n"
|
||||||
" C-k C-d Kill entire line\n"
|
" C-k C-d Kill entire line\n"
|
||||||
" C-k C-q Quit now (no confirm)\n"
|
" C-k C-q Quit now (no confirm)\n"
|
||||||
|
" C-k C-s Save\n"
|
||||||
" C-k C-x Save and quit\n"
|
" C-k C-x Save and quit\n"
|
||||||
" C-k a Mark start of file, jump to end\n"
|
" C-k a Mark start of file, jump to end\n"
|
||||||
" C-k b Switch buffer\n"
|
" C-k b Switch buffer\n"
|
||||||
@@ -34,8 +37,11 @@ HelpText::Text()
|
|||||||
" C-k f Flush kill ring\n"
|
" C-k f Flush kill ring\n"
|
||||||
" C-k g Jump to line\n"
|
" C-k g Jump to line\n"
|
||||||
" C-k h Show this help\n"
|
" C-k h Show this help\n"
|
||||||
|
" C-k i New empty buffer\n"
|
||||||
" C-k j Jump to mark\n"
|
" C-k j Jump to mark\n"
|
||||||
|
" C-k k Center viewport on cursor\n"
|
||||||
" C-k l Reload buffer from disk\n"
|
" C-k l Reload buffer from disk\n"
|
||||||
|
" C-k m Toggle edit mode (code/writing)\n"
|
||||||
" C-k n Previous buffer\n"
|
" C-k n Previous buffer\n"
|
||||||
" C-k o Change working directory (prompt)\n"
|
" C-k o Change working directory (prompt)\n"
|
||||||
" C-k p Next buffer\n"
|
" C-k p Next buffer\n"
|
||||||
@@ -59,6 +65,10 @@ HelpText::Text()
|
|||||||
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
|
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
|
||||||
" ESC q Reflow paragraph\n"
|
" ESC q Reflow paragraph\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
"Universal argument:\n"
|
||||||
|
" C-u Begin repeat count (then type digits); C-u alone multiplies by 4\n"
|
||||||
|
" C-u N <cmd> Repeat <cmd> N times (e.g., C-u 8 C-f moves right 8 chars)\n"
|
||||||
|
"\n"
|
||||||
"Control keys:\n"
|
"Control keys:\n"
|
||||||
" C-a C-e Line start / end\n"
|
" C-a C-e Line start / end\n"
|
||||||
" C-b C-f Move left / right\n"
|
" C-b C-f Move left / right\n"
|
||||||
@@ -70,12 +80,32 @@ HelpText::Text()
|
|||||||
" C-t Regex search & replace\n"
|
" C-t Regex search & replace\n"
|
||||||
" C-h Search & replace\n"
|
" C-h Search & replace\n"
|
||||||
" C-l / C-g Refresh / Cancel\n"
|
" C-l / C-g Refresh / Cancel\n"
|
||||||
" C-u [digits] Universal argument (repeat count)\n"
|
|
||||||
"\n"
|
"\n"
|
||||||
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
|
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"GUI appearance (command prompt):\n"
|
"Edit modes:\n"
|
||||||
" : theme NAME Set GUI theme (eink, gruvbox, nord, plan9, solarized)\n"
|
" code Monospace font (default for source files)\n"
|
||||||
" : background MODE Set background: light | dark (affects eink, gruvbox, solarized)\n"
|
" writing Proportional font (auto for .txt, .md, .rst, .org, .tex)\n"
|
||||||
|
" C-k m or : mode [code|writing] to toggle\n"
|
||||||
|
"\n"
|
||||||
|
"GUI commands (command prompt):\n"
|
||||||
|
" : theme NAME Set theme (amber, eink, everforest, gruvbox,\n"
|
||||||
|
" kanagawa-paper, lcars, leuchtturm, nord, old-book,\n"
|
||||||
|
" orbital, plan9, solarized, tufte, weyland-yutani,\n"
|
||||||
|
" zenburn)\n"
|
||||||
|
" : background MODE Background: light | dark\n"
|
||||||
|
" : font NAME Set font (tab completes)\n"
|
||||||
|
" : font-size NUM Set font size in pixels\n"
|
||||||
|
" : mode [code|writing] Toggle or set edit mode\n"
|
||||||
|
"\n"
|
||||||
|
"Configuration:\n"
|
||||||
|
" Config file: ~/.config/kte/kge.toml (see CONFIG.md)\n"
|
||||||
|
" Legacy kge.ini is also supported.\n"
|
||||||
|
"\n"
|
||||||
|
"GUI window management:\n"
|
||||||
|
" Cmd+N (macOS) Open a new editor window sharing the same buffers\n"
|
||||||
|
" Ctrl+Shift+N (Linux) Open a new editor window sharing the same buffers\n"
|
||||||
|
" Close window Secondary windows close independently; closing the\n"
|
||||||
|
" primary window quits the editor\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -1,9 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* HelpText.h - embedded/customizable help content
|
* HelpText.h - embedded/customizable help content
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_HELPTEXT_H
|
#pragma once
|
||||||
#define KTE_HELPTEXT_H
|
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
class HelpText {
|
class HelpText {
|
||||||
@@ -13,5 +11,3 @@ public:
|
|||||||
// (in HelpText.cc) without touching the help command logic.
|
// (in HelpText.cc) without touching the help command logic.
|
||||||
static std::string Text();
|
static std::string Text();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_HELPTEXT_H
|
|
||||||
|
|||||||
+41
-1
@@ -1,11 +1,13 @@
|
|||||||
// Highlight.h - core syntax highlighting types for kte
|
// Highlight.h - core syntax highlighting types for kte
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
|
|
||||||
// Token kinds shared between renderers and highlighters
|
// Token kinds shared between renderers and highlighters
|
||||||
enum class TokenKind {
|
enum class TokenKind {
|
||||||
Default,
|
Default,
|
||||||
@@ -36,4 +38,42 @@ struct LineHighlight {
|
|||||||
std::uint64_t version{0}; // buffer version used for this line
|
std::uint64_t version{0}; // buffer version used for this line
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// A line's spans ready to draw: clamped to the line, ordered by start, not
|
||||||
|
// overlapping (the earlier span wins), and widened to whole UTF-8
|
||||||
|
// characters. Highlighters may emit one span per byte of a non-ASCII
|
||||||
|
// character; drawn separately, each byte showed as a replacement glyph.
|
||||||
|
inline void
|
||||||
|
SanitizeSpans(std::string_view line, const std::vector<HighlightSpan> &in, std::vector<HighlightSpan> &out)
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
const long len = static_cast<long>(line.size());
|
||||||
|
for (const HighlightSpan &sp: in) {
|
||||||
|
long s = std::clamp<long>(std::min(sp.col_start, sp.col_end), 0, len);
|
||||||
|
long e = std::clamp<long>(std::max(sp.col_start, sp.col_end), 0, len);
|
||||||
|
if (e > s)
|
||||||
|
out.push_back(HighlightSpan{static_cast<int>(s), static_cast<int>(e), sp.kind});
|
||||||
|
}
|
||||||
|
std::stable_sort(out.begin(), out.end(), [](const HighlightSpan &a, const HighlightSpan &b) {
|
||||||
|
return a.col_start < b.col_start;
|
||||||
|
});
|
||||||
|
auto continuation = [&](long i) {
|
||||||
|
return i < len && (static_cast<unsigned char>(line[static_cast<std::size_t>(i)]) & 0xC0) == 0x80;
|
||||||
|
};
|
||||||
|
std::size_t w = 0;
|
||||||
|
long prev_end = 0;
|
||||||
|
for (const HighlightSpan &sp: out) {
|
||||||
|
long s = sp.col_start, e = sp.col_end;
|
||||||
|
while (s > 0 && continuation(s))
|
||||||
|
--s;
|
||||||
|
while (e < len && continuation(e))
|
||||||
|
++e;
|
||||||
|
s = std::max(s, prev_end);
|
||||||
|
if (e <= s)
|
||||||
|
continue;
|
||||||
|
out[w++] = HighlightSpan{static_cast<int>(s), static_cast<int>(e), sp.kind};
|
||||||
|
prev_end = e;
|
||||||
|
}
|
||||||
|
out.resize(w);
|
||||||
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
#include "HighlighterEngine.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include "LanguageHighlighter.h"
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
HighlighterEngine::HighlighterEngine() = default;
|
|
||||||
HighlighterEngine::~HighlighterEngine() = default;
|
|
||||||
|
|
||||||
void
|
|
||||||
HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl)
|
|
||||||
{
|
|
||||||
hl_ = std::move(hl);
|
|
||||||
cache_.clear();
|
|
||||||
state_cache_.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
const LineHighlight &
|
|
||||||
HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const
|
|
||||||
{
|
|
||||||
auto it = cache_.find(row);
|
|
||||||
if (it != cache_.end()) {
|
|
||||||
if (it->second.version == buf_version) {
|
|
||||||
return it->second;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LineHighlight updated;
|
|
||||||
updated.version = buf_version;
|
|
||||||
updated.spans.clear();
|
|
||||||
if (!hl_) {
|
|
||||||
auto &slot = cache_[row];
|
|
||||||
slot = std::move(updated);
|
|
||||||
return cache_[row];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auto *stateful = dynamic_cast<StatefulHighlighter *>(hl_.get())) {
|
|
||||||
// Find nearest cached state at or before row-1 with matching version
|
|
||||||
StatefulHighlighter::LineState prev_state;
|
|
||||||
int start_row = -1;
|
|
||||||
if (!state_cache_.empty()) {
|
|
||||||
// linear search over map (unordered), track best candidate
|
|
||||||
int best = -1;
|
|
||||||
for (const auto &kv : state_cache_) {
|
|
||||||
int r = kv.first;
|
|
||||||
if (r <= row - 1 && kv.second.version == buf_version) {
|
|
||||||
if (r > best) {
|
|
||||||
best = r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (best >= 0) {
|
|
||||||
start_row = best;
|
|
||||||
prev_state = state_cache_.at(best).state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk from start_row+1 up to row computing states; only collect spans at the target row
|
|
||||||
for (int r = start_row + 1; r <= row; ++r) {
|
|
||||||
std::vector<HighlightSpan> tmp;
|
|
||||||
std::vector<HighlightSpan> &out = (r == row) ? updated.spans : tmp;
|
|
||||||
auto next_state = stateful->HighlightLineStateful(buf, r, prev_state, out);
|
|
||||||
// store state for this row (state after finishing r)
|
|
||||||
StateEntry se;
|
|
||||||
se.version = buf_version;
|
|
||||||
se.state = next_state;
|
|
||||||
state_cache_[r] = se;
|
|
||||||
prev_state = next_state;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Stateless path
|
|
||||||
hl_->HighlightLine(buf, row, updated.spans);
|
|
||||||
}
|
|
||||||
|
|
||||||
auto &slot = cache_[row];
|
|
||||||
slot = std::move(updated);
|
|
||||||
return cache_[row];
|
|
||||||
}
|
|
||||||
|
|
||||||
void
|
|
||||||
HighlighterEngine::InvalidateFrom(int row)
|
|
||||||
{
|
|
||||||
if (cache_.empty()) return;
|
|
||||||
// Simple implementation: erase all rows >= row
|
|
||||||
for (auto it = cache_.begin(); it != cache_.end(); ) {
|
|
||||||
if (it->first >= row) it = cache_.erase(it); else ++it;
|
|
||||||
}
|
|
||||||
if (!state_cache_.empty()) {
|
|
||||||
for (auto it = state_cache_.begin(); it != state_cache_.end(); ) {
|
|
||||||
if (it->first >= row) it = state_cache_.erase(it); else ++it;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// HighlighterEngine.h - caching layer for per-line highlights
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <memory>
|
|
||||||
#include <unordered_map>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "Highlight.h"
|
|
||||||
#include "LanguageHighlighter.h"
|
|
||||||
|
|
||||||
class Buffer;
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
class HighlighterEngine {
|
|
||||||
public:
|
|
||||||
HighlighterEngine();
|
|
||||||
~HighlighterEngine();
|
|
||||||
|
|
||||||
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
|
|
||||||
|
|
||||||
// Retrieve highlights for a given line and buffer version.
|
|
||||||
// If cache is stale, recompute using the current highlighter.
|
|
||||||
const LineHighlight &GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
|
|
||||||
|
|
||||||
// Invalidate cached lines from row (inclusive)
|
|
||||||
void InvalidateFrom(int row);
|
|
||||||
|
|
||||||
bool HasHighlighter() const { return static_cast<bool>(hl_); }
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::unique_ptr<LanguageHighlighter> hl_;
|
|
||||||
// Simple cache by row index (mutable to allow caching in const GetLine)
|
|
||||||
mutable std::unordered_map<int, LineHighlight> cache_;
|
|
||||||
// For stateful highlighters, remember per-line state (state after finishing that row)
|
|
||||||
struct StateEntry {
|
|
||||||
std::uint64_t version{0};
|
|
||||||
// Using the interface type; forward-declare via header
|
|
||||||
StatefulHighlighter::LineState state;
|
|
||||||
};
|
|
||||||
mutable std::unordered_map<int, StateEntry> state_cache_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
#include "HighlighterRegistry.h"
|
|
||||||
#include "CppHighlighter.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <filesystem>
|
|
||||||
|
|
||||||
// Forward declare simple highlighters implemented in this project
|
|
||||||
namespace kte {
|
|
||||||
class JSONHighlighter; class MarkdownHighlighter; class ShellHighlighter;
|
|
||||||
class GoHighlighter; class PythonHighlighter; class RustHighlighter; class LispHighlighter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headers for the above
|
|
||||||
#include "JsonHighlighter.h"
|
|
||||||
#include "MarkdownHighlighter.h"
|
|
||||||
#include "ShellHighlighter.h"
|
|
||||||
#include "GoHighlighter.h"
|
|
||||||
#include "PythonHighlighter.h"
|
|
||||||
#include "RustHighlighter.h"
|
|
||||||
#include "LispHighlighter.h"
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
static std::string to_lower(std::string_view s) {
|
|
||||||
std::string r(s);
|
|
||||||
std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string HighlighterRegistry::Normalize(std::string_view ft)
|
|
||||||
{
|
|
||||||
std::string f = to_lower(ft);
|
|
||||||
if (f == "c" || f == "c++" || f == "cc" || f == "hpp" || f == "hh" || f == "h" || f == "cxx") return "cpp";
|
|
||||||
if (f == "cpp") return "cpp";
|
|
||||||
if (f == "json") return "json";
|
|
||||||
if (f == "markdown" || f == "md" || f == "mkd" || f == "mdown") return "markdown";
|
|
||||||
if (f == "shell" || f == "sh" || f == "bash" || f == "zsh" || f == "ksh" || f == "fish") return "shell";
|
|
||||||
if (f == "go" || f == "golang") return "go";
|
|
||||||
if (f == "py" || f == "python") return "python";
|
|
||||||
if (f == "rs" || f == "rust") return "rust";
|
|
||||||
if (f == "lisp" || f == "scheme" || f == "scm" || f == "rkt" || f == "el" || f == "clj" || f == "cljc" || f == "cl") return "lisp";
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::unique_ptr<LanguageHighlighter> HighlighterRegistry::CreateFor(std::string_view filetype)
|
|
||||||
{
|
|
||||||
std::string ft = Normalize(filetype);
|
|
||||||
if (ft == "cpp") return std::make_unique<CppHighlighter>();
|
|
||||||
if (ft == "json") return std::make_unique<JSONHighlighter>();
|
|
||||||
if (ft == "markdown") return std::make_unique<MarkdownHighlighter>();
|
|
||||||
if (ft == "shell") return std::make_unique<ShellHighlighter>();
|
|
||||||
if (ft == "go") return std::make_unique<GoHighlighter>();
|
|
||||||
if (ft == "python") return std::make_unique<PythonHighlighter>();
|
|
||||||
if (ft == "rust") return std::make_unique<RustHighlighter>();
|
|
||||||
if (ft == "lisp") return std::make_unique<LispHighlighter>();
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string shebang_to_ft(std::string_view first_line) {
|
|
||||||
if (first_line.size() < 2 || first_line.substr(0,2) != "#!") return "";
|
|
||||||
std::string low = to_lower(first_line);
|
|
||||||
if (low.find("python") != std::string::npos) return "python";
|
|
||||||
if (low.find("bash") != std::string::npos) return "shell";
|
|
||||||
if (low.find("sh") != std::string::npos) return "shell";
|
|
||||||
if (low.find("zsh") != std::string::npos) return "shell";
|
|
||||||
if (low.find("fish") != std::string::npos) return "shell";
|
|
||||||
if (low.find("scheme") != std::string::npos || low.find("racket") != std::string::npos || low.find("guile") != std::string::npos) return "lisp";
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string HighlighterRegistry::DetectForPath(std::string_view path, std::string_view first_line)
|
|
||||||
{
|
|
||||||
// Extension
|
|
||||||
std::string p(path);
|
|
||||||
std::error_code ec;
|
|
||||||
std::string ext = std::filesystem::path(p).extension().string();
|
|
||||||
for (auto &ch: ext) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
|
||||||
if (!ext.empty()) {
|
|
||||||
if (ext == ".c" || ext == ".cc" || ext == ".cpp" || ext == ".cxx" || ext == ".h" || ext == ".hpp" || ext == ".hh") return "cpp";
|
|
||||||
if (ext == ".json") return "json";
|
|
||||||
if (ext == ".md" || ext == ".markdown" || ext == ".mkd") return "markdown";
|
|
||||||
if (ext == ".sh" || ext == ".bash" || ext == ".zsh" || ext == ".ksh" || ext == ".fish") return "shell";
|
|
||||||
if (ext == ".go") return "go";
|
|
||||||
if (ext == ".py") return "python";
|
|
||||||
if (ext == ".rs") return "rust";
|
|
||||||
if (ext == ".lisp" || ext == ".scm" || ext == ".rkt" || ext == ".el" || ext == ".clj" || ext == ".cljc" || ext == ".cl") return "lisp";
|
|
||||||
}
|
|
||||||
// Shebang
|
|
||||||
std::string ft = shebang_to_ft(first_line);
|
|
||||||
return ft;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// HighlighterRegistry.h - create/detect language highlighters
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
#include <string_view>
|
|
||||||
|
|
||||||
#include "LanguageHighlighter.h"
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
class HighlighterRegistry {
|
|
||||||
public:
|
|
||||||
// Create a highlighter for normalized filetype id (e.g., "cpp", "json", "markdown", "shell", "go", "python", "rust", "lisp").
|
|
||||||
static std::unique_ptr<LanguageHighlighter> CreateFor(std::string_view filetype);
|
|
||||||
|
|
||||||
// Detect filetype by path extension and shebang (first line).
|
|
||||||
// Returns normalized id or empty string if unknown.
|
|
||||||
static std::string DetectForPath(std::string_view path, std::string_view first_line);
|
|
||||||
|
|
||||||
// Normalize various aliases/extensions to canonical ids.
|
|
||||||
static std::string Normalize(std::string_view ft);
|
|
||||||
};
|
|
||||||
|
|
||||||
} // 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_;
|
||||||
|
};
|
||||||
@@ -3,9 +3,25 @@
|
|||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
|
|
||||||
#include <SDL.h>
|
#include <SDL.h>
|
||||||
|
#include <imgui.h>
|
||||||
|
|
||||||
#include "GUIInputHandler.h"
|
#include "ImGuiInputHandler.h"
|
||||||
#include "KKeymap.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
|
static bool
|
||||||
@@ -13,20 +29,17 @@ map_key(const SDL_Keycode key,
|
|||||||
const SDL_Keymod mod,
|
const SDL_Keymod mod,
|
||||||
bool &k_prefix,
|
bool &k_prefix,
|
||||||
bool &esc_meta,
|
bool &esc_meta,
|
||||||
// universal-argument state (by ref)
|
bool &k_ctrl_pending,
|
||||||
bool &uarg_active,
|
Editor *ed,
|
||||||
bool &uarg_collecting,
|
MappedInput &out,
|
||||||
bool &uarg_negative,
|
bool &suppress_textinput_once)
|
||||||
bool &uarg_had_digits,
|
|
||||||
int &uarg_value,
|
|
||||||
std::string &uarg_text,
|
|
||||||
MappedInput &out)
|
|
||||||
{
|
{
|
||||||
// Ctrl handling
|
// Ctrl handling
|
||||||
const bool is_ctrl = (mod & KMOD_CTRL) != 0;
|
const bool is_ctrl = (mod & KMOD_CTRL) != 0;
|
||||||
const bool is_alt = (mod & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 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) {
|
if (esc_meta) {
|
||||||
int ascii_key = 0;
|
int ascii_key = 0;
|
||||||
if (key == SDLK_BACKSPACE) {
|
if (key == SDLK_BACKSPACE) {
|
||||||
@@ -44,51 +57,73 @@ map_key(const SDL_Keycode key,
|
|||||||
ascii_key = '>';
|
ascii_key = '>';
|
||||||
}
|
}
|
||||||
if (ascii_key != 0) {
|
if (ascii_key != 0) {
|
||||||
|
esc_meta = false; // consume if we can decide on KEYDOWN
|
||||||
ascii_key = KLowerAscii(ascii_key);
|
ascii_key = KLowerAscii(ascii_key);
|
||||||
CommandId id;
|
CommandId id;
|
||||||
if (KLookupEscCommand(ascii_key, 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;
|
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.
|
// No usable ASCII from KEYDOWN → keep esc_meta set and let TEXTINPUT handle it
|
||||||
// Leave it set so SDL_TEXTINPUT fallback can translate and suppress insertion.
|
|
||||||
out.hasCommand = false;
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Movement and basic keys
|
// Movement and basic keys
|
||||||
|
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case SDLK_LEFT:
|
case SDLK_LEFT:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveLeft, "", 0};
|
out = {true, CommandId::MoveLeft, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_RIGHT:
|
case SDLK_RIGHT:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveRight, "", 0};
|
out = {true, CommandId::MoveRight, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_UP:
|
case SDLK_UP:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveUp, "", 0};
|
out = {true, CommandId::MoveUp, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_DOWN:
|
case SDLK_DOWN:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveDown, "", 0};
|
out = {true, CommandId::MoveDown, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_HOME:
|
case SDLK_HOME:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveHome, "", 0};
|
out = {true, CommandId::MoveHome, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_END:
|
case SDLK_END:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveEnd, "", 0};
|
out = {true, CommandId::MoveEnd, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_PAGEUP:
|
case SDLK_PAGEUP:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::PageUp, "", 0};
|
out = {true, CommandId::PageUp, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_PAGEDOWN:
|
case SDLK_PAGEDOWN:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::PageDown, "", 0};
|
out = {true, CommandId::PageDown, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_DELETE:
|
case SDLK_DELETE:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::DeleteChar, "", 0};
|
out = {true, CommandId::DeleteChar, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_BACKSPACE:
|
case SDLK_BACKSPACE:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::Backspace, "", 0};
|
out = {true, CommandId::Backspace, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case SDLK_TAB:
|
case SDLK_TAB:
|
||||||
@@ -102,10 +137,17 @@ map_key(const SDL_Keycode key,
|
|||||||
break; // fall through so k-prefix handler can process
|
break; // fall through so k-prefix handler can process
|
||||||
case SDLK_RETURN:
|
case SDLK_RETURN:
|
||||||
case SDLK_KP_ENTER:
|
case SDLK_KP_ENTER:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
|
if (mod & KMOD_SHIFT) {
|
||||||
|
out = {true, CommandId::SmartNewline, "", 0};
|
||||||
|
} else {
|
||||||
out = {true, CommandId::Newline, "", 0};
|
out = {true, CommandId::Newline, "", 0};
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
case SDLK_ESCAPE:
|
case SDLK_ESCAPE:
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
esc_meta = true; // next key will be treated as Meta
|
esc_meta = true; // next key will be treated as Meta
|
||||||
out.hasCommand = false; // no immediate command for bare ESC in GUI
|
out.hasCommand = false; // no immediate command for bare ESC in GUI
|
||||||
return true;
|
return true;
|
||||||
@@ -115,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 we are in k-prefix, interpret the very next key via the C-k keymap immediately.
|
||||||
if (k_prefix) {
|
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;
|
esc_meta = false;
|
||||||
// Normalize to ASCII; preserve case for letters using Shift
|
// Normalize to ASCII; preserve case for letters using Shift
|
||||||
int ascii_key = 0;
|
int ascii_key = 0;
|
||||||
@@ -135,74 +194,75 @@ map_key(const SDL_Keycode key,
|
|||||||
ascii_key = static_cast<int>(key);
|
ascii_key = static_cast<int>(key);
|
||||||
}
|
}
|
||||||
bool ctrl2 = (mod & KMOD_CTRL) != 0;
|
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) {
|
if (ascii_key != 0) {
|
||||||
int lower = KLowerAscii(ascii_key);
|
int lower = KLowerAscii(ascii_key);
|
||||||
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
|
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;
|
CommandId id;
|
||||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||||
|
#if IMGUI_IH_DEBUG
|
||||||
// Diagnostics for u/U
|
// Diagnostics for u/U
|
||||||
if (lower == 'u') {
|
if (lower == 'u') {
|
||||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||||
? static_cast<char>(ascii_key)
|
? static_cast<char>(ascii_key)
|
||||||
: '?';
|
: '?';
|
||||||
std::fprintf(stderr,
|
IH_LOGF(
|
||||||
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
||||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||||
mapped ? static_cast<int>(id) : -1);
|
mapped ? static_cast<int>(id) : -1);
|
||||||
std::fflush(stderr);
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
out = {true, id, "", 0};
|
out = {true, id, "", 0};
|
||||||
|
if (ed)
|
||||||
|
ed->SetStatus(""); // clear "C-k _" hint after suffix
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
int shown = KLowerAscii(ascii_key);
|
int shown = KLowerAscii(ascii_key);
|
||||||
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
|
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
|
||||||
std::string arg(1, c);
|
std::string arg(1, c);
|
||||||
out = {true, CommandId::UnknownKCommand, arg, 0};
|
out = {true, CommandId::UnknownKCommand, arg, 0};
|
||||||
|
if (ed)
|
||||||
|
ed->SetStatus(""); // clear hint; handler will set unknown status
|
||||||
return true;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_ctrl) {
|
if (is_ctrl) {
|
||||||
// Universal argument: C-u
|
// Universal argument: C-u
|
||||||
if (key == SDLK_u) {
|
if (key == SDLK_u) {
|
||||||
if (!uarg_active) {
|
if (ed)
|
||||||
uarg_active = true;
|
ed->UArgStart();
|
||||||
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;
|
|
||||||
}
|
|
||||||
out.hasCommand = false;
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Cancel universal arg on C-g as well (it maps to Refresh via ctrl map)
|
// Cancel universal arg on C-g as well (it maps to Refresh via ctrl map)
|
||||||
if (key == SDLK_g) {
|
if (key == SDLK_g) {
|
||||||
uarg_active = false;
|
if (ed)
|
||||||
uarg_collecting = false;
|
ed->UArgClear();
|
||||||
uarg_negative = false;
|
// Also cancel any pending k-prefix qualifier
|
||||||
uarg_had_digits = false;
|
k_ctrl_pending = false;
|
||||||
uarg_value = 0;
|
k_prefix = false; // treat as cancel of prefix
|
||||||
uarg_text.clear();
|
|
||||||
}
|
}
|
||||||
if (key == SDLK_k || key == SDLK_KP_EQUALS) {
|
if (key == SDLK_k || key == SDLK_KP_EQUALS) {
|
||||||
k_prefix = true;
|
k_prefix = true;
|
||||||
@@ -246,29 +306,17 @@ map_key(const SDL_Keycode key,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If collecting universal argument, allow digits/minus on KEYDOWN path too
|
// If collecting universal argument, allow digits on KEYDOWN path too
|
||||||
if (uarg_active && uarg_collecting) {
|
if (ed && ed->UArg() != 0) {
|
||||||
if ((key >= SDLK_0 && key <= SDLK_9) && !(mod & KMOD_SHIFT)) {
|
if ((key >= SDLK_0 && key <= SDLK_9) && !(mod & KMOD_SHIFT)) {
|
||||||
int d = static_cast<int>(key - SDLK_0);
|
int d = static_cast<int>(key - SDLK_0);
|
||||||
if (!uarg_had_digits) {
|
ed->UArgDigit(d);
|
||||||
uarg_value = 0;
|
out.hasCommand = false;
|
||||||
uarg_had_digits = true;
|
// 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.
|
||||||
if (uarg_value < 100000000) {
|
suppress_textinput_once = true;
|
||||||
uarg_value = uarg_value * 10 + d;
|
|
||||||
}
|
|
||||||
uarg_text.push_back(static_cast<char>('0' + d));
|
|
||||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
|
||||||
return 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
|
// k_prefix handled earlier
|
||||||
@@ -278,36 +326,87 @@ map_key(const SDL_Keycode key,
|
|||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||||
{
|
{
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
bool produced = false;
|
bool produced = false;
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case SDL_MOUSEWHEEL: {
|
case SDL_MOUSEWHEEL: {
|
||||||
// Map vertical wheel to line-wise cursor movement (MoveUp/MoveDown)
|
// High-resolution trackpads can deliver fractional wheel deltas. Accumulate
|
||||||
int dy = e.wheel.y;
|
// 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
|
#ifdef SDL_MOUSEWHEEL_FLIPPED
|
||||||
if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED)
|
if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED)
|
||||||
dy = -dy;
|
dy = -dy;
|
||||||
#endif
|
#endif
|
||||||
if (dy != 0) {
|
if (dy != 0.0f) {
|
||||||
int repeat = dy > 0 ? dy : -dy;
|
wheel_accum_y_ += dy;
|
||||||
CommandId id = dy > 0 ? CommandId::MoveUp : CommandId::MoveDown;
|
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_);
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
for (int i = 0; i < repeat; ++i) {
|
for (int i = 0; i < steps; ++i) {
|
||||||
q_.push(MappedInput{true, id, std::string(), 0});
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
case SDL_KEYDOWN: {
|
case SDL_KEYDOWN: {
|
||||||
|
// A suppression request covers only the text event of the key that
|
||||||
|
// made it, which arrives before the next key press. Chords such as
|
||||||
|
// Ctrl+V produce no text event at all, and a leftover request ate
|
||||||
|
// the next character typed (B8).
|
||||||
|
suppress_text_input_once_ = false;
|
||||||
// Remember state before mapping; used for TEXTINPUT suppression heuristics
|
// Remember state before mapping; used for TEXTINPUT suppression heuristics
|
||||||
const bool was_k_prefix = k_prefix_;
|
const bool was_k_prefix = k_prefix_;
|
||||||
const bool was_esc_meta = esc_meta_;
|
const bool was_esc_meta = esc_meta_;
|
||||||
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
|
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
|
||||||
const SDL_Keycode key = e.key.keysym.sym;
|
const SDL_Keycode key = e.key.keysym.sym;
|
||||||
|
|
||||||
|
// New window: Cmd+N (macOS) or Ctrl+Shift+N (Linux/Windows)
|
||||||
|
{
|
||||||
|
const bool gui_n = (mods & KMOD_GUI) && !(mods & KMOD_CTRL) && (key == SDLK_n);
|
||||||
|
const bool ctrl_sn = (mods & KMOD_CTRL) && (mods & KMOD_SHIFT) && (key == SDLK_n);
|
||||||
|
if (gui_n || ctrl_sn) {
|
||||||
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
|
q_.push(MappedInput{true, CommandId::NewWindow, std::string(), 0});
|
||||||
|
suppress_text_input_once_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font zoom: Cmd+=/Cmd+-/Cmd+0 (macOS) or Ctrl+=/Ctrl+-/Ctrl+0
|
||||||
|
if ((mods & (KMOD_CTRL | KMOD_GUI)) && !(mods & KMOD_SHIFT)) {
|
||||||
|
bool is_zoom = true;
|
||||||
|
CommandId zoom_cmd = CommandId::FontZoomIn;
|
||||||
|
if (key == SDLK_EQUALS || key == SDLK_PLUS)
|
||||||
|
zoom_cmd = CommandId::FontZoomIn;
|
||||||
|
else if (key == SDLK_MINUS)
|
||||||
|
zoom_cmd = CommandId::FontZoomOut;
|
||||||
|
else if (key == SDLK_0)
|
||||||
|
zoom_cmd = CommandId::FontZoomReset;
|
||||||
|
else
|
||||||
|
is_zoom = false;
|
||||||
|
if (is_zoom) {
|
||||||
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
|
q_.push(MappedInput{true, zoom_cmd, std::string(), 0});
|
||||||
|
suppress_text_input_once_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS)
|
// Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS)
|
||||||
// Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode.
|
// Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode.
|
||||||
if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) {
|
if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) {
|
||||||
@@ -315,58 +414,42 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
if (clip) {
|
if (clip) {
|
||||||
std::string text(clip);
|
std::string text(clip);
|
||||||
SDL_free(clip);
|
SDL_free(clip);
|
||||||
// Split on '\n' and enqueue as InsertText/Newline commands
|
// Turn line breaks (\n, \r\n, or bare \r) into Newline
|
||||||
|
// commands and the rest into InsertText; InsertText itself
|
||||||
|
// rejects any embedded '\r'/'\n'.
|
||||||
|
// Into a prompt, line breaks must not become Newline: that
|
||||||
|
// would accept the prompt and send the rest to the buffer.
|
||||||
|
const bool to_prompt = ed_ && ed_->PromptActive();
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
std::size_t start = 0;
|
for (const auto &cmd: to_prompt ? SplitPasteForPrompt(text) : SplitPasteIntoCommands(text))
|
||||||
while (start <= text.size()) {
|
q_.push(cmd);
|
||||||
std::size_t pos = text.find('\n', start);
|
|
||||||
std::string_view segment;
|
|
||||||
bool has_nl = (pos != std::string::npos);
|
|
||||||
if (has_nl) {
|
|
||||||
segment = std::string_view(text).substr(start, pos - start);
|
|
||||||
} else {
|
|
||||||
segment = std::string_view(text).substr(start);
|
|
||||||
}
|
|
||||||
if (!segment.empty()) {
|
|
||||||
MappedInput ins{true, CommandId::InsertText, std::string(segment), 0};
|
|
||||||
q_.push(ins);
|
|
||||||
}
|
|
||||||
if (has_nl) {
|
|
||||||
MappedInput nl{true, CommandId::Newline, std::string(), 0};
|
|
||||||
q_.push(nl);
|
|
||||||
start = pos + 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Suppress the corresponding TEXTINPUT that may follow
|
// Suppress the corresponding TEXTINPUT that may follow
|
||||||
suppress_text_input_once_ = true;
|
suppress_text_input_once_ = true;
|
||||||
return true; // consumed
|
return true; // consumed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
bool suppress_req = false;
|
||||||
produced = map_key(key, mods,
|
produced = map_key(key, mods,
|
||||||
k_prefix_, esc_meta_,
|
k_prefix_, esc_meta_,
|
||||||
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_,
|
k_ctrl_pending_,
|
||||||
uarg_text_,
|
ed_,
|
||||||
mi);
|
mi,
|
||||||
|
suppress_req);
|
||||||
// If we inserted a TAB on KEYDOWN, suppress any subsequent SDL_TEXTINPUT
|
if (suppress_req) {
|
||||||
// for this keystroke to avoid double insertion on platforms that emit it.
|
// Prevent the corresponding TEXTINPUT from delivering the same digit again
|
||||||
if (produced && mi.hasCommand && mi.id == CommandId::InsertText && mi.arg == "\t") {
|
|
||||||
suppress_text_input_once_ = true;
|
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,
|
// 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.
|
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
|
||||||
if (produced && mi.hasCommand && mi.id == CommandId::UArgStatus) {
|
// Additional suppression handled above when KEYDOWN consumed a uarg digit
|
||||||
// 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)) {
|
|
||||||
suppress_text_input_once_ = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suppress the immediate following SDL_TEXTINPUT when a printable KEYDOWN was used as a
|
// 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.
|
// k-prefix suffix or Meta (Alt/ESC) chord, regardless of whether a command was produced.
|
||||||
@@ -387,7 +470,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
}
|
}
|
||||||
// Alt/Meta + letter can also generate TEXTINPUT on some platforms
|
// Alt/Meta + letter can also generate TEXTINPUT on some platforms
|
||||||
const bool is_meta_symbol = (
|
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)) {
|
if (is_alt && ((key >= SDLK_a && key <= SDLK_z) || is_meta_symbol)) {
|
||||||
should_suppress = true;
|
should_suppress = true;
|
||||||
}
|
}
|
||||||
@@ -411,35 +495,26 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If universal argument collection is active, consume digit/minus TEXTINPUT
|
// If editor universal argument is active, consume digit TEXTINPUT
|
||||||
if (uarg_active_ && uarg_collecting_) {
|
if (ed_ &&ed_
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
->
|
||||||
|
UArg() != 0
|
||||||
|
)
|
||||||
|
{
|
||||||
const char *txt = e.text.text;
|
const char *txt = e.text.text;
|
||||||
if (txt && *txt) {
|
if (txt && *txt) {
|
||||||
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
||||||
if (c0 >= '0' && c0 <= '9') {
|
if (c0 >= '0' && c0 <= '9') {
|
||||||
int d = c0 - '0';
|
int d = c0 - '0';
|
||||||
if (!uarg_had_digits_) {
|
ed_->UArgDigit(d);
|
||||||
uarg_value_ = 0;
|
produced = true; // consumed to update status
|
||||||
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;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// End collection and allow this TEXTINPUT to be processed normally below
|
// Non-digit ends collection; allow processing normally below
|
||||||
uarg_collecting_ = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we are still in k-prefix and KEYDOWN path didn't handle the suffix,
|
// If we are still in k-prefix and KEYDOWN path didn't handle the suffix,
|
||||||
@@ -455,20 +530,36 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
ascii_key = static_cast<int>(c0);
|
ascii_key = static_cast<int>(c0);
|
||||||
}
|
}
|
||||||
if (ascii_key != 0) {
|
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
|
// Map via k-prefix table; do not pass Ctrl for TEXTINPUT case
|
||||||
CommandId id;
|
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
|
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
||||||
|
{
|
||||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||||
? static_cast<char>(ascii_key)
|
? static_cast<char>(ascii_key)
|
||||||
: '?';
|
: '?';
|
||||||
std::fprintf(stderr,
|
IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||||
"[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
|
||||||
ascii_key, disp, mapped ? 1 : 0,
|
ascii_key, disp, mapped ? 1 : 0,
|
||||||
mapped ? static_cast<int>(id) : -1);
|
mapped ? static_cast<int>(id) : -1);
|
||||||
std::fflush(stderr);
|
}
|
||||||
|
#endif
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
mi = {true, id, "", 0};
|
mi = {true, id, "", 0};
|
||||||
|
if (ed_)
|
||||||
|
ed_->SetStatus(""); // clear "C-k _" hint after suffix
|
||||||
produced = true;
|
produced = true;
|
||||||
break; // handled; do not insert text
|
break; // handled; do not insert text
|
||||||
} else {
|
} else {
|
||||||
@@ -479,12 +570,17 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
: '?';
|
: '?';
|
||||||
std::string arg(1, c);
|
std::string arg(1, c);
|
||||||
mi = {true, CommandId::UnknownKCommand, arg, 0};
|
mi = {true, CommandId::UnknownKCommand, arg, 0};
|
||||||
|
if (ed_)
|
||||||
|
ed_->SetStatus("");
|
||||||
produced = true;
|
produced = true;
|
||||||
break;
|
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;
|
produced = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -524,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;
|
produced = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -554,24 +651,6 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (produced && mi.hasCommand) {
|
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_) {
|
|
||||||
count = (uarg_value_ > 0) ? uarg_value_ : 4;
|
|
||||||
} else {
|
|
||||||
count = uarg_value_;
|
|
||||||
if (uarg_negative_)
|
|
||||||
count = -count;
|
|
||||||
}
|
|
||||||
mi.count = count;
|
|
||||||
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_);
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
q_.push(mi);
|
q_.push(mi);
|
||||||
}
|
}
|
||||||
@@ -580,7 +659,7 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
|
|
||||||
|
|
||||||
bool
|
bool
|
||||||
GUIInputHandler::Poll(MappedInput &out)
|
ImGuiInputHandler::Poll(MappedInput &out)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
if (q_.empty())
|
if (q_.empty())
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* GUIInputHandler - ImGui/SDL2-based input mapping for GUI mode
|
* ImGuiInputHandler - ImGui/SDL2-based input mapping for GUI mode
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_GUI_INPUT_HANDLER_H
|
#pragma once
|
||||||
#define KTE_GUI_INPUT_HANDLER_H
|
|
||||||
|
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <queue>
|
#include <queue>
|
||||||
|
|
||||||
@@ -12,11 +10,18 @@
|
|||||||
|
|
||||||
union SDL_Event; // fwd decl to avoid including SDL here (SDL defines SDL_Event as a union)
|
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:
|
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.
|
// Translate an SDL event to editor command and enqueue if applicable.
|
||||||
// Returns true if it produced a mapped command or consumed input.
|
// Returns true if it produced a mapped command or consumed input.
|
||||||
@@ -28,19 +33,17 @@ private:
|
|||||||
std::mutex mu_;
|
std::mutex mu_;
|
||||||
std::queue<MappedInput> q_;
|
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
|
// Treat ESC as a Meta prefix: next key is looked up via ESC keymap
|
||||||
bool esc_meta_ = false;
|
bool esc_meta_ = false;
|
||||||
// When a printable keydown generated a non-text command, suppress the very next SDL_TEXTINPUT
|
// 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.
|
// event produced by SDL for the same keystroke to avoid inserting stray characters.
|
||||||
bool suppress_text_input_once_ = false;
|
bool suppress_text_input_once_ = false;
|
||||||
|
|
||||||
// Universal argument (C-u) state for GUI
|
Editor *ed_ = nullptr; // attached editor for editor-owned uarg handling
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif // KTE_GUI_INPUT_HANDLER_H
|
// 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
|
||||||
|
};
|
||||||
@@ -0,0 +1,956 @@
|
|||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <limits>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <imgui.h>
|
||||||
|
|
||||||
|
#include "ImGuiRenderer.h"
|
||||||
|
#include "SearchHighlight.h"
|
||||||
|
#include "TermWidth.h"
|
||||||
|
#include "Highlight.h"
|
||||||
|
#include "GUITheme.h"
|
||||||
|
#include "Buffer.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
|
||||||
|
// Version string expected to be provided by build system as KTE_VERSION_STR
|
||||||
|
#ifndef KTE_VERSION_STR
|
||||||
|
# define KTE_VERSION_STR "dev"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ImGui compatibility: some bundled ImGui versions (or builds without docking)
|
||||||
|
// don't define ImGuiWindowFlags_NoDocking. Treat it as 0 in that case.
|
||||||
|
#ifndef ImGuiWindowFlags_NoDocking
|
||||||
|
# define ImGuiWindowFlags_NoDocking 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
ImGuiRenderer::Draw(Editor &ed)
|
||||||
|
{
|
||||||
|
// Make the editor window occupy the entire GUI container/viewport
|
||||||
|
ImGuiViewport *vp = ImGui::GetMainViewport();
|
||||||
|
// On HiDPI/Retina, snap to integer pixels to prevent any draw vs hit-test
|
||||||
|
// mismatches that can appear on the very first maximized frame.
|
||||||
|
ImVec2 main_pos = vp->Pos;
|
||||||
|
ImVec2 main_sz = vp->Size;
|
||||||
|
main_pos.x = std::floor(main_pos.x + 0.5f);
|
||||||
|
main_pos.y = std::floor(main_pos.y + 0.5f);
|
||||||
|
main_sz.x = std::floor(main_sz.x + 0.5f);
|
||||||
|
main_sz.y = std::floor(main_sz.y + 0.5f);
|
||||||
|
ImGui::SetNextWindowPos(main_pos);
|
||||||
|
ImGui::SetNextWindowSize(main_sz);
|
||||||
|
|
||||||
|
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar
|
||||||
|
| ImGuiWindowFlags_NoScrollbar
|
||||||
|
| ImGuiWindowFlags_NoScrollWithMouse
|
||||||
|
| ImGuiWindowFlags_NoResize
|
||||||
|
| ImGuiWindowFlags_NoMove
|
||||||
|
| ImGuiWindowFlags_NoCollapse
|
||||||
|
| ImGuiWindowFlags_NoSavedSettings
|
||||||
|
| ImGuiWindowFlags_NoBringToFrontOnFocus
|
||||||
|
| ImGuiWindowFlags_NoNavFocus;
|
||||||
|
|
||||||
|
// Reduce padding so the buffer content uses the whole area
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 6.f));
|
||||||
|
|
||||||
|
ImGui::Begin("kte", nullptr, flags);
|
||||||
|
|
||||||
|
const Buffer *buf = ed.CurrentBuffer();
|
||||||
|
if (!buf) {
|
||||||
|
ImGui::TextUnformatted("[no buffer]");
|
||||||
|
} else {
|
||||||
|
// 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();
|
||||||
|
const float row_h = ImGui::GetTextLineHeightWithSpacing();
|
||||||
|
const float space_w = ImGui::CalcTextSize(" ").x;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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_) {
|
||||||
|
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
||||||
|
ImGui::SetNextWindowScroll(ImVec2(-1.0f, 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.
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
|
||||||
|
bool forced_scroll = false;
|
||||||
|
{
|
||||||
|
const long scroll_top = static_cast<long>(scroll_y / row_h);
|
||||||
|
|
||||||
|
// Check if rowoffs was programmatically changed this frame
|
||||||
|
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
|
||||||
|
forced_scroll = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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_buf_rowoffs_ = buf_rowoffs;
|
||||||
|
prev_buf_coloffs_ = buf_coloffs;
|
||||||
|
|
||||||
|
// Max-line-width cache: reset when the buffer or font changes. We only
|
||||||
|
// measure visible lines per frame and maintain a running max, so the
|
||||||
|
// scrollbar may be slightly conservative on first view of a file until
|
||||||
|
// the user scrolls, but idle CPU drops dramatically on large files.
|
||||||
|
// Edits bump the buffer version but we intentionally do NOT reset the
|
||||||
|
// max on version changes — keeping a conservative (possibly too-wide)
|
||||||
|
// scrollbar is preferable to per-keystroke jitter.
|
||||||
|
{
|
||||||
|
ImFont *cur_font = ImGui::GetFont();
|
||||||
|
float cur_fsize = ImGui::GetFontSize();
|
||||||
|
if (buf != max_width_buf_
|
||||||
|
|| cur_font != max_width_font_
|
||||||
|
|| cur_fsize != max_width_font_size_) {
|
||||||
|
max_width_buf_ = buf;
|
||||||
|
max_width_font_ = cur_font;
|
||||||
|
max_width_font_size_ = cur_fsize;
|
||||||
|
max_width_px_ = 0.0f;
|
||||||
|
}
|
||||||
|
max_width_version_ = buf->Version();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search matches: the pattern is compiled once per frame (compiling it
|
||||||
|
// per line per frame was a large source of idle CPU on macOS).
|
||||||
|
const kte::SearchHighlight search_hl(ed);
|
||||||
|
const bool search_mode = search_hl.Active();
|
||||||
|
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||||
|
|
||||||
|
// Compute the visible row range and skip rendering work for off-screen
|
||||||
|
// lines. ImGui clips drawing, but string allocation, tab expansion,
|
||||||
|
// syntax-highlight lookups, and width measurement are not free.
|
||||||
|
const std::size_t total_rows = nlines;
|
||||||
|
std::size_t first_vis = 0;
|
||||||
|
std::size_t last_vis = total_rows; // exclusive
|
||||||
|
if (row_h > 0.0f && total_rows > 0) {
|
||||||
|
const long margin = 4; // render a few extra rows above/below for smoother scrolling
|
||||||
|
long fv = static_cast<long>(std::floor(scroll_y / row_h)) - margin;
|
||||||
|
long lv = static_cast<long>(
|
||||||
|
std::ceil((scroll_y + child_h_plan) / row_h)) + margin;
|
||||||
|
if (fv < 0) fv = 0;
|
||||||
|
if (lv < 0) lv = 0;
|
||||||
|
if (static_cast<std::size_t>(fv) > total_rows)
|
||||||
|
fv = static_cast<long>(total_rows);
|
||||||
|
if (static_cast<std::size_t>(lv) > total_rows)
|
||||||
|
lv = static_cast<long>(total_rows);
|
||||||
|
first_vis = static_cast<std::size_t>(fv);
|
||||||
|
last_vis = static_cast<std::size_t>(lv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
// 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;
|
||||||
|
std::size_t by = static_cast<std::size_t>(by_l);
|
||||||
|
if (by >= nlines)
|
||||||
|
by = (nlines == 0) ? 0 : (nlines - 1);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i;
|
||||||
|
std::size_t cur_x = has_current ? ed.SearchMatchX() : 0;
|
||||||
|
std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||||
|
for (const auto &rg: hl_src_ranges) {
|
||||||
|
std::size_t sx = rg.first, ex = rg.second;
|
||||||
|
std::size_t rx_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);
|
||||||
|
// Choose color: current match stronger
|
||||||
|
bool is_current = has_current && sx == cur_x && ex == cur_end;
|
||||||
|
ImU32 col = is_current
|
||||||
|
? IM_COL32(255, 220, 120, 140)
|
||||||
|
: IM_COL32(200, 200, 0, 90);
|
||||||
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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());
|
||||||
|
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;
|
||||||
|
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
|
||||||
|
if (draw_end <= draw_start)
|
||||||
|
continue;
|
||||||
|
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// We drew text via draw list (no layout advance). Manually advance the cursor to the next line.
|
||||||
|
// 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 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());
|
||||||
|
}
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a visible cursor indicator on the current line
|
||||||
|
if (i == cy) {
|
||||||
|
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 area starting right after the scroll child
|
||||||
|
ImVec2 win_pos = ImGui::GetWindowPos();
|
||||||
|
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();
|
||||||
|
std::string ptext = ed.PromptText();
|
||||||
|
auto kind = ed.CurrentPromptKind();
|
||||||
|
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
|
||||||
|
kind == Editor::PromptKind::Chdir) {
|
||||||
|
const char *home_c = std::getenv("HOME");
|
||||||
|
if (home_c && *home_c) {
|
||||||
|
std::string home(home_c);
|
||||||
|
if (ptext.rfind(home, 0) == 0) {
|
||||||
|
std::string rest = ptext.substr(home.size());
|
||||||
|
if (rest.empty())
|
||||||
|
ptext = "~";
|
||||||
|
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
|
||||||
|
ptext = std::string("~") + rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float pad = 6.f;
|
||||||
|
float left_x = p0.x + pad;
|
||||||
|
float right_x = p1.x - pad;
|
||||||
|
float max_px = std::max(0.0f, right_x - left_x);
|
||||||
|
|
||||||
|
std::string prefix;
|
||||||
|
if (kind == Editor::PromptKind::Command) {
|
||||||
|
prefix = ": ";
|
||||||
|
} else if (!label.empty()) {
|
||||||
|
prefix = label + ": ";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose showing right-end of filename portion when too long for space
|
||||||
|
std::string final_msg;
|
||||||
|
ImVec2 prefix_sz = ImGui::CalcTextSize(prefix.c_str());
|
||||||
|
float avail_px = std::max(0.0f, max_px - prefix_sz.x);
|
||||||
|
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind ==
|
||||||
|
Editor::PromptKind::Chdir) && avail_px > 0.0f) {
|
||||||
|
// Trim from left until it fits by pixel width
|
||||||
|
std::string tail = ptext;
|
||||||
|
ImVec2 tail_sz = ImGui::CalcTextSize(tail.c_str());
|
||||||
|
if (tail_sz.x > avail_px) {
|
||||||
|
// Remove leading chars until it fits
|
||||||
|
// Use a simple loop; text lengths are small here
|
||||||
|
size_t start = 0;
|
||||||
|
// To avoid O(n^2) worst-case, remove chunks
|
||||||
|
while (start < tail.size()) {
|
||||||
|
// Estimate how many chars to skip based on ratio
|
||||||
|
float ratio = tail_sz.x / avail_px;
|
||||||
|
size_t skip = ratio > 1.5f
|
||||||
|
? std::min(tail.size() - start,
|
||||||
|
(size_t) std::max<size_t>(
|
||||||
|
1, (size_t) (tail.size() / 4)))
|
||||||
|
: 1;
|
||||||
|
start += skip;
|
||||||
|
std::string candidate = tail.substr(start);
|
||||||
|
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
|
||||||
|
if (cand_sz.x <= avail_px) {
|
||||||
|
tail = candidate;
|
||||||
|
tail_sz = cand_sz;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ImGui::CalcTextSize(tail.c_str()).x > avail_px && !tail.empty()) {
|
||||||
|
// As a last resort, ensure fit by chopping exactly
|
||||||
|
// binary reduce
|
||||||
|
size_t lo = 0, hi = tail.size();
|
||||||
|
while (lo < hi) {
|
||||||
|
size_t mid = (lo + hi) / 2;
|
||||||
|
std::string cand = tail.substr(mid);
|
||||||
|
if (ImGui::CalcTextSize(cand.c_str()).x <= avail_px)
|
||||||
|
hi = mid;
|
||||||
|
else
|
||||||
|
lo = mid + 1;
|
||||||
|
}
|
||||||
|
tail = tail.substr(lo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final_msg = prefix + tail;
|
||||||
|
} else {
|
||||||
|
final_msg = prefix + ptext;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, y0 + (bar_h - msg_sz.y) * 0.5f));
|
||||||
|
ImGui::TextUnformatted(final_msg.c_str());
|
||||||
|
ImGui::PopClipRect();
|
||||||
|
} else {
|
||||||
|
// Build left text
|
||||||
|
std::string left;
|
||||||
|
left.reserve(256);
|
||||||
|
left += "kge"; // GUI app name
|
||||||
|
left += " ";
|
||||||
|
left += KTE_VERSION_STR;
|
||||||
|
std::string fname;
|
||||||
|
try {
|
||||||
|
fname = ed.DisplayNameFor(*buf);
|
||||||
|
} catch (...) {
|
||||||
|
fname = buf->Filename();
|
||||||
|
try {
|
||||||
|
fname = std::filesystem::path(fname).filename().string();
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
left += " ";
|
||||||
|
// Insert buffer position prefix "[x/N] " before filename
|
||||||
|
{
|
||||||
|
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 += fname;
|
||||||
|
if (buf->Dirty())
|
||||||
|
left += " *";
|
||||||
|
// Append total line count as "<n>L"
|
||||||
|
{
|
||||||
|
unsigned long lcount = static_cast<unsigned long>(buf->Nrows());
|
||||||
|
left += " ";
|
||||||
|
left += std::to_string(lcount);
|
||||||
|
left += "L";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build right text (cursor/mark)
|
||||||
|
int row1 = static_cast<int>(buf->Cury()) + 1;
|
||||||
|
int col1 = static_cast<int>(buf->Curx()) + 1;
|
||||||
|
bool have_mark = buf->MarkSet();
|
||||||
|
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
|
||||||
|
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
|
||||||
|
char rbuf[128];
|
||||||
|
if (have_mark)
|
||||||
|
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: %d,%d", row1, col1, mrow1, mcol1);
|
||||||
|
else
|
||||||
|
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1);
|
||||||
|
std::string right = rbuf;
|
||||||
|
|
||||||
|
// Middle message: if a prompt is active, show "Label: text"; otherwise show status
|
||||||
|
std::string msg;
|
||||||
|
if (ed.PromptActive()) {
|
||||||
|
msg = ed.PromptLabel();
|
||||||
|
if (!msg.empty())
|
||||||
|
msg += ": ";
|
||||||
|
msg += ed.PromptText();
|
||||||
|
} else {
|
||||||
|
msg = ed.Status();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measurements
|
||||||
|
ImVec2 left_sz = ImGui::CalcTextSize(left.c_str());
|
||||||
|
ImVec2 right_sz = ImGui::CalcTextSize(right.c_str());
|
||||||
|
float pad = 6.f;
|
||||||
|
float left_x = p0.x + pad;
|
||||||
|
float right_x = p1.x - pad - right_sz.x;
|
||||||
|
if (right_x < left_x + left_sz.x + pad) {
|
||||||
|
// Not enough room; clip left to fit
|
||||||
|
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, 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, y0 + (bar_h - left_sz.y) * 0.5f));
|
||||||
|
ImGui::TextUnformatted(left.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw right
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
|
||||||
|
y0 + (bar_h - right_sz.y) * 0.5f));
|
||||||
|
ImGui::TextUnformatted(right.c_str());
|
||||||
|
|
||||||
|
// Draw middle message centered in remaining space
|
||||||
|
if (!msg.empty()) {
|
||||||
|
float mid_left = left_x + left_sz.x + pad;
|
||||||
|
float mid_right = std::max(right_x - pad, mid_left);
|
||||||
|
float mid_w = std::max(0.0f, mid_right - mid_left);
|
||||||
|
if (mid_w > 1.0f) {
|
||||||
|
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, 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::End();
|
||||||
|
ImGui::PopStyleVar(3);
|
||||||
|
|
||||||
|
// --- Visual File Picker overlay (GUI only) ---
|
||||||
|
if (ed.FilePickerVisible()) {
|
||||||
|
// Centered popup-style window that always fits within the current viewport
|
||||||
|
ImGuiViewport *vp2 = ImGui::GetMainViewport();
|
||||||
|
|
||||||
|
// Desired size, min size, and margins
|
||||||
|
const ImVec2 want(800.0f, 500.0f);
|
||||||
|
const ImVec2 min_sz(240.0f, 160.0f);
|
||||||
|
const float margin = 20.0f; // space from viewport edges
|
||||||
|
|
||||||
|
// Compute the maximum allowed size (viewport minus margins) and make sure it's not negative
|
||||||
|
ImVec2 max_sz(std::max(32.0f, vp2->Size.x - 2.0f * margin),
|
||||||
|
std::max(32.0f, vp2->Size.y - 2.0f * margin));
|
||||||
|
|
||||||
|
// Clamp desired size to [min_sz, max_sz]
|
||||||
|
ImVec2 size(std::min(want.x, max_sz.x), std::min(want.y, max_sz.y));
|
||||||
|
size.x = std::max(size.x, std::min(min_sz.x, max_sz.x));
|
||||||
|
size.y = std::max(size.y, std::min(min_sz.y, max_sz.y));
|
||||||
|
|
||||||
|
// Center within the viewport using the final size
|
||||||
|
ImVec2 pos(vp2->Pos.x + std::max(margin, (vp2->Size.x - size.x) * 0.5f),
|
||||||
|
vp2->Pos.y + std::max(margin, (vp2->Size.y - size.y) * 0.5f));
|
||||||
|
|
||||||
|
// On HiDPI displays (macOS Retina), ensure integer pixel alignment to avoid
|
||||||
|
// potential hit-test vs draw mismatches from sub-pixel positions.
|
||||||
|
pos.x = std::floor(pos.x + 0.5f);
|
||||||
|
pos.y = std::floor(pos.y + 0.5f);
|
||||||
|
size.x = std::floor(size.x + 0.5f);
|
||||||
|
size.y = std::floor(size.y + 0.5f);
|
||||||
|
|
||||||
|
ImGui::SetNextWindowPos(pos, ImGuiCond_Always);
|
||||||
|
ImGui::SetNextWindowSize(size, ImGuiCond_Always);
|
||||||
|
ImGuiWindowFlags wflags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse |
|
||||||
|
ImGuiWindowFlags_NoDocking;
|
||||||
|
bool open = true;
|
||||||
|
if (ImGui::Begin("File Picker", &open, wflags)) {
|
||||||
|
// Current directory
|
||||||
|
std::string curdir = ed.FilePickerDir();
|
||||||
|
if (curdir.empty()) {
|
||||||
|
try {
|
||||||
|
curdir = std::filesystem::current_path().string();
|
||||||
|
} catch (...) {
|
||||||
|
curdir = ".";
|
||||||
|
}
|
||||||
|
ed.SetFilePickerDir(curdir);
|
||||||
|
}
|
||||||
|
ImGui::TextUnformatted(curdir.c_str());
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button("Up")) {
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(curdir);
|
||||||
|
if (p.has_parent_path()) {
|
||||||
|
ed.SetFilePickerDir(p.parent_path().string());
|
||||||
|
}
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button("Close")) {
|
||||||
|
ed.SetFilePickerVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
ImGui::TextUnformatted("Name");
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
// Scrollable list
|
||||||
|
ImGui::BeginChild("picker-list", ImVec2(0, 0), true);
|
||||||
|
|
||||||
|
// Build entries: directories first then files, alphabetical
|
||||||
|
struct Entry {
|
||||||
|
std::string name;
|
||||||
|
std::filesystem::path path;
|
||||||
|
bool is_dir;
|
||||||
|
};
|
||||||
|
std::vector<Entry> entries;
|
||||||
|
entries.reserve(256);
|
||||||
|
// Optional parent entry
|
||||||
|
try {
|
||||||
|
std::filesystem::path base(curdir);
|
||||||
|
std::error_code ec;
|
||||||
|
for (auto it = std::filesystem::directory_iterator(base, ec);
|
||||||
|
!ec && it != std::filesystem::directory_iterator(); it.increment(ec)) {
|
||||||
|
const auto &p = it->path();
|
||||||
|
std::string nm;
|
||||||
|
try {
|
||||||
|
nm = p.filename().string();
|
||||||
|
} catch (...) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (nm == "." || nm == "..")
|
||||||
|
continue;
|
||||||
|
bool is_dir = false;
|
||||||
|
std::error_code ec2;
|
||||||
|
is_dir = it->is_directory(ec2);
|
||||||
|
entries.push_back({nm, p, is_dir});
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
// ignore listing errors; show empty
|
||||||
|
}
|
||||||
|
std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) {
|
||||||
|
if (a.is_dir != b.is_dir)
|
||||||
|
return a.is_dir && !b.is_dir;
|
||||||
|
return a.name < b.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw rows
|
||||||
|
int idx = 0;
|
||||||
|
for (const auto &e: entries) {
|
||||||
|
ImGui::PushID(idx++); // ensure unique/stable IDs even if names repeat
|
||||||
|
std::string label;
|
||||||
|
label.reserve(e.name.size() + 4);
|
||||||
|
if (e.is_dir)
|
||||||
|
label += "[";
|
||||||
|
label += e.name;
|
||||||
|
if (e.is_dir)
|
||||||
|
label += "]";
|
||||||
|
|
||||||
|
// Render selectable row
|
||||||
|
ImGui::Selectable(label.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick);
|
||||||
|
|
||||||
|
// Activate based strictly on hover + mouse, to avoid any off-by-one due to click routing
|
||||||
|
if (ImGui::IsItemHovered()) {
|
||||||
|
if (e.is_dir && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
|
||||||
|
// Enter directory on double-click
|
||||||
|
ed.SetFilePickerDir(e.path.string());
|
||||||
|
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||||
|
// Open file on single click
|
||||||
|
ed.RequestOpenFile(e.path.string());
|
||||||
|
(void) ed.ProcessPendingOpens();
|
||||||
|
ed.SetFilePickerVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::PopID();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::EndChild();
|
||||||
|
}
|
||||||
|
ImGui::End();
|
||||||
|
if (!open) {
|
||||||
|
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
-5
@@ -1,13 +1,13 @@
|
|||||||
/*
|
/*
|
||||||
* InputHandler.h - input abstraction and mapping to commands
|
* InputHandler.h - input abstraction and mapping to commands
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_INPUT_HANDLER_H
|
#pragma once
|
||||||
#define KTE_INPUT_HANDLER_H
|
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "Command.h"
|
#include "Command.h"
|
||||||
|
|
||||||
|
class Editor; // fwd decl
|
||||||
|
|
||||||
|
|
||||||
// Result of translating raw input into an editor command.
|
// Result of translating raw input into an editor command.
|
||||||
struct MappedInput {
|
struct MappedInput {
|
||||||
@@ -21,9 +21,11 @@ class InputHandler {
|
|||||||
public:
|
public:
|
||||||
virtual ~InputHandler() = default;
|
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.
|
// 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.
|
// Returns true if a command is available in 'out'. Returns false if no input.
|
||||||
virtual bool Poll(MappedInput &out) = 0;
|
virtual bool Poll(MappedInput &out) = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_INPUT_HANDLER_H
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
#include "JsonHighlighter.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include <cctype>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
static bool is_digit(char c) { return c >= '0' && c <= '9'; }
|
|
||||||
|
|
||||||
void JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
int n = static_cast<int>(s.size());
|
|
||||||
auto push = [&](int a, int b, TokenKind k){ if (b> a) out.push_back({a,b,k}); };
|
|
||||||
|
|
||||||
int i = 0;
|
|
||||||
while (i < n) {
|
|
||||||
char c = s[i];
|
|
||||||
if (c == ' ' || c == '\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(i,j,TokenKind::Whitespace); i=j; continue; }
|
|
||||||
if (c == '"') {
|
|
||||||
int j = i+1; bool esc=false; while (j < n) { char d = s[j++]; if (esc) { esc=false; continue; } if (d == '\\') { esc=true; continue; } if (d == '"') break; }
|
|
||||||
push(i, j, TokenKind::String); i = j; continue;
|
|
||||||
}
|
|
||||||
if (is_digit(c) || (c=='-' && i+1<n && is_digit(s[i+1]))) {
|
|
||||||
int j=i+1; while (j<n && (std::isdigit(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='e'||s[j]=='E'||s[j]=='+'||s[j]=='-'||s[j]=='_')) ++j; push(i,j,TokenKind::Number); i=j; continue;
|
|
||||||
}
|
|
||||||
// booleans/null
|
|
||||||
if (std::isalpha(static_cast<unsigned char>(c))) {
|
|
||||||
int j=i+1; while (j<n && std::isalpha(static_cast<unsigned char>(s[j]))) ++j;
|
|
||||||
std::string id = s.substr(i, j-i);
|
|
||||||
if (id == "true" || id == "false" || id == "null") push(i,j,TokenKind::Constant); else push(i,j,TokenKind::Identifier);
|
|
||||||
i=j; continue;
|
|
||||||
}
|
|
||||||
// punctuation
|
|
||||||
if (c=='{'||c=='}'||c=='['||c==']'||c==','||c==':' ) { push(i,i+1,TokenKind::Punctuation); ++i; continue; }
|
|
||||||
// fallback
|
|
||||||
push(i,i+1,TokenKind::Default); ++i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
+31
-5
@@ -1,6 +1,4 @@
|
|||||||
#include <iostream>
|
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
#include <ostream>
|
|
||||||
|
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
|
|
||||||
@@ -17,6 +15,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'd':
|
case 'd':
|
||||||
out = CommandId::KillLine;
|
out = CommandId::KillLine;
|
||||||
return true;
|
return true;
|
||||||
|
case 's':
|
||||||
|
out = CommandId::Save;
|
||||||
|
return true;
|
||||||
case 'q':
|
case 'q':
|
||||||
out = CommandId::QuitNow;
|
out = CommandId::QuitNow;
|
||||||
return true;
|
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)
|
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (ascii_key == 'E') {
|
||||||
|
// Explicitly rejected and kept distinct from 'e' (OpenFileStart): the
|
||||||
|
// switch below operates on the lowercased key, so it can't otherwise
|
||||||
|
// tell 'E' and 'e' apart. Return false and let the caller show its
|
||||||
|
// normal "unknown k-command" status-line message - curses already
|
||||||
|
// owns the terminal here, so writing to stderr would corrupt the
|
||||||
|
// screen.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
switch (k_lower) {
|
switch (k_lower) {
|
||||||
case 'a':
|
case 'a':
|
||||||
out = CommandId::MarkAllAndJumpEnd;
|
out = CommandId::MarkAllAndJumpEnd;
|
||||||
return true;
|
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':
|
case 'b':
|
||||||
out = CommandId::BufferSwitchStart;
|
out = CommandId::BufferSwitchStart;
|
||||||
return true;
|
return true;
|
||||||
@@ -54,9 +73,6 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'e':
|
case 'e':
|
||||||
out = CommandId::OpenFileStart;
|
out = CommandId::OpenFileStart;
|
||||||
return true;
|
return true;
|
||||||
case 'E':
|
|
||||||
std::cerr << "E is not a valid command" << std::endl;
|
|
||||||
return false;
|
|
||||||
case 'f':
|
case 'f':
|
||||||
out = CommandId::FlushKillRing;
|
out = CommandId::FlushKillRing;
|
||||||
return true;
|
return true;
|
||||||
@@ -72,6 +88,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'l':
|
case 'l':
|
||||||
out = CommandId::ReloadBuffer;
|
out = CommandId::ReloadBuffer;
|
||||||
return true;
|
return true;
|
||||||
|
case 'm':
|
||||||
|
out = CommandId::ToggleEditMode;
|
||||||
|
return true;
|
||||||
case 'n':
|
case 'n':
|
||||||
out = CommandId::BufferPrev;
|
out = CommandId::BufferPrev;
|
||||||
return true;
|
return true;
|
||||||
@@ -108,6 +127,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case '=':
|
case '=':
|
||||||
out = CommandId::IndentRegion;
|
out = CommandId::IndentRegion;
|
||||||
return true;
|
return true;
|
||||||
|
case '/':
|
||||||
|
out = CommandId::VisualLineModeToggle;
|
||||||
|
return true;
|
||||||
case ';':
|
case ';':
|
||||||
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
|
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
|
||||||
return true;
|
return true;
|
||||||
@@ -211,6 +233,10 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
|
|||||||
case 'q':
|
case 'q':
|
||||||
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
|
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
|
||||||
return true;
|
return true;
|
||||||
|
case '\n':
|
||||||
|
case '\r':
|
||||||
|
out = CommandId::SmartNewline; // Shift+Enter (some terminals send this as Alt+Enter sequences)
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* KKeymap.h - mapping for k-command (C-k prefix) keys to CommandId
|
* KKeymap.h - mapping for k-command (C-k prefix) keys to CommandId
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_KKEYMAP_H
|
#pragma once
|
||||||
#define KTE_KKEYMAP_H
|
|
||||||
|
|
||||||
#include "Command.h"
|
#include "Command.h"
|
||||||
|
|
||||||
|
|
||||||
@@ -30,5 +28,3 @@ KLowerAscii(const int key)
|
|||||||
return key + ('a' - 'A');
|
return key + ('a' - 'A');
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif // KTE_KKEYMAP_H
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
// LanguageHighlighter.h - interface for line-based highlighters
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <vector>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "Highlight.h"
|
|
||||||
|
|
||||||
class Buffer;
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
class LanguageHighlighter {
|
|
||||||
public:
|
|
||||||
virtual ~LanguageHighlighter() = default;
|
|
||||||
// Produce highlight spans for a given buffer row. Implementations should append to out.
|
|
||||||
virtual void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const = 0;
|
|
||||||
virtual bool Stateful() const { return false; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional extension for stateful highlighters (e.g., multi-line comments/strings).
|
|
||||||
// Engines may detect and use this via dynamic_cast without breaking stateless impls.
|
|
||||||
class StatefulHighlighter : public LanguageHighlighter {
|
|
||||||
public:
|
|
||||||
struct LineState {
|
|
||||||
bool in_block_comment{false};
|
|
||||||
bool in_raw_string{false};
|
|
||||||
// For raw strings, remember the delimiter between the opening R"delim( and closing )delim"
|
|
||||||
std::string raw_delim;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Highlight one line given the previous line state; return the resulting state after this line.
|
|
||||||
// Implementations should append spans for this line to out and compute the next state.
|
|
||||||
virtual LineState HighlightLineStateful(const Buffer &buf,
|
|
||||||
int row,
|
|
||||||
const LineState &prev,
|
|
||||||
std::vector<HighlightSpan> &out) const = 0;
|
|
||||||
|
|
||||||
bool Stateful() const override { return true; }
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#include "LispHighlighter.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include <cctype>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
|
||||||
|
|
||||||
LispHighlighter::LispHighlighter()
|
|
||||||
{
|
|
||||||
const char* kw[] = {"defun","lambda","let","let*","define","set!","if","cond","begin","quote","quasiquote","unquote","unquote-splicing","loop","do","and","or","not"};
|
|
||||||
for (auto s: kw) kws_.insert(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
void LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
int n = static_cast<int>(s.size());
|
|
||||||
int i = 0;
|
|
||||||
int bol = 0; while (bol<n && (s[bol]==' '||s[bol]=='\t')) ++bol;
|
|
||||||
if (bol < n && s[bol] == ';') { push(out, bol, n, TokenKind::Comment); if (bol>0) push(out,0,bol,TokenKind::Whitespace); return; }
|
|
||||||
while (i < n) {
|
|
||||||
char c = s[i];
|
|
||||||
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
|
||||||
if (c==';') { push(out,i,n,TokenKind::Comment); break; }
|
|
||||||
if (c=='"') { int j=i+1; bool esc=false; while (j<n){ char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break; } push(out,i,j,TokenKind::String); i=j; continue; }
|
|
||||||
if (std::isalpha(static_cast<unsigned char>(c)) || c=='*' || c=='-' || c=='+' || c=='/' || c=='_' ) {
|
|
||||||
int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j])) || s[j]=='*' || s[j]=='-' || s[j]=='+' || s[j]=='/' || s[j]=='_' || s[j]=='!')) ++j;
|
|
||||||
std::string id=s.substr(i,j-i);
|
|
||||||
TokenKind k = kws_.count(id) ? TokenKind::Keyword : TokenKind::Identifier;
|
|
||||||
push(out,i,j,k); i=j; continue;
|
|
||||||
}
|
|
||||||
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isdigit(static_cast<unsigned char>(s[j]))||s[j]=='.')) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
|
|
||||||
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
|
|
||||||
push(out,i,i+1,TokenKind::Default); ++i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -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,88 +0,0 @@
|
|||||||
#include "MarkdownHighlighter.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include <cctype>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
static void push_span(std::vector<HighlightSpan> &out, int a, int b, TokenKind k) {
|
|
||||||
if (b > a) out.push_back({a,b,k});
|
|
||||||
}
|
|
||||||
|
|
||||||
void MarkdownHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
LineState st; // not used in stateless entry
|
|
||||||
(void)HighlightLineStateful(buf, row, st, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatefulHighlighter::LineState MarkdownHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
StatefulHighlighter::LineState state = prev;
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return state;
|
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
int n = static_cast<int>(s.size());
|
|
||||||
|
|
||||||
// Reuse in_block_comment flag as "in fenced code" state.
|
|
||||||
if (state.in_block_comment) {
|
|
||||||
// If line contains closing fence ``` then close after it
|
|
||||||
auto pos = s.find("```");
|
|
||||||
if (pos == std::string::npos) {
|
|
||||||
push_span(out, 0, n, TokenKind::String);
|
|
||||||
state.in_block_comment = true;
|
|
||||||
return state;
|
|
||||||
} else {
|
|
||||||
int end = static_cast<int>(pos + 3);
|
|
||||||
push_span(out, 0, end, TokenKind::String);
|
|
||||||
// rest of line processed normally after fence
|
|
||||||
int i = end;
|
|
||||||
// whitespace
|
|
||||||
if (i < n) push_span(out, i, n, TokenKind::Default);
|
|
||||||
state.in_block_comment = false;
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect fenced code block start at beginning (allow leading spaces)
|
|
||||||
int bol = 0; while (bol < n && (s[bol]==' '||s[bol]=='\t')) ++bol;
|
|
||||||
if (bol + 3 <= n && s.compare(bol, 3, "```") == 0) {
|
|
||||||
push_span(out, bol, n, TokenKind::String);
|
|
||||||
state.in_block_comment = true; // enter fenced mode
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headings: lines starting with 1-6 '#'
|
|
||||||
if (bol < n && s[bol] == '#') {
|
|
||||||
int j = bol; while (j < n && s[j] == '#') ++j; // hashes
|
|
||||||
// include following space and text as Keyword to stand out
|
|
||||||
push_span(out, bol, n, TokenKind::Keyword);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process inline: emphasis and code spans
|
|
||||||
int i = 0;
|
|
||||||
while (i < n) {
|
|
||||||
char c = s[i];
|
|
||||||
if (c == '`') {
|
|
||||||
int j = i + 1; while (j < n && s[j] != '`') ++j; if (j < n) ++j;
|
|
||||||
push_span(out, i, j, TokenKind::String); i = j; continue;
|
|
||||||
}
|
|
||||||
if (c == '*' || c == '_') {
|
|
||||||
// bold/italic markers: treat the marker and until next same marker as Type to highlight
|
|
||||||
char m = c; int j = i + 1; while (j < n && s[j] != m) ++j; if (j < n) ++j;
|
|
||||||
push_span(out, i, j, TokenKind::Type); i = j; continue;
|
|
||||||
}
|
|
||||||
// links []() minimal: treat [text](url) as Function
|
|
||||||
if (c == '[') {
|
|
||||||
int j = i + 1; while (j < n && s[j] != ']') ++j; if (j < n) ++j; // include ]
|
|
||||||
if (j < n && s[j] == '(') { while (j < n && s[j] != ')') ++j; if (j < n) ++j; }
|
|
||||||
push_span(out, i, j, TokenKind::Function); i = j; continue;
|
|
||||||
}
|
|
||||||
// whitespace
|
|
||||||
if (c == ' ' || c == '\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push_span(out, i, j, TokenKind::Whitespace); i=j; continue; }
|
|
||||||
// fallback: default single char
|
|
||||||
push_span(out, i, i+1, TokenKind::Default); ++i;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// MarkdownHighlighter.h - simple Markdown highlighter
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "LanguageHighlighter.h"
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
class MarkdownHighlighter final : public StatefulHighlighter {
|
|
||||||
public:
|
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
|
||||||
LineState HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const override;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#include "NullHighlighter.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
void NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
int n = static_cast<int>(s.size());
|
|
||||||
if (n <= 0) return;
|
|
||||||
out.push_back({0, n, TokenKind::Default});
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+800
-5
@@ -1,5 +1,8 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <limits>
|
||||||
|
#include <ostream>
|
||||||
|
|
||||||
#include "PieceTable.h"
|
#include "PieceTable.h"
|
||||||
|
|
||||||
@@ -7,10 +10,16 @@
|
|||||||
PieceTable::PieceTable() = default;
|
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);
|
add_.reserve(initialCapacity);
|
||||||
materialized_.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_),
|
pieces_(other.pieces_),
|
||||||
materialized_(other.materialized_),
|
materialized_(other.materialized_),
|
||||||
dirty_(other.dirty_),
|
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 &
|
PieceTable &
|
||||||
@@ -34,6 +49,12 @@ PieceTable::operator=(const PieceTable &other)
|
|||||||
materialized_ = other.materialized_;
|
materialized_ = other.materialized_;
|
||||||
dirty_ = other.dirty_;
|
dirty_ = other.dirty_;
|
||||||
total_size_ = other.total_size_;
|
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;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +69,11 @@ PieceTable::PieceTable(PieceTable &&other) noexcept
|
|||||||
{
|
{
|
||||||
other.dirty_ = true;
|
other.dirty_ = true;
|
||||||
other.total_size_ = 0;
|
other.total_size_ = 0;
|
||||||
|
other.InvalidateLineIndex(); // its index described the moved-away text
|
||||||
|
other.piece_ends_version_ = std::numeric_limits<std::uint64_t>::max();
|
||||||
|
version_ = other.version_;
|
||||||
|
range_cache_ = {};
|
||||||
|
find_cache_ = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +90,15 @@ PieceTable::operator=(PieceTable &&other) noexcept
|
|||||||
total_size_ = other.total_size_;
|
total_size_ = other.total_size_;
|
||||||
other.dirty_ = true;
|
other.dirty_ = true;
|
||||||
other.total_size_ = 0;
|
other.total_size_ = 0;
|
||||||
|
other.InvalidateLineIndex(); // its index described the moved-away text
|
||||||
|
version_ = other.version_;
|
||||||
|
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;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +106,21 @@ PieceTable::operator=(PieceTable &&other) noexcept
|
|||||||
PieceTable::~PieceTable() = default;
|
PieceTable::~PieceTable() = default;
|
||||||
|
|
||||||
|
|
||||||
|
// Setter to allow tuning consolidation heuristics
|
||||||
void
|
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);
|
piece_limit_ = piece_limit;
|
||||||
materialized_.reserve(newCapacity);
|
small_piece_threshold_ = small_piece_threshold;
|
||||||
|
max_consolidation_bytes_ = max_consolidation_bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// (removed helper) — we'll invalidate caches inline inside mutating methods
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
PieceTable::AppendChar(char c)
|
PieceTable::AppendChar(char c)
|
||||||
{
|
{
|
||||||
@@ -147,10 +189,68 @@ void
|
|||||||
PieceTable::Clear()
|
PieceTable::Clear()
|
||||||
{
|
{
|
||||||
pieces_.clear();
|
pieces_.clear();
|
||||||
|
std::string().swap(original_);
|
||||||
add_.clear();
|
add_.clear();
|
||||||
materialized_.clear();
|
materialized_.clear();
|
||||||
total_size_ = 0;
|
total_size_ = 0;
|
||||||
dirty_ = true;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -171,6 +271,10 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
|
|||||||
last.len += len;
|
last.len += len;
|
||||||
total_size_ += len;
|
total_size_ += len;
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
|
InvalidateLineIndex();
|
||||||
|
version_++;
|
||||||
|
range_cache_ = {};
|
||||||
|
find_cache_ = {};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,6 +283,10 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
|
|||||||
pieces_.push_back(Piece{src, start, len});
|
pieces_.push_back(Piece{src, start, len});
|
||||||
total_size_ += len;
|
total_size_ += len;
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
|
InvalidateLineIndex();
|
||||||
|
version_++;
|
||||||
|
range_cache_ = {};
|
||||||
|
find_cache_ = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -197,12 +305,20 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
|
|||||||
first.len += len;
|
first.len += len;
|
||||||
total_size_ += len;
|
total_size_ += len;
|
||||||
dirty_ = true;
|
dirty_ = true;
|
||||||
|
InvalidateLineIndex();
|
||||||
|
version_++;
|
||||||
|
range_cache_ = {};
|
||||||
|
find_cache_ = {};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pieces_.insert(pieces_.begin(), Piece{src, start, len});
|
pieces_.insert(pieces_.begin(), Piece{src, start, len});
|
||||||
total_size_ += 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
|
// Ensure there is a null terminator present via std::string invariants
|
||||||
dirty_ = false;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+222
-19
@@ -1,19 +1,63 @@
|
|||||||
/*
|
/*
|
||||||
* PieceTable.h - Alternative to GapBuffer using a piece table representation
|
* PieceTable.h - Alternative to GapBuffer using a piece table representation
|
||||||
|
*
|
||||||
|
* PieceTable is kte's core text storage data structure. It provides efficient
|
||||||
|
* insert/delete operations without copying the entire buffer by maintaining a
|
||||||
|
* sequence of "pieces" that reference ranges in two underlying buffers:
|
||||||
|
* - original_: A loaded file's content (AdoptOriginal), never modified
|
||||||
|
* - add_: All text added during editing
|
||||||
|
*
|
||||||
|
* Key advantages:
|
||||||
|
* - O(1) append/prepend operations (common case)
|
||||||
|
* - O(n) insert/delete at arbitrary positions (n = number of pieces, not bytes)
|
||||||
|
* - Efficient undo: just restore the piece list
|
||||||
|
* - Memory efficient: no gap buffer waste
|
||||||
|
*
|
||||||
|
* Performance characteristics:
|
||||||
|
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
|
||||||
|
* - Materialization (Data() call) is O(total_size) but cached until next edit;
|
||||||
|
* a single-piece table (an unedited file) is viewed in place, not copied
|
||||||
|
* - Line index is lazily rebuilt on first line-based query after edits
|
||||||
|
* - Range and Find operations use lightweight caches for repeated queries
|
||||||
|
*
|
||||||
|
* API evolution:
|
||||||
|
* 1. Legacy API (GapBuffer compatibility):
|
||||||
|
* - Append/Prepend: Build content sequentially
|
||||||
|
* - Data(): Materialize entire buffer
|
||||||
|
*
|
||||||
|
* 2. New buffer-wide API (Phase 1):
|
||||||
|
* - Insert/Delete: Edit at arbitrary byte offsets
|
||||||
|
* - Line-based queries: LineCount, GetLine, GetLineRange
|
||||||
|
* - Position conversion: ByteOffsetToLineCol, LineColToByteOffset
|
||||||
|
* - Efficient extraction: GetRange, Find, WriteToStream
|
||||||
|
*
|
||||||
|
* Implementation notes:
|
||||||
|
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
|
||||||
|
* - Not thread-safe: even reads update caches. Buffers are used from the
|
||||||
|
* main thread only (the swap writer thread gets copies of the bytes).
|
||||||
|
* - Version tracking invalidates caches on mutations
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_PIECETABLE_H
|
#pragma once
|
||||||
#define KTE_PIECETABLE_H
|
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <ostream>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <limits>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "TextSpan.h"
|
||||||
|
|
||||||
|
|
||||||
class PieceTable {
|
class PieceTable {
|
||||||
public:
|
public:
|
||||||
PieceTable();
|
PieceTable();
|
||||||
|
|
||||||
explicit PieceTable(std::size_t initialCapacity);
|
// Advanced constructor allowing configuration of consolidation heuristics
|
||||||
|
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);
|
PieceTable(const PieceTable &other);
|
||||||
|
|
||||||
@@ -25,9 +69,6 @@ public:
|
|||||||
|
|
||||||
~PieceTable();
|
~PieceTable();
|
||||||
|
|
||||||
// Public API mirrors GapBuffer
|
|
||||||
void Reserve(std::size_t newCapacity);
|
|
||||||
|
|
||||||
void AppendChar(char c);
|
void AppendChar(char c);
|
||||||
|
|
||||||
void Append(const char *s, std::size_t len);
|
void Append(const char *s, std::size_t len);
|
||||||
@@ -43,18 +84,40 @@ public:
|
|||||||
// Content management
|
// Content management
|
||||||
void Clear();
|
void Clear();
|
||||||
|
|
||||||
|
// Replace the content with `bytes`, taken over without copying (a loaded
|
||||||
|
// file). Undo spans into the old storage are invalidated, as by Clear().
|
||||||
|
// If it throws (std::bad_alloc), the content is unchanged.
|
||||||
|
void AdoptOriginal(std::string &&bytes);
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
char *Data()
|
// The whole text, contiguous; nullptr when empty. Valid until the next
|
||||||
|
// modification.
|
||||||
|
[[nodiscard]] const char *Data() const
|
||||||
{
|
{
|
||||||
materialize();
|
const std::string_view v = ContentView();
|
||||||
return materialized_.empty() ? nullptr : materialized_.data();
|
return v.empty() ? nullptr : v.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] const char *Data() const
|
// The whole text as one view: in place for a single piece, else the
|
||||||
|
// materialized copy (built once per modification). Valid until the next
|
||||||
|
// modification.
|
||||||
|
[[nodiscard]] std::string_view ContentView() const;
|
||||||
|
|
||||||
|
// A view of [byte_offset, byte_offset + len), clamped to the content: in
|
||||||
|
// place when the range lies within one piece (no copy), else into the
|
||||||
|
// materialized text. Valid until the next modification.
|
||||||
|
[[nodiscard]] std::string_view View(std::size_t byte_offset, std::size_t len) const;
|
||||||
|
|
||||||
|
// Call fn(const char *data, std::size_t len) for each piece, in order:
|
||||||
|
// the content without materializing it.
|
||||||
|
template<typename Fn>
|
||||||
|
void ForEachChunk(Fn &&fn) const
|
||||||
{
|
{
|
||||||
const_cast<PieceTable *>(this)->materialize();
|
for (const Piece &p: pieces_) {
|
||||||
return materialized_.empty() ? nullptr : materialized_.data();
|
if (p.len > 0)
|
||||||
|
fn((p.src == Source::Original ? original_ : add_).data() + p.start, p.len);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -64,11 +127,66 @@ 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
|
for (const TextSpan &sp: spans) {
|
||||||
return materialized_.capacity();
|
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:
|
private:
|
||||||
enum class Source : unsigned char { Original, Add };
|
enum class Source : unsigned char { Original, Add };
|
||||||
@@ -85,14 +203,99 @@ private:
|
|||||||
|
|
||||||
void materialize() const;
|
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
|
// Underlying storages
|
||||||
std::string original_; // unused for builder use-case, but kept for API symmetry
|
std::string original_; // a loaded file's bytes (AdoptOriginal)
|
||||||
std::string add_;
|
std::string add_;
|
||||||
std::vector<Piece> pieces_;
|
std::vector<Piece> pieces_;
|
||||||
|
|
||||||
mutable std::string materialized_;
|
mutable std::string materialized_;
|
||||||
mutable bool dirty_ = true;
|
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;
|
std::size_t total_size_ = 0;
|
||||||
};
|
|
||||||
|
|
||||||
#endif // KTE_PIECETABLE_H
|
// 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_;
|
||||||
|
};
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
#include "PythonHighlighter.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include <cctype>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
|
||||||
static bool is_ident_start(char c){ return std::isalpha(static_cast<unsigned char>(c)) || c=='_'; }
|
|
||||||
static bool is_ident_char(char c){ return std::isalnum(static_cast<unsigned char>(c)) || c=='_'; }
|
|
||||||
|
|
||||||
PythonHighlighter::PythonHighlighter()
|
|
||||||
{
|
|
||||||
const char* kw[] = {"and","as","assert","break","class","continue","def","del","elif","else","except","False","finally","for","from","global","if","import","in","is","lambda","None","nonlocal","not","or","pass","raise","return","True","try","while","with","yield"};
|
|
||||||
for (auto s: kw) kws_.insert(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
void PythonHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
LineState st; (void)HighlightLineStateful(buf, row, st, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatefulHighlighter::LineState PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
StatefulHighlighter::LineState state = prev;
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return state;
|
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
int n = static_cast<int>(s.size());
|
|
||||||
|
|
||||||
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
|
||||||
if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) {
|
|
||||||
auto pos = s.find(state.raw_delim);
|
|
||||||
if (pos == std::string::npos) {
|
|
||||||
push(out, 0, n, TokenKind::String);
|
|
||||||
return state; // still inside
|
|
||||||
} else {
|
|
||||||
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
|
|
||||||
push(out, 0, end, TokenKind::String);
|
|
||||||
// remainder processed normally
|
|
||||||
s = s.substr(end);
|
|
||||||
n = static_cast<int>(s.size());
|
|
||||||
state.in_raw_string = false; state.raw_delim.clear();
|
|
||||||
// Continue parsing remainder as a separate small loop
|
|
||||||
int base = end; // original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
|
|
||||||
// For simplicity, mark rest as Default
|
|
||||||
if (n>0) push(out, base, base + n, TokenKind::Default);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int i = 0;
|
|
||||||
// Detect comment start '#', ignoring inside strings
|
|
||||||
while (i < n) {
|
|
||||||
char c = s[i];
|
|
||||||
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
|
||||||
if (c=='#') { push(out,i,n,TokenKind::Comment); break; }
|
|
||||||
// Strings: triple quotes and single-line
|
|
||||||
if (c=='"' || c=='\'') {
|
|
||||||
char q=c;
|
|
||||||
// triple?
|
|
||||||
if (i+2 < n && s[i+1]==q && s[i+2]==q) {
|
|
||||||
std::string delim(3, q);
|
|
||||||
int j = i+3; // search for closing triple
|
|
||||||
auto pos = s.find(delim, static_cast<std::size_t>(j));
|
|
||||||
if (pos == std::string::npos) {
|
|
||||||
push(out,i,n,TokenKind::String);
|
|
||||||
state.in_raw_string = true; state.raw_delim = delim; return state;
|
|
||||||
} else {
|
|
||||||
int end = static_cast<int>(pos + 3);
|
|
||||||
push(out,i,end,TokenKind::String); i=end; continue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
int j=i+1; bool esc=false; while (j<n) { char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d==q) break; }
|
|
||||||
push(out,i,j,TokenKind::String); i=j; continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='_' )) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
|
|
||||||
if (is_ident_start(c)) { int j=i+1; while (j<n && is_ident_char(s[j])) ++j; std::string id=s.substr(i,j-i); TokenKind k=TokenKind::Identifier; if (kws_.count(id)) k=TokenKind::Keyword; push(out,i,j,k); i=j; continue; }
|
|
||||||
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Operator; if (c==':'||c==','||c=='('||c==')'||c=='['||c==']') k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
|
|
||||||
push(out,i,i+1,TokenKind::Default); ++i;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// PythonHighlighter.h - simple Python highlighter with triple-quote state
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "LanguageHighlighter.h"
|
|
||||||
#include <unordered_set>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
class PythonHighlighter final : public StatefulHighlighter {
|
|
||||||
public:
|
|
||||||
PythonHighlighter();
|
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
|
||||||
LineState HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const override;
|
|
||||||
private:
|
|
||||||
std::unordered_set<std::string> kws_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -32,27 +32,27 @@ Project Goals
|
|||||||
|
|
||||||
Keybindings
|
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
|
- K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or
|
||||||
`C-g`.
|
`C-g`.
|
||||||
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
|
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
|
||||||
`C-k q` (quit with confirm), `C-k C-q` (quit immediately).
|
`C-k q` (quit with confirm), `C-k C-q` (quit immediately).
|
||||||
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-k
|
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
|
||||||
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u`
|
region), `C-y` (yank), `C-u` (universal argument).
|
||||||
(universal argument).
|
|
||||||
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
||||||
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
|
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
|
||||||
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
|
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
|
||||||
(close), `C-k C-r` (reload).
|
(close), `C-k l` (reload).
|
||||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g`
|
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
|
||||||
(goto line).
|
|
||||||
|
|
||||||
See `ke.md` for the canonical ke reference retained for now.
|
See `ke.md` for the canonical ke reference retained for now.
|
||||||
|
|
||||||
Build and Run
|
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
|
Dependencies by platform
|
||||||
------------------------
|
------------------------
|
||||||
@@ -62,30 +62,38 @@ Dependencies by platform
|
|||||||
- `brew install ncurses`
|
- `brew install ncurses`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- `brew install sdl2 freetype`
|
- `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
|
- Debian/Ubuntu
|
||||||
- Terminal (default):
|
- Terminal (default):
|
||||||
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- 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
|
- NixOS/Nix
|
||||||
- Terminal (default):
|
- Terminal (default):
|
||||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
- Ad-hoc shell:
|
||||||
- With flakes/devshell (example `flake.nix` inputs not provided): include
|
`nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
||||||
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell.
|
- With flakes/devshell (example `flake.nix` inputs not provided):
|
||||||
|
include
|
||||||
|
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your
|
||||||
|
devShell.
|
||||||
|
|
||||||
Notes
|
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
|
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
|
||||||
installed for your platform.
|
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`.
|
the build directory with `-DBUILD_GUI=OFF`.
|
||||||
|
|
||||||
Example build:
|
Example build:
|
||||||
@@ -113,7 +121,8 @@ built as `kge`) or request the GUI from `kte`:
|
|||||||
GUI build example
|
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
|
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON
|
||||||
|
|||||||
+2501
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -2,9 +2,12 @@ ROADMAP / TODO:
|
|||||||
|
|
||||||
- [x] Search + Replace
|
- [x] Search + Replace
|
||||||
- [x] Regex search + replace
|
- [x] Regex search + replace
|
||||||
- [ ] The undo system should actually work
|
|
||||||
- [x] Able to mark buffers as read-only
|
- [x] Able to mark buffers as read-only
|
||||||
- [x] Built-in help text
|
- [x] Built-in help text
|
||||||
- [x] Shorten paths in the homedir with ~
|
- [x] Shorten paths in the homedir with ~
|
||||||
- [x] When the filename is longer than the message window, scoot left to
|
- [x] When the filename is longer than the message window, scoot left to
|
||||||
keep it in view
|
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
-5
@@ -1,9 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* Renderer.h - rendering abstraction
|
* Renderer.h - rendering abstraction
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_RENDERER_H
|
#pragma once
|
||||||
#define KTE_RENDERER_H
|
|
||||||
|
|
||||||
|
|
||||||
class Editor;
|
class Editor;
|
||||||
|
|
||||||
@@ -13,5 +11,3 @@ public:
|
|||||||
|
|
||||||
virtual void Draw(Editor &ed) = 0;
|
virtual void Draw(Editor &ed) = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_RENDERER_H
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
#include "RustHighlighter.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include <cctype>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
|
||||||
static bool is_ident_start(char c){ return std::isalpha(static_cast<unsigned char>(c)) || c=='_'; }
|
|
||||||
static bool is_ident_char(char c){ return std::isalnum(static_cast<unsigned char>(c)) || c=='_'; }
|
|
||||||
|
|
||||||
RustHighlighter::RustHighlighter()
|
|
||||||
{
|
|
||||||
const char* kw[] = {"as","break","const","continue","crate","else","enum","extern","false","fn","for","if","impl","in","let","loop","match","mod","move","mut","pub","ref","return","self","Self","static","struct","super","trait","true","type","unsafe","use","where","while","dyn","async","await","try"};
|
|
||||||
for (auto s: kw) kws_.insert(s);
|
|
||||||
const char* tp[] = {"u8","u16","u32","u64","u128","usize","i8","i16","i32","i64","i128","isize","f32","f64","bool","char","str"};
|
|
||||||
for (auto s: tp) types_.insert(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
void RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
int n = static_cast<int>(s.size());
|
|
||||||
int i = 0;
|
|
||||||
while (i < n) {
|
|
||||||
char c = s[i];
|
|
||||||
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
|
||||||
if (c=='/' && i+1<n && s[i+1]=='/') { push(out,i,n,TokenKind::Comment); break; }
|
|
||||||
if (c=='/' && i+1<n && s[i+1]=='*') { int j=i+2; bool closed=false; while (j+1<=n) { if (j+1<n && s[j]=='*' && s[j+1]=='/') { j+=2; closed=true; break; } ++j; } if (!closed) { push(out,i,n,TokenKind::Comment); break; } else { push(out,i,j,TokenKind::Comment); i=j; continue; } }
|
|
||||||
if (c=='"') { int j=i+1; bool esc=false; while (j<n){ char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break; } push(out,i,j,TokenKind::String); i=j; continue; }
|
|
||||||
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='_' )) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
|
|
||||||
if (is_ident_start(c)) { int j=i+1; while (j<n && is_ident_char(s[j])) ++j; std::string id=s.substr(i,j-i); TokenKind k=TokenKind::Identifier; if (kws_.count(id)) k=TokenKind::Keyword; else if (types_.count(id)) k=TokenKind::Type; push(out,i,j,k); i=j; continue; }
|
|
||||||
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Operator; if (c==';'||c==','||c=='('||c==')'||c=='{'||c=='}'||c=='['||c==']') k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
|
|
||||||
push(out,i,i+1,TokenKind::Default); ++i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// RustHighlighter.h - simple Rust highlighter
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "LanguageHighlighter.h"
|
|
||||||
#include <unordered_set>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
class RustHighlighter final : public LanguageHighlighter {
|
|
||||||
public:
|
|
||||||
RustHighlighter();
|
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
|
||||||
private:
|
|
||||||
std::unordered_set<std::string> kws_;
|
|
||||||
std::unordered_set<std::string> types_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace kte
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#include "SearchHighlight.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
SearchHighlight::SearchHighlight(const Editor &ed)
|
||||||
|
{
|
||||||
|
active_ = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||||
|
if (!active_)
|
||||||
|
return;
|
||||||
|
query_ = ed.SearchQuery();
|
||||||
|
regex_ = ed.PromptActive() && (ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
|
||||||
|
ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind);
|
||||||
|
if (regex_) {
|
||||||
|
std::string err;
|
||||||
|
rx_valid_ = rx_.Compile(query_, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
SearchHighlight::Ranges(std::string_view line, std::vector<std::pair<std::size_t, std::size_t> > &out) const
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
if (!active_)
|
||||||
|
return;
|
||||||
|
if (regex_) {
|
||||||
|
if (!rx_valid_ || line.size() > Regex::IncrementalLineLimit())
|
||||||
|
return;
|
||||||
|
std::size_t from = 0, pos = 0, len = 0;
|
||||||
|
while (from <= line.size() && rx_.Search(line, from, pos, len)) {
|
||||||
|
out.emplace_back(pos, pos + len);
|
||||||
|
from = pos + std::max<std::size_t>(len, 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while ((pos = line.find(query_, pos)) != std::string_view::npos) {
|
||||||
|
out.emplace_back(pos, pos + query_.size());
|
||||||
|
pos += query_.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace kte
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* SearchHighlight.h - the search-match ranges every renderer highlights.
|
||||||
|
*
|
||||||
|
* Built once per frame from the editor's search state (the regex, when the
|
||||||
|
* regex search or regex replace prompt is up, is compiled here once rather
|
||||||
|
* than per line), then asked for each visible line's matches.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "RegexEngine.h"
|
||||||
|
|
||||||
|
class Editor;
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
class SearchHighlight {
|
||||||
|
public:
|
||||||
|
explicit SearchHighlight(const Editor &ed);
|
||||||
|
|
||||||
|
// Whether a search is up with a non-empty query.
|
||||||
|
[[nodiscard]] bool Active() const
|
||||||
|
{
|
||||||
|
return active_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The matches in one line as [start, end) byte ranges, ordered and not
|
||||||
|
// overlapping. Nothing for an invalid pattern (the status line shows the
|
||||||
|
// error) or, in regex mode, a line longer than
|
||||||
|
// Regex::IncrementalLineLimit().
|
||||||
|
void Ranges(std::string_view line, std::vector<std::pair<std::size_t, std::size_t> > &out) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool active_ = false;
|
||||||
|
bool regex_ = false;
|
||||||
|
bool rx_valid_ = false;
|
||||||
|
std::string query_;
|
||||||
|
Regex rx_;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#include "ShellHighlighter.h"
|
|
||||||
#include "Buffer.h"
|
|
||||||
#include <cctype>
|
|
||||||
|
|
||||||
namespace kte {
|
|
||||||
|
|
||||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
|
||||||
|
|
||||||
void ShellHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
|
||||||
{
|
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
|
||||||
int n = static_cast<int>(s.size());
|
|
||||||
int i = 0;
|
|
||||||
// if first non-space is '#', whole line is comment
|
|
||||||
int bol = 0; while (bol < n && (s[bol]==' '||s[bol]=='\t')) ++bol;
|
|
||||||
if (bol < n && s[bol] == '#') { push(out, bol, n, TokenKind::Comment); if (bol>0) push(out,0,bol,TokenKind::Whitespace); return; }
|
|
||||||
while (i < n) {
|
|
||||||
char c = s[i];
|
|
||||||
if (c == ' ' || c == '\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
|
||||||
if (c == '#') { push(out, i, n, TokenKind::Comment); break; }
|
|
||||||
if (c == '\'' || c == '"') {
|
|
||||||
char q = c; int j = i+1; bool esc=false; while (j<n) { char d=s[j++]; if (q=='"') { if (esc) {esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break; } else { if (d=='\'') break; } }
|
|
||||||
push(out,i,j,TokenKind::String); i=j; continue;
|
|
||||||
}
|
|
||||||
// simple keywords
|
|
||||||
if (std::isalpha(static_cast<unsigned char>(c))) {
|
|
||||||
int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='_')) ++j; std::string id=s.substr(i,j-i);
|
|
||||||
static const char* kws[] = {"if","then","fi","for","in","do","done","case","esac","while","function","elif","else"};
|
|
||||||
bool kw=false; for (auto k: kws) if (id==k) { kw=true; break; }
|
|
||||||
push(out,i,j, kw?TokenKind::Keyword:TokenKind::Identifier); i=j; continue;
|
|
||||||
}
|
|
||||||
if (std::ispunct(static_cast<unsigned char>(c))) {
|
|
||||||
TokenKind k = TokenKind::Operator;
|
|
||||||
if (c=='('||c==')'||c=='{'||c=='}'||c==','||c==';') k=TokenKind::Punctuation;
|
|
||||||
push(out,i,i+1,k); ++i; continue;
|
|
||||||
}
|
|
||||||
push(out,i,i+1,TokenKind::Default); ++i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // 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
|
||||||
+83
-11
@@ -1,3 +1,6 @@
|
|||||||
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
|
#include <string>
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
#include <termios.h>
|
#include <termios.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
@@ -8,8 +11,16 @@
|
|||||||
|
|
||||||
|
|
||||||
bool
|
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)
|
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
|
||||||
{
|
{
|
||||||
struct termios tio{};
|
struct termios tio{};
|
||||||
@@ -41,20 +52,41 @@ TerminalFrontend::Init(Editor &ed)
|
|||||||
// Enable 8-bit meta key sequences (Alt/ESC-prefix handling in terminals)
|
// Enable 8-bit meta key sequences (Alt/ESC-prefix handling in terminals)
|
||||||
meta(stdscr, TRUE);
|
meta(stdscr, TRUE);
|
||||||
// Make ESC key sequences resolve quickly so ESC+<key> works as meta
|
// Make ESC key sequences resolve quickly so ESC+<key> works as meta
|
||||||
#ifdef set_escdelay
|
// set_escdelay is an ncurses extension function (not a macro), so test for
|
||||||
set_escdelay(50);
|
// ncurses itself rather than `#ifdef set_escdelay`, which is always false.
|
||||||
|
#if defined(NCURSES_VERSION)
|
||||||
|
set_escdelay(TerminalFrontend::kEscDelayMs);
|
||||||
#endif
|
#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);
|
curs_set(1);
|
||||||
// Enable mouse support if available
|
// Enable mouse support if available
|
||||||
mouseinterval(0);
|
mouseinterval(0);
|
||||||
mousemask(ALL_MOUSE_EVENTS, nullptr);
|
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, nullptr);
|
||||||
|
|
||||||
int r = 0, c = 0;
|
int r = 0, c = 0;
|
||||||
getmaxyx(stdscr, r, c);
|
getmaxyx(stdscr, r, c);
|
||||||
prev_r_ = r;
|
prev_r_ = r;
|
||||||
prev_c_ = c;
|
prev_c_ = c;
|
||||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||||
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,29 +97,64 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
|||||||
// Handle resize and keep editor dimensions synced
|
// Handle resize and keep editor dimensions synced
|
||||||
int r, c;
|
int r, c;
|
||||||
getmaxyx(stdscr, r, c);
|
getmaxyx(stdscr, r, c);
|
||||||
|
bool changed = first_frame_;
|
||||||
if (r != prev_r_ || c != prev_c_) {
|
if (r != prev_r_ || c != prev_c_) {
|
||||||
resizeterm(r, c);
|
resizeterm(r, c);
|
||||||
clear();
|
clear();
|
||||||
prev_r_ = r;
|
prev_r_ = r;
|
||||||
prev_c_ = c;
|
prev_c_ = c;
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
// Its return value only reports an opened buffer; a deferred open that
|
||||||
|
// starts a prompt or sets a status must be drawn too.
|
||||||
|
{
|
||||||
|
const bool prompt_before = ed.PromptActive();
|
||||||
|
const std::string status_before = ed.Status();
|
||||||
|
const std::time_t status_time_before = ed.StatusTime();
|
||||||
|
if (ed.ProcessPendingOpens())
|
||||||
|
changed = true;
|
||||||
|
if (ed.PromptActive() != prompt_before || ed.Status() != status_before ||
|
||||||
|
ed.StatusTime() != status_time_before)
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle all pending input before drawing: one key per frame made a
|
||||||
|
// pasted block take a frame (and a full redraw) per character. The first
|
||||||
|
// read waits up to the configured timeout; the rest do not wait.
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
int handled = 0;
|
||||||
if (mi.hasCommand) {
|
while (handled < 4096 && input_.PollKey(mi)) {
|
||||||
|
changed = true;
|
||||||
|
if (mi.hasCommand)
|
||||||
Execute(ed, mi.id, mi.arg, mi.count);
|
Execute(ed, mi.id, mi.arg, mi.count);
|
||||||
|
if (ed.QuitRequested())
|
||||||
|
break;
|
||||||
|
if (++handled == 1)
|
||||||
|
timeout(0);
|
||||||
}
|
}
|
||||||
} else {
|
if (handled > 0)
|
||||||
// Avoid busy loop
|
timeout(16);
|
||||||
usleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ed.QuitRequested()) {
|
if (ed.QuitRequested()) {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redraw after input or a resize, and otherwise about once a second
|
||||||
|
// (status messages, deferred work): redrawing every 16 ms kept a core
|
||||||
|
// busy on large or long-line files while idle.
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
if (changed || now - last_draw_ >= std::chrono::milliseconds(1000)) {
|
||||||
|
try {
|
||||||
renderer_.Draw(ed);
|
renderer_.Draw(ed);
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
ed.SetStatus(std::string("Display error: ") + e.what());
|
||||||
|
}
|
||||||
|
last_draw_ = now;
|
||||||
|
first_frame_ = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -99,5 +166,10 @@ TerminalFrontend::Shutdown()
|
|||||||
(void) tcsetattr(STDIN_FILENO, TCSANOW, &orig_tio_);
|
(void) tcsetattr(STDIN_FILENO, TCSANOW, &orig_tio_);
|
||||||
have_orig_tio_ = false;
|
have_orig_tio_ = false;
|
||||||
}
|
}
|
||||||
|
// Restore previous SIGINT handler
|
||||||
|
if (have_old_sigint_) {
|
||||||
|
(void) sigaction(SIGINT, &old_sigint_, nullptr);
|
||||||
|
have_old_sigint_ = false;
|
||||||
|
}
|
||||||
endwin();
|
endwin();
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-6
@@ -1,10 +1,10 @@
|
|||||||
/*
|
/*
|
||||||
* TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle
|
* TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_TERMINAL_FRONTEND_H
|
#pragma once
|
||||||
#define KTE_TERMINAL_FRONTEND_H
|
#include <chrono>
|
||||||
|
|
||||||
#include <termios.h>
|
#include <termios.h>
|
||||||
|
#include <signal.h>
|
||||||
|
|
||||||
#include "Frontend.h"
|
#include "Frontend.h"
|
||||||
#include "TerminalInputHandler.h"
|
#include "TerminalInputHandler.h"
|
||||||
@@ -17,7 +17,12 @@ public:
|
|||||||
|
|
||||||
~TerminalFrontend() override = default;
|
~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;
|
void Step(Editor &ed, bool &running) override;
|
||||||
|
|
||||||
@@ -28,9 +33,13 @@ private:
|
|||||||
TerminalRenderer renderer_{};
|
TerminalRenderer renderer_{};
|
||||||
int prev_r_ = 0;
|
int prev_r_ = 0;
|
||||||
int prev_c_ = 0;
|
int prev_c_ = 0;
|
||||||
|
// Redraw only when something may have changed (see Step).
|
||||||
|
bool first_frame_ = true;
|
||||||
|
std::chrono::steady_clock::time_point last_draw_{};
|
||||||
// Saved terminal attributes to restore on shutdown
|
// Saved terminal attributes to restore on shutdown
|
||||||
bool have_orig_tio_ = false;
|
bool have_orig_tio_ = false;
|
||||||
struct termios orig_tio_{};
|
struct termios orig_tio_{};
|
||||||
|
// Saved SIGINT handler to restore on shutdown
|
||||||
|
bool have_old_sigint_ = false;
|
||||||
|
struct sigaction old_sigint_{};
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_TERMINAL_FRONTEND_H
|
|
||||||
|
|||||||
+261
-125
@@ -1,8 +1,12 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <climits>
|
||||||
|
#include <cwchar>
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
|
|
||||||
#include "TerminalInputHandler.h"
|
#include "TerminalInputHandler.h"
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Editor.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int
|
constexpr int
|
||||||
@@ -10,6 +14,22 @@ CTRL(char c)
|
|||||||
{
|
{
|
||||||
return c & 0x1F;
|
return c & 0x1F;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Encode a single wide character in the process locale's multibyte encoding
|
||||||
|
// (UTF-8, given main.cc's setlocale(LC_ALL, "")). Returns false if the
|
||||||
|
// codepoint can't be represented, leaving `out` untouched.
|
||||||
|
bool
|
||||||
|
wchar_to_mb(wchar_t wc, std::string &out)
|
||||||
|
{
|
||||||
|
std::mbstate_t state{};
|
||||||
|
char buf[MB_LEN_MAX];
|
||||||
|
std::size_t n = std::wcrtomb(buf, wc, &state);
|
||||||
|
if (n == static_cast<std::size_t>(-1))
|
||||||
|
return false;
|
||||||
|
out.assign(buf, n);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TerminalInputHandler::TerminalInputHandler() = default;
|
TerminalInputHandler::TerminalInputHandler() = default;
|
||||||
@@ -19,77 +39,145 @@ TerminalInputHandler::~TerminalInputHandler() = default;
|
|||||||
|
|
||||||
static bool
|
static bool
|
||||||
map_key_to_command(const int ch,
|
map_key_to_command(const int ch,
|
||||||
|
const bool is_keycode,
|
||||||
bool &k_prefix,
|
bool &k_prefix,
|
||||||
bool &esc_meta,
|
bool &esc_meta,
|
||||||
// universal-argument state (by ref)
|
bool &k_ctrl_pending,
|
||||||
bool &uarg_active,
|
bool &mouse_selecting,
|
||||||
bool &uarg_collecting,
|
Editor *ed,
|
||||||
bool &uarg_negative,
|
|
||||||
bool &uarg_had_digits,
|
|
||||||
int &uarg_value,
|
|
||||||
std::string &uarg_text,
|
|
||||||
MappedInput &out)
|
MappedInput &out)
|
||||||
{
|
{
|
||||||
// Handle special keys from ncurses
|
// Handle special keys from ncurses. These are only meaningful when
|
||||||
switch (ch) {
|
// 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).
|
||||||
|
//
|
||||||
|
// 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: {
|
case KEY_MOUSE: {
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
MEVENT ev{};
|
MEVENT ev{};
|
||||||
if (getmouse(&ev) == OK) {
|
if (getmouse(&ev) == OK) {
|
||||||
// Mouse wheel → map to MoveUp/MoveDown one line per wheel notch
|
// Mouse wheel → scroll viewport without moving cursor
|
||||||
#ifdef BUTTON4_PRESSED
|
#ifdef BUTTON4_PRESSED
|
||||||
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
|
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
|
||||||
out = {true, CommandId::MoveUp, "", 0};
|
out = {true, CommandId::ScrollUp, "", 0};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#ifdef BUTTON5_PRESSED
|
#ifdef BUTTON5_PRESSED
|
||||||
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
|
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
|
||||||
out = {true, CommandId::MoveDown, "", 0};
|
out = {true, CommandId::ScrollDown, "", 0};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
// React to left button click/press
|
// 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];
|
char buf[64];
|
||||||
// Use screen coordinates; command handler will translate via offsets
|
// Use screen coordinates; command handler will translate via offsets
|
||||||
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
|
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
|
||||||
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
|
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;
|
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
|
// No actionable mouse event
|
||||||
out.hasCommand = false;
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case KEY_LEFT:
|
case KEY_LEFT:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveLeft, "", 0};
|
out = {true, CommandId::MoveLeft, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_RIGHT:
|
case KEY_RIGHT:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveRight, "", 0};
|
out = {true, CommandId::MoveRight, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_UP:
|
case KEY_UP:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveUp, "", 0};
|
out = {true, CommandId::MoveUp, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_DOWN:
|
case KEY_DOWN:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveDown, "", 0};
|
out = {true, CommandId::MoveDown, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_HOME:
|
case KEY_HOME:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveHome, "", 0};
|
out = {true, CommandId::MoveHome, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_END:
|
case KEY_END:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::MoveEnd, "", 0};
|
out = {true, CommandId::MoveEnd, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_PPAGE:
|
case KEY_PPAGE:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::PageUp, "", 0};
|
out = {true, CommandId::PageUp, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_NPAGE:
|
case KEY_NPAGE:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::PageDown, "", 0};
|
out = {true, CommandId::PageDown, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_DC:
|
case KEY_DC:
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::DeleteChar, "", 0};
|
out = {true, CommandId::DeleteChar, "", 0};
|
||||||
return true;
|
return true;
|
||||||
case KEY_RESIZE:
|
case KEY_RESIZE:
|
||||||
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;
|
return true;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -99,67 +187,73 @@ map_key_to_command(const int ch,
|
|||||||
if (ch == 27) {
|
if (ch == 27) {
|
||||||
// ESC
|
// ESC
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
esc_meta = true; // next key will be considered meta-modified
|
esc_meta = true; // next key will be considered meta-modified
|
||||||
out.hasCommand = false; // no command yet
|
out.hasCommand = false; // no command yet
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meta (ESC-prefixed) keys: backspace, Enter and printable keys use the
|
||||||
|
// ESC keymap. Checked before control chords so ESC ^H (meta-backspace) is
|
||||||
|
// not taken as C-h, and before Enter so ESC Enter reaches SmartNewline.
|
||||||
|
// Other control chords after ESC act as themselves.
|
||||||
|
const bool is_backspace = (is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H');
|
||||||
|
const bool meta_key = meta && (is_backspace || (!is_keycode && (ch == '\n' || ch == '\r' || ch >= 0x20)));
|
||||||
|
if (meta_key) {
|
||||||
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
|
int ascii_key = ch;
|
||||||
|
if (is_backspace) {
|
||||||
|
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
||||||
|
} else if (ch == ',') {
|
||||||
|
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
||||||
|
ascii_key = '<';
|
||||||
|
} else if (ch == '.') {
|
||||||
|
// Likewise, map '.' to '>'
|
||||||
|
ascii_key = '>';
|
||||||
|
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
|
||||||
|
ascii_key = ascii_key - 'A' + 'a';
|
||||||
|
}
|
||||||
|
CommandId id;
|
||||||
|
if (KLookupEscCommand(ascii_key, id)) {
|
||||||
|
out = {true, id, "", 0};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Unhandled ESC sequence: exit escape mode and show status
|
||||||
|
out = {true, CommandId::UnknownEscCommand, "", 0};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Control keys
|
// Control keys
|
||||||
if (ch == CTRL('K')) {
|
if (ch == CTRL('K')) {
|
||||||
// C-k prefix
|
// C-k prefix
|
||||||
k_prefix = true;
|
k_prefix = true;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::KPrefix, "", 0};
|
out = {true, CommandId::KPrefix, "", 0};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (ch == CTRL('G')) {
|
if (ch == CTRL('G')) {
|
||||||
// cancel
|
// cancel
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
esc_meta = false;
|
esc_meta = false;
|
||||||
// cancel universal argument as well
|
// cancel universal argument as well
|
||||||
uarg_active = false;
|
if (ed)
|
||||||
uarg_collecting = false;
|
ed->UArgClear();
|
||||||
uarg_negative = false;
|
|
||||||
uarg_had_digits = false;
|
|
||||||
uarg_value = 0;
|
|
||||||
uarg_text.clear();
|
|
||||||
out = {true, CommandId::Refresh, "", 0};
|
out = {true, CommandId::Refresh, "", 0};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Universal argument: C-u
|
// Universal argument: C-u
|
||||||
if (ch == CTRL('U')) {
|
if (ch == CTRL('U')) {
|
||||||
// Start or extend universal argument
|
if (ed)
|
||||||
if (!uarg_active) {
|
ed->UArgStart();
|
||||||
uarg_active = true;
|
out.hasCommand = false; // C-u itself doesn't issue a command
|
||||||
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;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Tab (note: terminals encode Tab and C-i as the same code 9)
|
// Tab (note: terminals encode Tab and C-i as the same code 9)
|
||||||
if (ch == '\t') {
|
if (ch == '\t') {
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out.hasCommand = true;
|
out.hasCommand = true;
|
||||||
out.id = CommandId::InsertText;
|
out.id = CommandId::InsertText;
|
||||||
out.arg = "\t";
|
out.arg = "\t";
|
||||||
@@ -170,22 +264,40 @@ map_key_to_command(const int ch,
|
|||||||
// IMPORTANT: if we're in k-prefix, the very next key must be interpreted
|
// 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.
|
// via the C-k keymap first, even if it's a Control chord like C-d.
|
||||||
if (k_prefix) {
|
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;
|
bool ctrl = false;
|
||||||
int ascii_key = ch;
|
int ascii_key = ch;
|
||||||
if (ch >= 1 && ch <= 26) {
|
if (ch >= 1 && ch <= 26) {
|
||||||
ctrl = true;
|
ctrl = true;
|
||||||
ascii_key = 'a' + (ch - 1);
|
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
|
// Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings
|
||||||
CommandId id;
|
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};
|
out = {true, id, "", 0};
|
||||||
|
if (ed)
|
||||||
|
ed->SetStatus(""); // clear "C-k _" hint after suffix
|
||||||
} else {
|
} else {
|
||||||
int shown = KLowerAscii(ascii_key);
|
int shown = KLowerAscii(ascii_key);
|
||||||
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
|
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
|
||||||
std::string arg(1, c);
|
std::string arg(1, c);
|
||||||
out = {true, CommandId::UnknownKCommand, arg, 0};
|
out = {true, CommandId::UnknownKCommand, arg, 0};
|
||||||
|
if (ed)
|
||||||
|
ed->SetStatus(""); // clear hint; handler will set unknown status
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -202,73 +314,41 @@ map_key_to_command(const int ch,
|
|||||||
// Enter
|
// Enter
|
||||||
if (ch == '\n' || ch == '\r') {
|
if (ch == '\n' || ch == '\r') {
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::Newline, "", 0};
|
out = {true, CommandId::Newline, "", 0};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// If previous key was ESC, interpret as meta and use ESC keymap
|
|
||||||
if (esc_meta) {
|
|
||||||
esc_meta = false;
|
|
||||||
int ascii_key = ch;
|
|
||||||
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
|
|
||||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
|
||||||
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
|
||||||
} else if (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
|
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
||||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
|
k_ctrl_pending = false;
|
||||||
out = {true, CommandId::Backspace, "", 0};
|
out = {true, CommandId::Backspace, "", 0};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// k_prefix handled earlier
|
// k_prefix handled earlier
|
||||||
|
|
||||||
// If collecting universal arg, handle digits and optional leading '-'
|
// If universal argument is active at editor level and we get a digit, feed it
|
||||||
if (uarg_active && uarg_collecting) {
|
if (ed && ed->UArg() != 0 && ch >= '0' && ch <= '9') {
|
||||||
if (ch >= '0' && ch <= '9') {
|
ed->UArgDigit(ch - '0');
|
||||||
int d = ch - '0';
|
out.hasCommand = false; // keep collecting, no command yet
|
||||||
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;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printable ASCII
|
// Printable character: ASCII, or (for a regular decoded wide character,
|
||||||
if (ch >= 0x20 && ch <= 0x7E) {
|
// not a keycode) any other printable Unicode codepoint - e.g. accented
|
||||||
|
// Latin, Cyrillic, CJK, etc.
|
||||||
|
if (!is_keycode && ch >= 0x20 && ch != 0x7F) {
|
||||||
|
std::string mb;
|
||||||
|
if (ch <= 0x7E) {
|
||||||
|
mb.assign(1, static_cast<char>(ch));
|
||||||
|
} else if (!wchar_to_mb(static_cast<wchar_t>(ch), mb)) {
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
out.hasCommand = true;
|
out.hasCommand = true;
|
||||||
out.id = CommandId::InsertText;
|
out.id = CommandId::InsertText;
|
||||||
out.arg.assign(1, static_cast<char>(ch));
|
out.arg = mb;
|
||||||
out.count = 0;
|
out.count = 0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -278,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
|
bool
|
||||||
TerminalInputHandler::decode_(MappedInput &out)
|
TerminalInputHandler::decode_(MappedInput &out)
|
||||||
{
|
{
|
||||||
int ch = getch();
|
int ch = 0;
|
||||||
if (ch == ERR) {
|
bool is_keycode = false;
|
||||||
|
if (!read_key(ch, is_keycode))
|
||||||
return false; // no input
|
return false; // no input
|
||||||
|
if (ch < 0) {
|
||||||
|
// Invalid byte sequence, dropped. It still ends a pending ESC or
|
||||||
|
// C-k prefix, as any other key would; left set, it applied to the
|
||||||
|
// next key instead.
|
||||||
|
esc_meta_ = false;
|
||||||
|
k_prefix_ = false;
|
||||||
|
k_ctrl_pending_ = false;
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
bool consumed = map_key_to_command(
|
bool consumed = map_key_to_command(
|
||||||
ch,
|
ch, is_keycode,
|
||||||
k_prefix_, esc_meta_,
|
k_prefix_, esc_meta_,
|
||||||
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_, uarg_text_,
|
k_ctrl_pending_,
|
||||||
|
mouse_selecting_,
|
||||||
|
ed_,
|
||||||
out);
|
out);
|
||||||
if (!consumed)
|
if (!consumed)
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-12
@@ -1,9 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* TerminalInputHandler - ncurses-based input handling for terminal mode
|
* TerminalInputHandler - ncurses-based input handling for terminal mode
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_TERMINAL_INPUT_HANDLER_H
|
#pragma once
|
||||||
#define KTE_TERMINAL_INPUT_HANDLER_H
|
|
||||||
|
|
||||||
#include "InputHandler.h"
|
#include "InputHandler.h"
|
||||||
|
|
||||||
|
|
||||||
@@ -13,23 +11,36 @@ public:
|
|||||||
|
|
||||||
~TerminalInputHandler() override;
|
~TerminalInputHandler() override;
|
||||||
|
|
||||||
|
|
||||||
|
void Attach(Editor *ed) override
|
||||||
|
{
|
||||||
|
ed_ = ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
bool Poll(MappedInput &out) override;
|
bool Poll(MappedInput &out) override;
|
||||||
|
|
||||||
|
// Read one key if available. Returns false when no input was read; true
|
||||||
|
// when a key was consumed (out.hasCommand says whether it produced a
|
||||||
|
// command: prefixes such as C-k or ESC do not).
|
||||||
|
bool PollKey(MappedInput &out)
|
||||||
|
{
|
||||||
|
out = {};
|
||||||
|
return decode_(out);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool decode_(MappedInput &out);
|
bool decode_(MappedInput &out);
|
||||||
|
|
||||||
// ke-style prefix state
|
// ke-style prefix state
|
||||||
bool k_prefix_ = false; // true after C-k until next key or ESC
|
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
|
// Simple meta (ESC) state for ESC sequences like ESC b/f
|
||||||
bool esc_meta_ = false;
|
bool esc_meta_ = false;
|
||||||
|
|
||||||
// Universal argument (C-u) state
|
// Mouse drag selection state
|
||||||
bool uarg_active_ = false; // an argument is pending for the next command
|
bool mouse_selecting_ = false;
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif // KTE_TERMINAL_INPUT_HANDLER_H
|
Editor *ed_ = nullptr; // attached editor for uarg handling
|
||||||
|
};
|
||||||
|
|||||||
+291
-128
@@ -1,12 +1,16 @@
|
|||||||
|
#include <clocale>
|
||||||
|
#define _XOPEN_SOURCE_EXTENDED 1
|
||||||
|
#include <cwchar>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <ncurses.h>
|
#include <ncurses.h>
|
||||||
#include <regex>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "TerminalRenderer.h"
|
#include "TerminalRenderer.h"
|
||||||
|
#include "SearchHighlight.h"
|
||||||
|
#include "TermWidth.h"
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "Highlight.h"
|
#include "Highlight.h"
|
||||||
@@ -34,49 +38,37 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
|
|
||||||
const Buffer *buf = ed.CurrentBuffer();
|
const Buffer *buf = ed.CurrentBuffer();
|
||||||
int content_rows = rows - 1; // last line is status
|
int content_rows = rows - 1; // last line is status
|
||||||
|
if (content_rows < 1)
|
||||||
|
content_rows = 1;
|
||||||
|
|
||||||
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
|
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
|
||||||
if (buf) {
|
if (buf) {
|
||||||
const auto &lines = buf->Rows();
|
// Per-row access only: Rows() rebuilds a string for every line of the
|
||||||
|
// file after each edit, which made every frame O(file size).
|
||||||
|
const std::size_t nlines = buf->Nrows();
|
||||||
std::size_t rowoffs = buf->Rowoffs();
|
std::size_t rowoffs = buf->Rowoffs();
|
||||||
std::size_t coloffs = buf->Coloffs();
|
std::size_t coloffs = buf->Coloffs();
|
||||||
|
|
||||||
const int tabw = 8;
|
const int tabw = static_cast<int>(kte::kTabWidth);
|
||||||
|
// Phase 3: prefetch visible viewport highlights (current terminal area)
|
||||||
|
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||||
|
int fr = static_cast<int>(rowoffs);
|
||||||
|
int rc = std::max(0, content_rows);
|
||||||
|
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search matches: the pattern is compiled once per frame.
|
||||||
|
const kte::SearchHighlight search_hl(ed);
|
||||||
|
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
|
||||||
for (int r = 0; r < content_rows; ++r) {
|
for (int r = 0; r < content_rows; ++r) {
|
||||||
move(r, 0);
|
move(r, 0);
|
||||||
std::size_t li = rowoffs + static_cast<std::size_t>(r);
|
std::size_t li = rowoffs + static_cast<std::size_t>(r);
|
||||||
std::size_t render_col = 0;
|
std::size_t render_col = 0;
|
||||||
std::size_t src_i = 0;
|
std::size_t src_i = 0;
|
||||||
// Compute matches for this line if search highlighting is active
|
// Per-row copy: GetLineView() would materialize the whole buffer
|
||||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
// after every edit.
|
||||||
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
|
const std::string line = li < nlines ? buf->GetLineString(li) : std::string();
|
||||||
if (search_mode && li < lines.size()) {
|
search_hl.Ranges(line, ranges);
|
||||||
std::string sline = static_cast<std::string>(lines[li]);
|
|
||||||
// If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges
|
|
||||||
if (ed.PromptActive() && (
|
|
||||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
|
||||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
|
||||||
try {
|
|
||||||
std::regex rx(ed.SearchQuery());
|
|
||||||
for (auto it = std::sregex_iterator(sline.begin(), sline.end(), rx);
|
|
||||||
it != std::sregex_iterator(); ++it) {
|
|
||||||
const auto &m = *it;
|
|
||||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
|
||||||
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
|
||||||
ranges.emplace_back(sx, ex);
|
|
||||||
}
|
|
||||||
} catch (const std::regex_error &) {
|
|
||||||
// ignore invalid patterns here; status shows error
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const std::string &q = ed.SearchQuery();
|
|
||||||
std::size_t pos = 0;
|
|
||||||
while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) {
|
|
||||||
ranges.emplace_back(pos, pos + q.size());
|
|
||||||
pos += q.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto is_src_in_hl = [&](std::size_t si) -> bool {
|
auto is_src_in_hl = [&](std::size_t si) -> bool {
|
||||||
if (ranges.empty())
|
if (ranges.empty())
|
||||||
return false;
|
return false;
|
||||||
@@ -95,59 +87,185 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
|
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_my = has_current ? ed.SearchMatchY() : 0;
|
||||||
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||||
bool hl_on = false;
|
|
||||||
bool cur_on = false;
|
// 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;
|
int written = 0;
|
||||||
if (li < lines.size()) {
|
if (li < nlines) {
|
||||||
std::string line = static_cast<std::string>(lines[li]);
|
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
|
||||||
|
const 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;
|
src_i = 0;
|
||||||
render_col = 0;
|
render_col = 0;
|
||||||
// Syntax highlighting: fetch per-line spans
|
// Syntax highlighting: fetch per-line spans (sanitized copy)
|
||||||
const kte::LineHighlight *lh_ptr = nullptr;
|
std::vector<kte::HighlightSpan> sane_spans;
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
||||||
lh_ptr = &buf->Highlighter()->GetLine(*buf, static_cast<int>(li), buf->Version());
|
HasHighlighter()) {
|
||||||
|
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 {
|
auto token_at = [&](std::size_t src_index) -> kte::TokenKind {
|
||||||
if (!lh_ptr) return kte::TokenKind::Default;
|
const int si = static_cast<int>(src_index);
|
||||||
for (const auto &sp: lh_ptr->spans) {
|
if (si < last_token_si)
|
||||||
if (static_cast<int>(src_index) >= sp.col_start && static_cast<int>(src_index) < sp.col_end)
|
span_i = 0; // asked about an earlier byte: restart
|
||||||
return sp.kind;
|
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;
|
return kte::TokenKind::Default;
|
||||||
};
|
};
|
||||||
auto apply_token_attr = [&](kte::TokenKind k) {
|
auto token_attr = [&](kte::TokenKind k) -> attr_t {
|
||||||
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
|
|
||||||
attrset(A_NORMAL);
|
|
||||||
switch (k) {
|
switch (k) {
|
||||||
case kte::TokenKind::Keyword:
|
case kte::TokenKind::Keyword:
|
||||||
case kte::TokenKind::Type:
|
case kte::TokenKind::Type:
|
||||||
case kte::TokenKind::Constant:
|
case kte::TokenKind::Constant:
|
||||||
case kte::TokenKind::Function:
|
case kte::TokenKind::Function:
|
||||||
attron(A_BOLD);
|
return A_BOLD;
|
||||||
break;
|
|
||||||
case kte::TokenKind::Comment:
|
case kte::TokenKind::Comment:
|
||||||
attron(A_DIM);
|
return A_DIM;
|
||||||
break;
|
|
||||||
case kte::TokenKind::String:
|
case kte::TokenKind::String:
|
||||||
case kte::TokenKind::Char:
|
case kte::TokenKind::Char:
|
||||||
case kte::TokenKind::Number:
|
case kte::TokenKind::Number:
|
||||||
// standout a bit using A_UNDERLINE if available
|
return A_UNDERLINE;
|
||||||
attron(A_UNDERLINE);
|
|
||||||
break;
|
|
||||||
default:
|
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) {
|
while (written < cols) {
|
||||||
char ch = ' ';
|
|
||||||
bool from_src = false;
|
bool from_src = false;
|
||||||
|
wchar_t wch = L' ';
|
||||||
|
int wch_len = 1;
|
||||||
|
int disp_w = 1;
|
||||||
|
|
||||||
if (src_i < line.size()) {
|
if (src_i < line.size()) {
|
||||||
unsigned char c = static_cast<unsigned char>(line[src_i]);
|
// Decode UTF-8
|
||||||
if (c == '\t') {
|
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);
|
std::size_t next_tab = tabw - (render_col % tabw);
|
||||||
if (render_col + next_tab <= coloffs) {
|
if (render_col + next_tab <= coloffs) {
|
||||||
render_col += next_tab;
|
render_col += next_tab;
|
||||||
++src_i;
|
src_i += wch_len;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Emit spaces for tab
|
// Emit spaces for tab
|
||||||
@@ -160,107 +278,152 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
// Now render visible spaces
|
// Now render visible spaces
|
||||||
while (next_tab > 0 && written < cols) {
|
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 =
|
bool in_cur =
|
||||||
has_current && li == cur_my && src_i >= cur_mx
|
has_current && li == cur_my && src_i >= cur_mx
|
||||||
&& src_i < cur_mend;
|
&&
|
||||||
// Toggle highlight attributes
|
src_i < cur_mend;
|
||||||
int attr = 0;
|
attr_t a = A_NORMAL;
|
||||||
|
a |= token_attr(token_at(src_i));
|
||||||
|
if (in_sel) {
|
||||||
|
a |= A_REVERSE;
|
||||||
|
} else {
|
||||||
if (in_hl)
|
if (in_hl)
|
||||||
attr |= A_STANDOUT;
|
a |= A_STANDOUT;
|
||||||
if (in_cur)
|
if (in_cur)
|
||||||
attr |= A_BOLD;
|
a |= 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));
|
|
||||||
}
|
}
|
||||||
|
attrset(a);
|
||||||
addch(' ');
|
addch(' ');
|
||||||
++written;
|
++written;
|
||||||
++render_col;
|
++render_col;
|
||||||
--next_tab;
|
--next_tab;
|
||||||
}
|
}
|
||||||
++src_i;
|
src_i += wch_len;
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
// normal char
|
// normal char (controls are drawn as ^X or '?')
|
||||||
|
disp_w = kte::CellWidth(wch);
|
||||||
|
|
||||||
if (render_col < coloffs) {
|
if (render_col < coloffs) {
|
||||||
++render_col;
|
render_col += disp_w;
|
||||||
++src_i;
|
src_i += wch_len;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ch = static_cast<char>(c);
|
|
||||||
from_src = true;
|
from_src = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// beyond EOL, fill spaces
|
// beyond EOL, fill spaces
|
||||||
ch = ' ';
|
wch = L' ';
|
||||||
|
wch_len = 1;
|
||||||
|
disp_w = 1;
|
||||||
from_src = false;
|
from_src = false;
|
||||||
}
|
}
|
||||||
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
|
||||||
bool in_cur =
|
if (written + disp_w > cols) {
|
||||||
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i <
|
// would overflow, just break
|
||||||
cur_mend;
|
break;
|
||||||
if (in_hl && !hl_on) {
|
|
||||||
attron(A_STANDOUT);
|
|
||||||
hl_on = true;
|
|
||||||
}
|
}
|
||||||
if (!in_hl && hl_on) {
|
|
||||||
attroff(A_STANDOUT);
|
bool in_mark = from_src && is_src_in_mark_sel(li, src_i);
|
||||||
hl_on = false;
|
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) {
|
bool in_sel = in_mark || in_vsel;
|
||||||
attroff(A_BOLD);
|
bool in_hl = search_hl.Active() && from_src && is_src_in_hl(src_i);
|
||||||
cur_on = false;
|
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
|
||||||
}
|
src_i < cur_mend;
|
||||||
if (!in_hl && from_src) {
|
attr_t a = A_NORMAL;
|
||||||
apply_token_attr(token_at(src_i));
|
|
||||||
}
|
|
||||||
addch(static_cast<unsigned char>(ch));
|
|
||||||
++written;
|
|
||||||
++render_col;
|
|
||||||
if (from_src)
|
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)
|
if (src_i >= line.size() && written >= cols)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hl_on) {
|
|
||||||
attroff(A_STANDOUT);
|
|
||||||
hl_on = false;
|
|
||||||
}
|
|
||||||
if (cur_on) {
|
|
||||||
attroff(A_BOLD);
|
|
||||||
cur_on = false;
|
|
||||||
}
|
|
||||||
attrset(A_NORMAL);
|
attrset(A_NORMAL);
|
||||||
clrtoeol();
|
clrtoeol();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place terminal cursor at logical position accounting for tabs and 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 cy = buf->Cury();
|
||||||
std::size_t rx = buf->Rx(); // render x computed by command layer
|
std::size_t cx = buf->Curx();
|
||||||
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
|
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
|
||||||
int cur_x = static_cast<int>(rx) - static_cast<int>(buf->Coloffs());
|
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) {
|
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
|
// remember where to leave the terminal cursor after status is drawn
|
||||||
saved_cur_y = cur_y;
|
saved_cur_y = cur_y;
|
||||||
@@ -383,7 +546,7 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
left += " [RO]";
|
left += " [RO]";
|
||||||
// Append total line count as "<n>L"
|
// Append total line count as "<n>L"
|
||||||
if (b) {
|
if (b) {
|
||||||
unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
|
unsigned long lcount = static_cast<unsigned long>(b->Nrows());
|
||||||
left += " ";
|
left += " ";
|
||||||
left += std::to_string(lcount);
|
left += std::to_string(lcount);
|
||||||
left += "L";
|
left += "L";
|
||||||
|
|||||||
+30
-5
@@ -1,9 +1,10 @@
|
|||||||
/*
|
/*
|
||||||
* TerminalRenderer - ncurses-based renderer for terminal mode
|
* TerminalRenderer - ncurses-based renderer for terminal mode
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_TERMINAL_RENDERER_H
|
#pragma once
|
||||||
#define KTE_TERMINAL_RENDERER_H
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstddef>
|
||||||
#include "Renderer.h"
|
#include "Renderer.h"
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,30 @@ public:
|
|||||||
~TerminalRenderer() override;
|
~TerminalRenderer() override;
|
||||||
|
|
||||||
void Draw(Editor &ed) override;
|
void Draw(Editor &ed) override;
|
||||||
};
|
|
||||||
|
|
||||||
#endif // KTE_TERMINAL_RENDERER_H
|
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
|
bool
|
||||||
TestFrontend::Init(Editor &ed)
|
TestFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||||
{
|
{
|
||||||
|
(void) argc;
|
||||||
|
(void) argv;
|
||||||
ed.SetDimensions(24, 80);
|
ed.SetDimensions(24, 80);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -14,6 +16,9 @@ TestFrontend::Init(Editor &ed)
|
|||||||
void
|
void
|
||||||
TestFrontend::Step(Editor &ed, bool &running)
|
TestFrontend::Step(Editor &ed, bool &running)
|
||||||
{
|
{
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
if (input_.Poll(mi)) {
|
||||||
if (mi.hasCommand) {
|
if (mi.hasCommand) {
|
||||||
|
|||||||
+2
-6
@@ -1,9 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* TestFrontend.h - headless frontend for testing with programmable input
|
* TestFrontend.h - headless frontend for testing with programmable input
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_TEST_FRONTEND_H
|
#pragma once
|
||||||
#define KTE_TEST_FRONTEND_H
|
|
||||||
|
|
||||||
#include "Frontend.h"
|
#include "Frontend.h"
|
||||||
#include "TestInputHandler.h"
|
#include "TestInputHandler.h"
|
||||||
#include "TestRenderer.h"
|
#include "TestRenderer.h"
|
||||||
@@ -15,7 +13,7 @@ public:
|
|||||||
|
|
||||||
~TestFrontend() override = default;
|
~TestFrontend() override = default;
|
||||||
|
|
||||||
bool Init(Editor &ed) override;
|
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||||
|
|
||||||
void Step(Editor &ed, bool &running) override;
|
void Step(Editor &ed, bool &running) override;
|
||||||
|
|
||||||
@@ -37,5 +35,3 @@ private:
|
|||||||
TestInputHandler input_{};
|
TestInputHandler input_{};
|
||||||
TestRenderer renderer_{};
|
TestRenderer renderer_{};
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_TEST_FRONTEND_H
|
|
||||||
|
|||||||
+1
-5
@@ -1,9 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* TestInputHandler.h - programmable input handler for testing
|
* TestInputHandler.h - programmable input handler for testing
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_TEST_INPUT_HANDLER_H
|
#pragma once
|
||||||
#define KTE_TEST_INPUT_HANDLER_H
|
|
||||||
|
|
||||||
#include <queue>
|
#include <queue>
|
||||||
|
|
||||||
#include "InputHandler.h"
|
#include "InputHandler.h"
|
||||||
@@ -30,5 +28,3 @@ public:
|
|||||||
private:
|
private:
|
||||||
std::queue<MappedInput> queue_;
|
std::queue<MappedInput> queue_;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_TEST_INPUT_HANDLER_H
|
|
||||||
|
|||||||
+1
-5
@@ -1,9 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
* TestRenderer.h - minimal renderer for testing (no actual display)
|
* TestRenderer.h - minimal renderer for testing (no actual display)
|
||||||
*/
|
*/
|
||||||
#ifndef KTE_TEST_RENDERER_H
|
#pragma once
|
||||||
#define KTE_TEST_RENDERER_H
|
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
|
||||||
#include "Renderer.h"
|
#include "Renderer.h"
|
||||||
@@ -32,5 +30,3 @@ public:
|
|||||||
private:
|
private:
|
||||||
std::size_t draw_count_ = 0;
|
std::size_t draw_count_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_TEST_RENDERER_H
|
|
||||||
|
|||||||
+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};
|
||||||
|
};
|
||||||
+38
-6
@@ -1,8 +1,9 @@
|
|||||||
#ifndef KTE_UNDONODE_H
|
#pragma once
|
||||||
#define KTE_UNDONODE_H
|
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "TextSpan.h"
|
||||||
|
|
||||||
|
|
||||||
enum class UndoType : std::uint8_t {
|
enum class UndoType : std::uint8_t {
|
||||||
@@ -11,16 +12,47 @@ enum class UndoType : std::uint8_t {
|
|||||||
Paste,
|
Paste,
|
||||||
Newline,
|
Newline,
|
||||||
DeleteRow,
|
DeleteRow,
|
||||||
|
InsertRow,
|
||||||
|
// Inverse of Newline: forward = join_lines(row) [removes the newline at the
|
||||||
|
// end of `row`], backward = split_line(row, col) [recreates the original
|
||||||
|
// two lines]. Used by backspace-at-col-0 and delete-at-eol, whose forward
|
||||||
|
// action is a join, not a split — Newline's apply() semantics are the
|
||||||
|
// wrong direction for those.
|
||||||
|
JoinLines,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct UndoNode {
|
struct UndoNode {
|
||||||
UndoType type{};
|
UndoType type{};
|
||||||
int row{};
|
int row{};
|
||||||
int col{};
|
int col{};
|
||||||
|
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
||||||
std::string text;
|
std::string text;
|
||||||
|
// A large Insert/Paste/Delete text is held as references to the buffer's
|
||||||
|
// storage instead (text is then empty and span_len is its length): undo
|
||||||
|
// of a replace-all over a 100 MB file otherwise kept 200 MB of copies.
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
std::size_t span_len = 0;
|
||||||
|
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
||||||
UndoNode *child = nullptr; // next in current timeline
|
UndoNode *child = nullptr; // next in current timeline
|
||||||
UndoNode *next = nullptr; // redo branch
|
UndoNode *next = nullptr; // redo branch
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool HasSpans() const
|
||||||
|
{
|
||||||
|
return !spans.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Length of the node's text, however it is held.
|
||||||
|
[[nodiscard]] std::size_t TextSize() const
|
||||||
|
{
|
||||||
|
return HasSpans() ? span_len : text.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Approximate heap and object bytes held by this node.
|
||||||
|
[[nodiscard]] std::size_t CostBytes() const
|
||||||
|
{
|
||||||
|
return sizeof(UndoNode) + text.capacity() + spans.capacity() * sizeof(TextSpan);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
#endif // KTE_UNDONODE_H
|
|
||||||
|
|||||||
+471
-137
@@ -1,67 +1,111 @@
|
|||||||
#include "UndoSystem.h"
|
#include "UndoSystem.h"
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
|
#include <algorithm>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
||||||
: buf_(&owner), tree_(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
|
void
|
||||||
UndoSystem::Begin(UndoType type)
|
UndoSystem::Begin(UndoType type)
|
||||||
{
|
{
|
||||||
#ifdef KTE_UNDO_DEBUG
|
if (!buf_)
|
||||||
debug_log("Begin");
|
return;
|
||||||
#endif
|
|
||||||
// Reuse pending if batching conditions are met
|
|
||||||
const int row = static_cast<int>(buf_->Cury());
|
const int row = static_cast<int>(buf_->Cury());
|
||||||
const int col = static_cast<int>(buf_->Curx());
|
const int col = static_cast<int>(buf_->Curx());
|
||||||
if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) {
|
|
||||||
if (type == UndoType::Delete) {
|
// Some operations should always be standalone undo steps.
|
||||||
// Support batching both forward deletes (DeleteChar) and backspace (prepend case)
|
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
||||||
// Forward delete: cursor stays at anchor col; keep batching when col == anchor
|
UndoType::InsertRow || type == UndoType::JoinLines);
|
||||||
const auto anchor = static_cast<std::size_t>(tree_.pending->col);
|
if (always_standalone) {
|
||||||
if (anchor == static_cast<std::size_t>(col)) {
|
commit();
|
||||||
pending_prepend_ = false;
|
|
||||||
return; // keep batching forward delete
|
|
||||||
}
|
}
|
||||||
// Backspace: cursor moved left by exactly one position relative to current anchor.
|
|
||||||
// Extend batch by shifting anchor left and prepending the deleted byte.
|
if (tree_.pending) {
|
||||||
if (static_cast<std::size_t>(col) + 1 == anchor) {
|
if (tree_.pending->type == type) {
|
||||||
tree_.pending->col = col;
|
// Typed-run coalescing rules.
|
||||||
pending_prepend_ = true;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
break;
|
||||||
std::size_t expected = static_cast<std::size_t>(tree_.pending->col) + tree_.pending->text.
|
}
|
||||||
size();
|
case UndoType::Delete: {
|
||||||
if (expected == static_cast<std::size_t>(col)) {
|
if (tree_.pending->row == row) {
|
||||||
pending_prepend_ = false;
|
// Two common delete shapes:
|
||||||
return; // keep batching
|
// 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;
|
||||||
}
|
}
|
||||||
// Otherwise commit any existing batch and start a new node
|
case UndoType::Newline:
|
||||||
|
case UndoType::DeleteRow:
|
||||||
|
case UndoType::InsertRow:
|
||||||
|
case UndoType::JoinLines:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Can't coalesce: seal the previous pending step.
|
||||||
commit();
|
commit();
|
||||||
auto *node = new UndoNode();
|
}
|
||||||
node->type = type;
|
|
||||||
node->row = row;
|
|
||||||
node->col = col;
|
|
||||||
node->child = nullptr;
|
|
||||||
node->next = nullptr;
|
|
||||||
tree_.pending = node;
|
|
||||||
pending_prepend_ = false;
|
|
||||||
|
|
||||||
#ifdef KTE_UNDO_DEBUG
|
// Start a new pending node.
|
||||||
debug_log("Begin:new");
|
tree_.pending = new UndoNode{};
|
||||||
#endif
|
tree_.pending->type = type;
|
||||||
// Assert pending is detached from the tree
|
tree_.pending->row = row;
|
||||||
assert(tree_.pending && "pending must exist after Begin");
|
tree_.pending->col = col;
|
||||||
assert(tree_.pending != tree_.root);
|
tree_.pending->group_id = active_group_id_;
|
||||||
assert(tree_.pending != tree_.current);
|
tree_.pending->text.clear();
|
||||||
assert(tree_.pending != tree_.saved);
|
tree_.pending->parent = nullptr;
|
||||||
assert(!is_descendant(tree_.root, tree_.pending));
|
tree_.pending->child = nullptr;
|
||||||
|
tree_.pending->next = nullptr;
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,15 +114,11 @@ UndoSystem::Append(char ch)
|
|||||||
{
|
{
|
||||||
if (!tree_.pending)
|
if (!tree_.pending)
|
||||||
return;
|
return;
|
||||||
if (pending_prepend_ && tree_.pending->type == UndoType::Delete) {
|
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||||
// Prepend for backspace so that text is in increasing column order
|
|
||||||
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
||||||
} else {
|
} else {
|
||||||
tree_.pending->text.push_back(ch);
|
tree_.pending->text.push_back(ch);
|
||||||
}
|
}
|
||||||
#ifdef KTE_UNDO_DEBUG
|
|
||||||
debug_log("Append:ch");
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -87,108 +127,154 @@ UndoSystem::Append(std::string_view text)
|
|||||||
{
|
{
|
||||||
if (!tree_.pending)
|
if (!tree_.pending)
|
||||||
return;
|
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());
|
tree_.pending->text.append(text.data(), text.size());
|
||||||
#ifdef KTE_UNDO_DEBUG
|
}
|
||||||
debug_log("Append:sv");
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::commit()
|
UndoSystem::commit()
|
||||||
{
|
{
|
||||||
#ifdef KTE_UNDO_DEBUG
|
|
||||||
debug_log("commit:enter");
|
|
||||||
#endif
|
|
||||||
if (!tree_.pending)
|
if (!tree_.pending)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// If we have redo branches from current, discard them (non-linear behavior)
|
// Drop empty text batches for text-based operations.
|
||||||
if (tree_.current && tree_.current->child) {
|
if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
|
||||||
free_node(tree_.current->child);
|
|| tree_.pending->type == UndoType::Paste)
|
||||||
tree_.current->child = nullptr;
|
&& tree_.pending->text.empty()) {
|
||||||
// We diverged; saved snapshot cannot be on discarded branch anymore
|
delete tree_.pending;
|
||||||
if (tree_.saved) {
|
tree_.pending = nullptr;
|
||||||
// If saved is not equal to current, keep it; if it was on discarded branch we cannot easily detect now.
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
// For simplicity, leave saved as-is; dirty flag uses pointer equality.
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach pending as next state
|
try_convert_to_spans(tree_.pending);
|
||||||
|
bytes_ += tree_.pending->CostBytes();
|
||||||
|
|
||||||
if (!tree_.root) {
|
if (!tree_.root) {
|
||||||
tree_.root = tree_.pending;
|
tree_.root = tree_.pending;
|
||||||
|
tree_.pending->parent = nullptr;
|
||||||
tree_.current = tree_.pending;
|
tree_.current = tree_.pending;
|
||||||
} else if (!tree_.current) {
|
} else if (!tree_.current) {
|
||||||
// Should not happen if root exists, but handle gracefully
|
// 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;
|
tree_.current = tree_.pending;
|
||||||
} else {
|
} else {
|
||||||
// Attach as primary child (head of redo list)
|
// Branching semantics: attach as a new redo branch under current.
|
||||||
tree_.pending->next = nullptr;
|
// 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;
|
tree_.current->child = tree_.pending;
|
||||||
|
} else {
|
||||||
|
tree_.pending->next = tree_.current->child;
|
||||||
|
tree_.current->child = tree_.pending;
|
||||||
|
}
|
||||||
tree_.current = tree_.pending;
|
tree_.current = tree_.pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
tree_.pending = nullptr;
|
tree_.pending = nullptr;
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
#ifdef KTE_UNDO_DEBUG
|
if (group_depth_ == 0)
|
||||||
debug_log("commit:done");
|
maybe_prune();
|
||||||
#endif
|
|
||||||
// post-conditions
|
|
||||||
assert(tree_.pending == nullptr && "pending must be cleared after commit");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::undo()
|
UndoSystem::undo()
|
||||||
{
|
{
|
||||||
// Close any pending batch
|
// Seal any in-progress typed run before undo.
|
||||||
commit();
|
commit();
|
||||||
if (!tree_.current)
|
if (!tree_.current)
|
||||||
return;
|
return;
|
||||||
UndoNode *parent = find_parent(tree_.root, tree_.current);
|
|
||||||
UndoNode *node = tree_.current;
|
|
||||||
// Apply inverse of current node
|
|
||||||
apply(node, -1);
|
|
||||||
tree_.current = parent;
|
|
||||||
update_dirty_flag();
|
|
||||||
#ifdef KTE_UNDO_DEBUG
|
|
||||||
debug_log("undo");
|
debug_log("undo");
|
||||||
#endif
|
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
|
void
|
||||||
UndoSystem::redo()
|
UndoSystem::redo(int branch_index)
|
||||||
{
|
{
|
||||||
// Redo next child along current timeline
|
|
||||||
if (tree_.pending) {
|
|
||||||
// If app added pending edits, finalize them before redo chain
|
|
||||||
commit();
|
commit();
|
||||||
}
|
UndoNode **head = nullptr;
|
||||||
UndoNode *next = nullptr;
|
|
||||||
if (!tree_.current) {
|
if (!tree_.current) {
|
||||||
next = tree_.root; // if nothing yet, try applying first node
|
head = &tree_.root;
|
||||||
} else {
|
} else {
|
||||||
next = tree_.current->child;
|
head = &tree_.current->child;
|
||||||
}
|
}
|
||||||
if (!next)
|
if (!head || !*head)
|
||||||
return;
|
return;
|
||||||
apply(next, +1);
|
if (branch_index < 0)
|
||||||
tree_.current = next;
|
branch_index = 0;
|
||||||
update_dirty_flag();
|
|
||||||
#ifdef KTE_UNDO_DEBUG
|
// 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");
|
debug_log("redo");
|
||||||
#endif
|
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
|
void
|
||||||
UndoSystem::mark_saved()
|
UndoSystem::mark_saved()
|
||||||
{
|
{
|
||||||
|
commit();
|
||||||
tree_.saved = tree_.current;
|
tree_.saved = tree_.current;
|
||||||
|
base_unsaved_ = false;
|
||||||
|
update_dirty_flag();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::AbortGroups()
|
||||||
|
{
|
||||||
|
commit();
|
||||||
|
group_depth_ = 0;
|
||||||
|
active_group_id_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::mark_base_unsaved()
|
||||||
|
{
|
||||||
|
base_unsaved_ = true;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
#ifdef KTE_UNDO_DEBUG
|
|
||||||
debug_log("mark_saved");
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -199,62 +285,130 @@ UndoSystem::discard_pending()
|
|||||||
delete tree_.pending;
|
delete tree_.pending;
|
||||||
tree_.pending = nullptr;
|
tree_.pending = nullptr;
|
||||||
}
|
}
|
||||||
#ifdef KTE_UNDO_DEBUG
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
debug_log("discard_pending");
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::clear()
|
UndoSystem::clear()
|
||||||
{
|
{
|
||||||
if (tree_.root) {
|
discard_pending();
|
||||||
free_node(tree_.root);
|
// The root may have siblings (edits made after undoing to the start).
|
||||||
}
|
FreeUndoForest(tree_.root);
|
||||||
if (tree_.pending) {
|
tree_.root = nullptr;
|
||||||
delete tree_.pending;
|
tree_.current = nullptr;
|
||||||
}
|
tree_.saved = nullptr;
|
||||||
tree_.root = tree_.current = tree_.saved = tree_.pending = nullptr;
|
base_unsaved_ = false;
|
||||||
|
bytes_ = 0;
|
||||||
|
active_group_id_ = 0;
|
||||||
|
group_depth_ = 0;
|
||||||
|
next_group_id_ = 1;
|
||||||
update_dirty_flag();
|
update_dirty_flag();
|
||||||
#ifdef KTE_UNDO_DEBUG
|
}
|
||||||
debug_log("clear");
|
|
||||||
#endif
|
|
||||||
|
// Position just after the node's text inserted at (node->col, node->row).
|
||||||
|
// The text may span lines: the column is then the length after its last
|
||||||
|
// newline, not col + size (which left the cursor past the end of the line,
|
||||||
|
// so later edits and their undo records went to the wrong place).
|
||||||
|
static void
|
||||||
|
end_of_text(const Buffer &buf, const UndoNode *node, std::size_t &x, std::size_t &y)
|
||||||
|
{
|
||||||
|
y = static_cast<std::size_t>(node->row);
|
||||||
|
x = static_cast<std::size_t>(node->col);
|
||||||
|
auto scan = [&](const char *d, std::size_t n) {
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
if (d[i] == '\n') {
|
||||||
|
++y;
|
||||||
|
x = 0;
|
||||||
|
} else {
|
||||||
|
++x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (node->HasSpans())
|
||||||
|
buf.VisitSpans(node->spans, scan);
|
||||||
|
else
|
||||||
|
scan(node->text.data(), node->text.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Insert the node's text at its position (from spans without copying).
|
||||||
|
static void
|
||||||
|
insert_node_text(Buffer &buf, const UndoNode *node)
|
||||||
|
{
|
||||||
|
if (node->HasSpans())
|
||||||
|
buf.insert_spans(node->row, node->col, node->spans);
|
||||||
|
else
|
||||||
|
buf.insert_text(node->row, node->col, node->text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::apply(const UndoNode *node, int direction)
|
UndoSystem::apply(const UndoNode *node, int direction)
|
||||||
{
|
{
|
||||||
|
std::size_t ex = 0, ey = 0;
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
|
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
||||||
|
// Low-level Buffer edit primitives do not move the cursor.
|
||||||
switch (node->type) {
|
switch (node->type) {
|
||||||
case UndoType::Insert:
|
case UndoType::Insert:
|
||||||
case UndoType::Paste:
|
case UndoType::Paste:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
insert_node_text(*buf_, node);
|
||||||
|
end_of_text(*buf_, node, ex, ey);
|
||||||
|
buf_->SetCursor(ex, ey);
|
||||||
} else {
|
} else {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->TextSize());
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Delete:
|
case UndoType::Delete:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->TextSize());
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} 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;
|
break;
|
||||||
case UndoType::Newline:
|
case UndoType::Newline:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->split_line(node->row, node->col);
|
buf_->split_line(node->row, node->col);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||||
} else {
|
} else {
|
||||||
buf_->join_lines(node->row);
|
buf_->join_lines(node->row);
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::DeleteRow:
|
case UndoType::DeleteRow:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->delete_row(node->row);
|
buf_->delete_row(node->row);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->insert_row(node->row, node->text);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -264,20 +418,13 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
void
|
void
|
||||||
UndoSystem::free_node(UndoNode *node)
|
UndoSystem::free_node(UndoNode *node)
|
||||||
{
|
{
|
||||||
|
// Free this node and its child subtree, but not its siblings.
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
// Free child subtree(s) and sibling branches
|
UndoNode *children = node->child;
|
||||||
if (node->child) {
|
|
||||||
// Free entire redo list starting at child, including each subtree
|
|
||||||
UndoNode *branch = node->child;
|
|
||||||
while (branch) {
|
|
||||||
UndoNode *next = branch->next;
|
|
||||||
free_node(branch);
|
|
||||||
branch = next;
|
|
||||||
}
|
|
||||||
node->child = nullptr;
|
node->child = nullptr;
|
||||||
}
|
|
||||||
delete node;
|
delete node;
|
||||||
|
FreeUndoForest(children);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -285,26 +432,27 @@ void
|
|||||||
UndoSystem::free_branch(UndoNode *node)
|
UndoSystem::free_branch(UndoNode *node)
|
||||||
{
|
{
|
||||||
// Free a branch list (node and its next siblings) including their subtrees
|
// Free a branch list (node and its next siblings) including their subtrees
|
||||||
while (node) {
|
FreeUndoForest(node);
|
||||||
UndoNode *next = node->next;
|
|
||||||
free_node(node);
|
|
||||||
node = next;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Iterative search for `target` below `cur`; sets out_parent to its parent.
|
||||||
static bool
|
static bool
|
||||||
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
dfs_find_parent(UndoNode *cur, UndoNode *target, UndoNode *&out_parent)
|
||||||
{
|
{
|
||||||
if (!cur)
|
if (!cur)
|
||||||
return false;
|
return false;
|
||||||
for (UndoNode *child = cur->child; child != nullptr; child = child->next) {
|
std::vector<UndoNode *> stack{cur};
|
||||||
|
while (!stack.empty()) {
|
||||||
|
UndoNode *node = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
for (UndoNode *child = node->child; child != nullptr; child = child->next) {
|
||||||
if (child == target) {
|
if (child == target) {
|
||||||
out_parent = cur;
|
out_parent = node;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (dfs_find_parent(child, target, out_parent))
|
stack.push_back(child);
|
||||||
return true;
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -323,11 +471,190 @@ UndoSystem::find_parent(UndoNode *from, UndoNode *target)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Texts at least this long are held as storage spans when possible.
|
||||||
|
static constexpr std::size_t kSpanMinBytes = PieceTable::kCaptureDeletedMin;
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::try_convert_to_spans(UndoNode *node)
|
||||||
|
{
|
||||||
|
if (!buf_ || !node || node->text.size() < kSpanMinBytes)
|
||||||
|
return;
|
||||||
|
std::vector<TextSpan> spans;
|
||||||
|
switch (node->type) {
|
||||||
|
case UndoType::Insert:
|
||||||
|
case UndoType::Paste:
|
||||||
|
// The inserted text is in the buffer at the node's position.
|
||||||
|
spans = buf_->SpansAt(node->row, node->col, node->text.size());
|
||||||
|
break;
|
||||||
|
case UndoType::Delete:
|
||||||
|
// The deleted text is still in storage; the deletion remembered where.
|
||||||
|
spans = buf_->TakeDeletedSpans(node->row, node->col, node->text.size());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only when the spans hold exactly the recorded text; otherwise (the
|
||||||
|
// text is not where the node says) keep the copy.
|
||||||
|
if (spans.empty() || !buf_->SpansEqual(spans, node->text))
|
||||||
|
return;
|
||||||
|
node->span_len = node->text.size();
|
||||||
|
node->spans = std::move(spans);
|
||||||
|
node->spans.shrink_to_fit();
|
||||||
|
std::string().swap(node->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::maybe_prune()
|
||||||
|
{
|
||||||
|
std::size_t budget = budget_;
|
||||||
|
if (budget == 0) {
|
||||||
|
const std::size_t content = buf_ ? buf_->ContentBytes() : 0;
|
||||||
|
budget = std::max<std::size_t>(std::size_t{64} << 20, content * 2);
|
||||||
|
}
|
||||||
|
if (bytes_ <= budget)
|
||||||
|
return;
|
||||||
|
// Prune well below the budget so this does not run on every commit.
|
||||||
|
prune_to(budget - budget / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::forget_saved_state()
|
||||||
|
{
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
base_unsaved_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Total bytes held by a node, its children and its siblings.
|
||||||
|
static std::size_t
|
||||||
|
forest_bytes(const UndoNode *first)
|
||||||
|
{
|
||||||
|
std::size_t total = 0;
|
||||||
|
std::vector<const UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const UndoNode *n = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
total += n->CostBytes();
|
||||||
|
if (n->child)
|
||||||
|
stack.push_back(n->child);
|
||||||
|
if (n->next)
|
||||||
|
stack.push_back(n->next);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
forest_contains(const UndoNode *first, const UndoNode *target)
|
||||||
|
{
|
||||||
|
if (!target)
|
||||||
|
return false;
|
||||||
|
std::vector<const UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const UndoNode *n = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
if (n == target)
|
||||||
|
return true;
|
||||||
|
if (n->child)
|
||||||
|
stack.push_back(n->child);
|
||||||
|
if (n->next)
|
||||||
|
stack.push_back(n->next);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::prune_to(const std::size_t target)
|
||||||
|
{
|
||||||
|
// The line kept: root..current, then current's active redo line.
|
||||||
|
std::vector<UndoNode *> line;
|
||||||
|
for (UndoNode *n = tree_.current; n; n = n->parent)
|
||||||
|
line.push_back(n);
|
||||||
|
std::reverse(line.begin(), line.end());
|
||||||
|
{
|
||||||
|
UndoNode *n = tree_.current ? tree_.current->child : tree_.root;
|
||||||
|
for (; n; n = n->child)
|
||||||
|
line.push_back(n);
|
||||||
|
}
|
||||||
|
if (line.empty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Detach and free the siblings of `keep` in the list starting at *head.
|
||||||
|
auto drop_siblings = [&](UndoNode **head, UndoNode *keep) {
|
||||||
|
UndoNode *n = *head;
|
||||||
|
*head = keep;
|
||||||
|
while (n) {
|
||||||
|
UndoNode *nx = n->next;
|
||||||
|
if (n != keep) {
|
||||||
|
n->next = nullptr;
|
||||||
|
if (forest_contains(n, tree_.saved))
|
||||||
|
forget_saved_state();
|
||||||
|
FreeUndoForest(n);
|
||||||
|
}
|
||||||
|
n = nx;
|
||||||
|
}
|
||||||
|
keep->next = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Branches off the kept line: reachable only by choosing a redo branch.
|
||||||
|
drop_siblings(&tree_.root, line.front());
|
||||||
|
for (std::size_t i = 0; i + 1 < line.size(); ++i)
|
||||||
|
drop_siblings(&line[i]->child, line[i + 1]);
|
||||||
|
bytes_ = forest_bytes(tree_.root);
|
||||||
|
|
||||||
|
// 2. The oldest edits, while they are older than the current state. An
|
||||||
|
// undo group goes whole: undoing what remained of it would stop at a
|
||||||
|
// state inside the group, which the user never saw.
|
||||||
|
std::size_t i = 0;
|
||||||
|
bool mid_group = false;
|
||||||
|
while ((bytes_ > target || mid_group) && i < line.size() && line[i] != tree_.current && tree_.current) {
|
||||||
|
UndoNode *old = line[i];
|
||||||
|
UndoNode *nx = old->child;
|
||||||
|
if (!nx)
|
||||||
|
break;
|
||||||
|
if (old->group_id != 0 && old->group_id == tree_.current->group_id)
|
||||||
|
break; // the current state's own group stays whole
|
||||||
|
// Dropping `old` loses the state before it. `old`'s own state
|
||||||
|
// becomes the first state in history.
|
||||||
|
if (tree_.saved == nullptr)
|
||||||
|
forget_saved_state();
|
||||||
|
else if (tree_.saved == old)
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
mid_group = old->group_id != 0 && nx->group_id == old->group_id;
|
||||||
|
bytes_ -= old->CostBytes();
|
||||||
|
old->child = nullptr;
|
||||||
|
delete old;
|
||||||
|
nx->parent = nullptr;
|
||||||
|
tree_.root = nx;
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The redo line beyond the current state, newest first.
|
||||||
|
if (bytes_ > target && tree_.current && tree_.current->child) {
|
||||||
|
UndoNode *future = tree_.current->child;
|
||||||
|
if (forest_contains(future, tree_.saved))
|
||||||
|
forget_saved_state();
|
||||||
|
tree_.current->child = nullptr;
|
||||||
|
bytes_ -= forest_bytes(future);
|
||||||
|
FreeUndoForest(future);
|
||||||
|
}
|
||||||
|
update_dirty_flag();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::update_dirty_flag()
|
UndoSystem::update_dirty_flag()
|
||||||
{
|
{
|
||||||
// dirty if current != saved
|
// dirty if current != saved
|
||||||
bool dirty = (tree_.current != tree_.saved);
|
bool dirty = base_unsaved_ || (tree_.current != tree_.saved);
|
||||||
buf_->SetDirty(dirty);
|
buf_->SetDirty(dirty);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +681,10 @@ UndoSystem::type_str(UndoType t)
|
|||||||
return "Newline";
|
return "Newline";
|
||||||
case UndoType::DeleteRow:
|
case UndoType::DeleteRow:
|
||||||
return "DeleteRow";
|
return "DeleteRow";
|
||||||
|
case UndoType::InsertRow:
|
||||||
|
return "InsertRow";
|
||||||
|
case UndoType::JoinLines:
|
||||||
|
return "JoinLines";
|
||||||
}
|
}
|
||||||
return "?";
|
return "?";
|
||||||
}
|
}
|
||||||
@@ -364,11 +695,14 @@ UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
|
|||||||
{
|
{
|
||||||
if (!root || !target)
|
if (!root || !target)
|
||||||
return false;
|
return false;
|
||||||
if (root == target)
|
std::vector<UndoNode *> stack{root};
|
||||||
return true;
|
while (!stack.empty()) {
|
||||||
for (UndoNode *child = root->child; child != nullptr; child = child->next) {
|
UndoNode *node = stack.back();
|
||||||
if (is_descendant(child, target))
|
stack.pop_back();
|
||||||
|
if (node == target)
|
||||||
return true;
|
return true;
|
||||||
|
for (UndoNode *child = node->child; child != nullptr; child = child->next)
|
||||||
|
stack.push_back(child);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-8
@@ -1,6 +1,45 @@
|
|||||||
#ifndef KTE_UNDOSYSTEM_H
|
/*
|
||||||
#define KTE_UNDOSYSTEM_H
|
* 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 <string_view>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -14,6 +53,12 @@ class UndoSystem {
|
|||||||
public:
|
public:
|
||||||
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
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 Begin(UndoType type);
|
||||||
|
|
||||||
void Append(char ch);
|
void Append(char ch);
|
||||||
@@ -24,17 +69,56 @@ public:
|
|||||||
|
|
||||||
void undo();
|
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();
|
void mark_saved();
|
||||||
|
|
||||||
|
// No undo state matches the file on disk any more (e.g. after crash
|
||||||
|
// recovery replayed edits onto it): the buffer stays dirty in every state
|
||||||
|
// until the next mark_saved() or clear().
|
||||||
|
void mark_base_unsaved();
|
||||||
|
|
||||||
|
// Close any open group (after an interrupted command) and seal pending.
|
||||||
|
void AbortGroups();
|
||||||
|
|
||||||
void discard_pending();
|
void discard_pending();
|
||||||
|
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
void UpdateBufferReference(Buffer &new_buf);
|
void UpdateBufferReference(Buffer &new_buf);
|
||||||
|
|
||||||
|
// History memory budget in bytes (node texts, spans and nodes). When
|
||||||
|
// committed history exceeds it, branches off the current path and then
|
||||||
|
// the oldest edits are dropped. 0 (the default) means max(64 MiB, twice
|
||||||
|
// the buffer's size).
|
||||||
|
void SetByteBudget(std::size_t bytes)
|
||||||
|
{
|
||||||
|
budget_ = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t HistoryBytes() const
|
||||||
|
{
|
||||||
|
return bytes_;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(KTE_TESTS)
|
||||||
|
// Test-only introspection hook.
|
||||||
|
const UndoTree &TreeForTests() const
|
||||||
|
{
|
||||||
|
return tree_;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
enum class PendingAppendMode : std::uint8_t {
|
||||||
|
Append,
|
||||||
|
Prepend,
|
||||||
|
};
|
||||||
|
|
||||||
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
||||||
void free_node(UndoNode *node);
|
void free_node(UndoNode *node);
|
||||||
|
|
||||||
@@ -50,10 +134,28 @@ private:
|
|||||||
|
|
||||||
void update_dirty_flag();
|
void update_dirty_flag();
|
||||||
|
|
||||||
|
// Hold a large committed text as storage spans (see UndoNode::spans).
|
||||||
|
void try_convert_to_spans(UndoNode *node);
|
||||||
|
|
||||||
|
// Enforce the byte budget (outside groups).
|
||||||
|
void maybe_prune();
|
||||||
|
|
||||||
|
void prune_to(std::size_t target);
|
||||||
|
|
||||||
|
// The saved state's node is being dropped: no state in history matches
|
||||||
|
// the file any more.
|
||||||
|
void forget_saved_state();
|
||||||
|
|
||||||
|
std::size_t bytes_ = 0; // approximate bytes held by committed history
|
||||||
|
std::size_t budget_ = 0;
|
||||||
|
|
||||||
|
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
||||||
|
|
||||||
|
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_;
|
Buffer *buf_;
|
||||||
UndoTree &tree_;
|
UndoTree &tree_;
|
||||||
// Internal hint for Delete batching: whether next Append() should prepend
|
|
||||||
bool pending_prepend_ = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KTE_UNDOSYSTEM_H
|
|
||||||
|
|||||||
+30
-2
@@ -1,3 +1,31 @@
|
|||||||
// Placeholder translation unit for UndoTree struct definition.
|
// Undo logic is implemented in UndoSystem; this file only owns node lifetime.
|
||||||
// Undo logic is implemented in UndoSystem.
|
|
||||||
#include "UndoTree.h"
|
#include "UndoTree.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
FreeUndoForest(UndoNode *first)
|
||||||
|
{
|
||||||
|
// Nodes form a first-child/next-sibling tree whose depth equals the
|
||||||
|
// length of the edit history, so walk it with an explicit stack.
|
||||||
|
std::vector<UndoNode *> stack;
|
||||||
|
if (first)
|
||||||
|
stack.push_back(first);
|
||||||
|
while (!stack.empty()) {
|
||||||
|
UndoNode *node = stack.back();
|
||||||
|
stack.pop_back();
|
||||||
|
if (node->child)
|
||||||
|
stack.push_back(node->child);
|
||||||
|
if (node->next)
|
||||||
|
stack.push_back(node->next);
|
||||||
|
delete node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
UndoTree::~UndoTree()
|
||||||
|
{
|
||||||
|
FreeUndoForest(root);
|
||||||
|
delete pending;
|
||||||
|
}
|
||||||
|
|||||||
+11
-4
@@ -1,6 +1,4 @@
|
|||||||
#ifndef KTE_UNDOTREE_H
|
#pragma once
|
||||||
#define KTE_UNDOTREE_H
|
|
||||||
|
|
||||||
#include "UndoNode.h"
|
#include "UndoNode.h"
|
||||||
|
|
||||||
|
|
||||||
@@ -9,7 +7,16 @@ struct UndoTree {
|
|||||||
UndoNode *current = nullptr; // current state of buffer
|
UndoNode *current = nullptr; // current state of buffer
|
||||||
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
||||||
UndoNode *pending = nullptr; // in-progress batch (detached)
|
UndoNode *pending = nullptr; // in-progress batch (detached)
|
||||||
|
|
||||||
|
// Frees the entire node graph (root's subtree/branches plus any detached
|
||||||
|
// pending node). `current`/`saved` are aliases into that same graph, not
|
||||||
|
// separately owned. Without this, closing/reloading/resetting a buffer
|
||||||
|
// (which replaces or destroys its UndoTree) leaks every UndoNode ever
|
||||||
|
// created for it.
|
||||||
|
~UndoTree();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
#endif // KTE_UNDOTREE_H
|
// 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);
|
||||||
|
|||||||
@@ -24,5 +24,8 @@
|
|||||||
<string>10.13</string>
|
<string>10.13</string>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<!-- Allow running multiple instances of the app -->
|
||||||
|
<key>LSMultipleInstancesProhibited</key>
|
||||||
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user