48 Commits

Author SHA1 Message Date
b0b5b55dce Switch Docker to Alpine and build kge.
Update build environment to Alpine, enable GUI support, and refine developer guide

- Migrated Dockerfile base image from Ubuntu 22.04 to Alpine 3.19 for a smaller and faster container.
- Added dependencies for GUI support (SDL2, OpenGL/Mesa, Freetype, etc.) and updated CMake options.
- Enhanced `DEVELOPER_GUIDE.md` with new instructions for GUI builds, updated dependencies, and simplified custom build workflows.
- Addressed Alpine-specific ncurses library path issues in CMake configuration.
2026-02-17 16:53:12 -08:00
422b27b1ba Add Docker support for Linux build testing
- Introduced a `Dockerfile` for setting up a minimal Ubuntu-based build environment with required dependencies.
- Added `docker-build.sh` script to simplify Linux build and test execution using Docker or Podman.
- Updated `DEVELOPER_GUIDE.md` with instructions for using Docker/Podman for Linux builds, including CI/CD integration examples.
2026-02-17 16:35:52 -08:00
9485d2aa24 Linux fixup. 2026-02-17 16:13:28 -08:00
8a6b7851d5 Bump patch version. 2026-02-17 16:08:53 -08:00
8ec0d6ac41 Add benchmarks, migration tests, and dev guide
Add benchmarks for core operations, migration edge case tests, improved
buffer I/O tests, and developer guide

- Introduced `test_benchmarks.cc` for performance benchmarking of key
  operations in `PieceTable` and `Buffer`, including syntax highlighting
  and iteration patterns.
- Added `test_migration_coverage.cc` to provide comprehensive tests for
  migration of `Buffer::Rows()` to `PieceTable` APIs, with edge cases,
  boundary handling, and consistency checks.
- Enhanced `test_buffer_io.cc` with additional cases for save/load
  workflows, file handling, and better integration with the core API.
- Documented architectural details and core concepts in a new
  `DEVELOPER_GUIDE.md`. Highlighted design principles, code
  organization, and contribution workflows.
2026-02-17 16:08:23 -08:00
337b585ba0 Reformat code. 2026-02-17 13:44:36 -08:00
95a588b0df Add test for Git editor swap cleanup and improve swap file handling
- Added `test_swap_git_editor.cc` to verify proper swap file cleanup during Git editor workflows. Ensures no stale swap files are left after editor closure.
- Updated swap handling logic in `Editor.cc` to always remove swap files on buffer closure during normal exit, preventing accumulation of leftover files.
- Bumped version to 1.6.5 in `CMakeLists.txt`.
2026-02-17 13:10:01 -08:00
199d7a20f7 Add indented bullet reflow test, improve undo edge cases, and bump version
- Added `test_reflow_indented_bullets.cc` to verify correct reflow handling for indented bullet points.
- Enhanced undo system with additional tests for cursor adjacency, explicit grouping, branching, newline independence, and dirty-state tracking.
- Introduced external modification detection for files and required confirmation before overwrites.
- Refactored buffer save logic to use atomic writes and track on-disk identity.
- Updated CMake to include new test files and bumped version to 1.6.4.
2026-02-16 12:44:08 -08:00
44827fe53f Add mark-clearing behavior to refresh command and related test.
- Updated `Refresh` command to clear the mark when no active prompt, search, or visual-line mode is present.
- Added a new unit test verifying mark-clearing behavior for `Ctrl-G` (mapped to `Refresh`).
- Bumped version to 1.6.3 in `CMakeLists.txt`.
2026-02-14 23:05:44 -08:00
2a6ff2a862 Introduce swap journaling crash recovery system with tests.
- Added detailed journaling system (`SwapManager`) for crash recovery, including edit recording and replay.
- Integrated recovery prompts for handling swap files during file open flows.
- Implemented swap file cleanup, checkpointing, and compaction mechanisms.
- Added extensive unit tests for swap-related behaviors such as recovery prompts, file pruning, and corruption handling.
- Updated CMake to include new test files.
2026-02-13 08:45:27 -08:00
895e4ccb1e Add swap journaling and group undo/redo with extensive tests.
- Introduced SwapManager for sidecar journaling of buffer mutations, with a safe recovery mechanism.
- Added group undo/redo functionality, allowing atomic grouping of related edits.
- Implemented `SwapRecorder` and integrated it as a callback interface for mutations.
- Added unit tests for swap journaling (save/load/replay) and undo grouping.
- Refactored undo to support group tracking and ID management.
- Updated CMake to include the new tests and swap journaling logic.
2026-02-11 20:47:18 -08:00
15b350bfaa Add TestHarness infrastructure and initial smoke test
- Implemented `TestHarness` class for headless editor testing.
- Added utility methods for text insertion, editing, and querying.
- Introduced `test_daily_driver_harness` for verifying basic text buffer operations.
- Updated CMake to include the new test files.
2026-02-10 23:34:01 -08:00
cc8df36bdf Implement branching undo system with tests and updates.
- Added branching model for undo/redo, enabling multiple redo paths and branch selection.
- Updated `UndoNode` to include `parent` and refined hierarchical navigation.
- Extended `UndoSystem` with branching logic for redo operations, supporting sibling branch selection.
- Overhauled tests to validate branching behavior and tree invariants.
- Refined editor command logic for undo/redo with repeat counts and branch selection.
- Enabled test-only introspection hooks for undo tree validation.
- Updated CMake to include test definitions (`KTE_TESTS` flag).
2026-02-10 23:13:00 -08:00
1c0f04f076 Bump version to 1.6.0.
- Linear undo
- Multicursor support
- Reflow numbered lists
2026-02-10 22:41:20 -08:00
ac0eadc345 Add undo system with coalescing logic and comprehensive tests.
- Implemented robust undo system supporting coalescing of text operations (insert, backspace, delete).
- Added `UndoSystem` integration into the editor/commands pipeline.
- Wrote extensive unit tests for various undo/redo scenarios, including multiline operations, cursor preservation, and history management.
- Refactored to ensure consistent cursor behavior during undo/redo actions.
- Updated CMake to include new tests.
2026-02-10 22:39:55 -08:00
f3bdced3d4 Add visual-line mode support with tests and UI integration.
- Introduced visual-line mode for multi-line selection and edits.
- Implemented commands, rendering, and keyboard shortcuts.
- Added tests for broadcast operations in visual-line mode.
2026-02-10 22:07:13 -08:00
2551388420 Support numbered lists in reflow-paragraph.
Add `reflow-paragraph` tests for numbered lists with hanging indents and extend support for numbered list parsing and wrapping logic.
2026-02-10 21:23:20 -08:00
d2d155f211 Fix data race.
+ Add thread-safety with mutexes in `PieceTable` and `Buffer`
+ Bump version to 1.5.9
2026-01-28 01:03:58 -08:00
8634eb78f0 Refactor Init method across all frontends to include argc and argv for improved argument handling consistency. 2026-01-12 10:35:24 -08:00
6eb240a0c4 Refactor ImGui editor layout and scrolling logic for improved precision and consistency. 2026-01-11 15:34:56 -08:00
4c402f5ef3 Replace Greek and Mathematical Operators font fallback with Iosevka Extended for improved font handling. 2026-01-11 12:07:24 -08:00
a8abda4b87 Unicode improvements and version bump.
- Added full UTF-8 support for terminal rendering, including multi-width character handling.
- Improved font handling in ImGui with expanded glyph support (Greek, Mathematical Operators).
- Updated locale initialization to enable proper character rendering.
- Bumped version to 1.5.8.
2026-01-11 11:39:08 -08:00
7347556aa2 Add missing cmake for macos. 2026-01-02 10:39:33 -08:00
289e155c98 bump version 2026-01-02 09:50:08 -08:00
147a52f3d4 center cursor 2026-01-01 21:59:20 -08:00
dda7541e2f adding berkeley mono as the default. 2026-01-01 20:10:22 -08:00
2408f5494c bump version 2026-01-01 19:13:07 -08:00
2542690eca updating jump to line 2026-01-01 19:12:46 -08:00
cc0c187481 Improve macOS app build process and bundle handling.
- Updated `make-app-release` script to use `macdeployqt` with proper verbosity and bundle fixup.
- Introduced post-build fixup using CMake's `BundleUtilities` to internalize non-Qt dylibs.
- Enhanced macOS bundle RPATH settings for accurate Framework resolution.
- Added optional `kge_fixup_bundle` CMake target for post-build handling.
- Refined `default.nix` to load Nixpkgs in a default argument.
2025-12-09 18:49:16 -08:00
a8dcfbec58 Fix C-k c handling. 2025-12-08 15:28:45 -08:00
65705e3354 bump version 2025-12-07 15:25:50 -08:00
e1f9a9eb6a Preserve cursor position on buffer reload.
- Remember and restore the cursor's position after reloading a buffer, clamping if necessary.
- Improve user experience by maintaining editing context.
2025-12-07 15:25:40 -08:00
c9f34003f2 Add unit testing plan documentation.
- Introduced comprehensive test plan to guide development and ensure coverage.
- Documented test principles, execution harness, build steps, and test catalog.
- Categorized test cases by functionality (e.g., filesystem I/O, PieceTable semantics, buffer editing, undo system, etc.).
- Outlined regression tests and performance/stress scenarios.
- Provided a phased roadmap for implementing planned test cases.
2025-12-07 12:34:47 -08:00
f450ef825c Replace individual test binaries with unified test runner.
- Removed standalone test executables (`test_undo`, `test_buffer_save`, `test_buffer_open_nonexistent_save`, etc.).
- Introduced `kte_tests` as a unified test runner.
- Migrated existing tests to a new minimal, reusable framework in `tests/Test.h`.
- Updated `CMakeLists.txt` to build a single `kte_tests` executable.
- Simplified dependencies, reducing the need for ncurses/GUI in test builds.
2025-12-07 00:37:16 -08:00
f6f0c11be4 Add PieceTable-based buffer tests and improvements for file I/O and editing.
- Introduced comprehensive tests:
  - `test_buffer_open_nonexistent_save.cc`: Save after opening a non-existent file.
  - `test_buffer_save.cc`: Save buffer contents to disk.
  - `test_buffer_save_existing.cc`: Save after opening existing files.
- Implemented `PieceTable::WriteToStream()` to directly stream content without full materialization.
- Updated `Buffer::Save` and `Buffer::SaveAs` to use efficient streaming via `PieceTable`.
- Enhanced editing commands (`Insert`, `Delete`, `Replace`, etc.) to use PieceTable APIs, ensuring proper undo and save functionality.
2025-12-07 00:30:11 -08:00
657c9bbc19 bump version 2025-12-06 11:40:27 -08:00
3493695165 Add support for creating a new empty buffer (C-k i).
- Introduced `BufferNew` command to create and switch to a new unnamed buffer.
- Registered `BufferNew` in the command registry and updated keymap and help text.
- Implemented `cmd_buffer_new()` to handle buffer creation and switching logic.
2025-12-06 11:40:00 -08:00
5f57cf23dc bump version 2025-12-05 21:31:46 -08:00
9312550be4 Fix scrolling issue in TUI. 2025-12-05 21:31:33 -08:00
f734f98891 update mac app release 2025-12-05 20:53:04 -08:00
1191e14ce9 Bump version. 2025-12-05 20:53:04 -08:00
12cc04d7e0 Improve input handling and scrolling behavior for high-resolution trackpads.
- Added precise fractional mouse wheel delta handling with per-step command emission.
- Introduced scroll accumulators (`wheel_accum_y_`, `wheel_accum_x_`) for high-resolution trackpad input.
- Replaced hardcoded ESC delay with configurable `kEscDelayMs` constant in `TerminalFrontend`.
- Enabled mouse position reporting and reduced CPU usage during idle with optimized `timeout()` setting.
2025-12-05 20:53:04 -08:00
3f4c60d311 Add detailed migration plan for PieceTable-based buffer architecture.
- Created `piece-table-migration.md` outlining the steps to transition from GapBuffer to a unified PieceTable architecture.
- Included phased approach: extending PieceTable, Buffer adapter layer, command updates, and renderer changes.
- Detailed API changes, file updates, testing strategy, risk assessment, and timeline for each migration phase.
- Document serves as a reference for architecture goals and implementation details.
2025-12-05 20:53:04 -08:00
71c1c9e50b Remove GapBuffer and associated legacy implementation.
- Deleted `GapBuffer` class and its API implementations.
- Removed `AppendBuffer` selector and conditional `KTE_USE_PIECE_TABLE` macros.
- Eliminated legacy support in buffer APIs, file I/O, benchmarks, and correctness tests.
- Updated guidelines and comments to reflect PieceTable as the default and only buffer backend.
2025-12-05 20:53:04 -08:00
afb6888c31 Introduce PieceTable-based buffer backend (Phase 1)
- Added `PieceTable` class for efficient text manipulation and implemented core editing APIs (`Insert`, `Delete`, `Find`, etc.).
- Integrated `PieceTable` into `Buffer` class with an adapter for rows caching.
- Enabled seamless switching between legacy row-based and new PieceTable-backed editing via `KTE_USE_BUFFER_PIECE_TABLE`.
- Updated file I/O, line-based queries, and cursor operations to support PieceTable-based storage.
- Lazy rebuilding of line index and improved management of edit state for performance.
2025-12-05 20:53:04 -08:00
222f73252b nixos: rename kge->kge-qt 2025-12-05 10:37:16 -08:00
51ea473a91 nixos and qt fixup 2025-12-05 09:25:48 -08:00
fd517b5d57 fix nixos build 2025-12-05 08:21:38 -08:00
149 changed files with 51652 additions and 18753 deletions

View File

@@ -1,5 +1,6 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
<option name="PREFERRED_PROJECT_CODE_STYLE" value="sccl" />
</state>
</component>

3
.idea/editor.xml generated
View File

@@ -19,7 +19,7 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
@@ -58,6 +58,7 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefinitionsOrder/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />

2
.idea/kte.iml generated
View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<module classpath="CMake" type="CPP_MODULE" version="4">
<module classpath="CIDR" type="CPP_MODULE" version="4">
<component name="FacetManager">
<facet type="Python" name="Python facet">
<configuration sdkName="" />

View File

@@ -1,28 +1,35 @@
# Project Guidelines
kte is Kyle's Text Editor — a simple, fast text editor written in C++17. It
replaces the earlier C implementation, ke (see the ke manual in `docs/ke.md`). The
design draws inspiration from Antirez' kilo, with keybindings rooted in the
kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
It
replaces the earlier C implementation, ke (see the ke manual in
`docs/ke.md`). The
design draws inspiration from Antirez' kilo, with keybindings rooted in
the
WordStar/VDE family and emacs. The spiritual parent is `mg(1)`.
These guidelines summarize the goals, interfaces, key operations, and current
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.
- 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.
- 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
- PieceTable: editable in-memory text representation (
`PieceTable.h/.cpp`).
- InputHandler: interface for handling text input (`InputHandler.h/`),
along
with `TerminalInputHandler` (ncurses-based) and `GUIInputHandler`.
- Renderer: interface for rendering text (`Renderer.h`), along with
`TerminalRenderer` (ncurses-based) and `GUIRenderer`.
@@ -36,13 +43,16 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
## Contributing/Development Notes
- C++ standard: C++17.
- C++ standard: C++20.
- Keep dependencies minimal.
- Prefer small, focused changes that preserve kes UX unless explicitly changing
- Prefer small, focused changes that preserve kes UX unless explicitly
changing
behavior.
## References
- Previous editor manual: `ke.md` (canonical keybinding/spec reference for now).
- Previous editor manual: `ke.md` (canonical keybinding/spec reference
for now).
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.

View File

@@ -1,12 +0,0 @@
/*
* AppendBuffer.h - selector header to choose GapBuffer or PieceTable
*/
#pragma once
#ifdef KTE_USE_PIECE_TABLE
#include "PieceTable.h"
using AppendBuffer = PieceTable;
#else
#include "GapBuffer.h"
using AppendBuffer = GapBuffer;
#endif

600
Buffer.cc
View File

@@ -2,8 +2,20 @@
#include <sstream>
#include <filesystem>
#include <cstdlib>
#include <limits>
#include <cerrno>
#include <cstring>
#include <string_view>
#include <vector>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include "Buffer.h"
#include "SwapRecorder.h"
#include "UndoSystem.h"
#include "UndoTree.h"
// For reconstructing highlighter state on copies
@@ -19,6 +31,159 @@ Buffer::Buffer()
}
bool
Buffer::stat_identity(const std::string &path, FileIdentity &out)
{
struct stat st{};
if (::stat(path.c_str(), &st) != 0) {
out.valid = false;
return false;
}
out.valid = true;
// Use nanosecond timestamp when available.
std::uint64_t ns = 0;
#if defined(__APPLE__)
ns = static_cast<std::uint64_t>(st.st_mtimespec.tv_sec) * 1000000000ull
+ static_cast<std::uint64_t>(st.st_mtimespec.tv_nsec);
#else
ns = static_cast<std::uint64_t>(st.st_mtim.tv_sec) * 1000000000ull
+ static_cast<std::uint64_t>(st.st_mtim.tv_nsec);
#endif
out.mtime_ns = ns;
out.size = static_cast<std::uint64_t>(st.st_size);
out.dev = static_cast<std::uint64_t>(st.st_dev);
out.ino = static_cast<std::uint64_t>(st.st_ino);
return true;
}
bool
Buffer::current_disk_identity(FileIdentity &out) const
{
if (!is_file_backed_ || filename_.empty()) {
out.valid = false;
return false;
}
return stat_identity(filename_, out);
}
bool
Buffer::ExternallyModifiedOnDisk() const
{
if (!is_file_backed_ || filename_.empty())
return false;
FileIdentity now{};
if (!current_disk_identity(now)) {
// If the file vanished, treat as modified when we previously had an identity.
return on_disk_identity_.valid;
}
if (!on_disk_identity_.valid)
return false;
return now.mtime_ns != on_disk_identity_.mtime_ns
|| now.size != on_disk_identity_.size
|| now.dev != on_disk_identity_.dev
|| now.ino != on_disk_identity_.ino;
}
void
Buffer::RefreshOnDiskIdentity()
{
FileIdentity id{};
if (current_disk_identity(id))
on_disk_identity_ = id;
}
static bool
write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
{
std::size_t off = 0;
while (off < len) {
ssize_t n = ::write(fd, data + off, len - off);
if (n < 0) {
if (errno == EINTR)
continue;
err = std::string("Write failed: ") + std::strerror(errno);
return false;
}
off += static_cast<std::size_t>(n);
}
return true;
}
static void
best_effort_fsync_dir(const std::string &path)
{
try {
std::filesystem::path p(path);
std::filesystem::path dir = p.parent_path();
if (dir.empty())
return;
int dfd = ::open(dir.c_str(), O_RDONLY);
if (dfd < 0)
return;
(void) ::fsync(dfd);
(void) ::close(dfd);
} catch (...) {
// best-effort
}
}
static bool
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err)
{
// Create a temp file in the same directory so rename() is atomic.
std::filesystem::path p(path);
std::filesystem::path dir = p.parent_path();
std::string base = p.filename().string();
std::filesystem::path tmpl = dir / ("." + base + ".kte.tmp.XXXXXX");
std::string tmpl_s = tmpl.string();
// mkstemp requires a mutable buffer.
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
buf.push_back('\0');
int fd = ::mkstemp(buf.data());
if (fd < 0) {
err = std::string("Failed to create temp file for save: ") + std::strerror(errno);
return false;
}
std::string tmp_path(buf.data());
// If the destination exists, carry over its permissions.
struct stat dst_st{};
if (::stat(path.c_str(), &dst_st) == 0) {
(void) ::fchmod(fd, dst_st.st_mode);
}
bool ok = write_all_fd(fd, data, len, err);
if (ok) {
if (::fsync(fd) != 0) {
err = std::string("fsync failed: ") + std::strerror(errno);
ok = false;
}
}
(void) ::close(fd);
if (ok) {
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
err = std::string("rename failed: ") + std::strerror(errno);
ok = false;
}
}
if (!ok) {
(void) ::unlink(tmp_path.c_str());
return false;
}
best_effort_fsync_dir(path);
return true;
}
Buffer::Buffer(const std::string &path)
{
std::string err;
@@ -29,20 +194,22 @@ Buffer::Buffer(const std::string &path)
// Copy constructor/assignment: perform a deep copy of core fields; reinitialize undo for the new buffer.
Buffer::Buffer(const Buffer &other)
{
curx_ = other.curx_;
cury_ = other.cury_;
rx_ = other.rx_;
nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_;
rows_ = other.rows_;
filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_;
dirty_ = other.dirty_;
read_only_ = other.read_only_;
mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
curx_ = other.curx_;
cury_ = other.cury_;
rx_ = other.rx_;
nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_;
rows_ = other.rows_;
content_ = other.content_;
rows_cache_dirty_ = other.rows_cache_dirty_;
filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_;
dirty_ = other.dirty_;
read_only_ = other.read_only_;
mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
// Copy syntax/highlighting flags
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
@@ -77,23 +244,25 @@ Buffer::operator=(const Buffer &other)
{
if (this == &other)
return *this;
curx_ = other.curx_;
cury_ = other.cury_;
rx_ = other.rx_;
nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_;
rows_ = other.rows_;
filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_;
dirty_ = other.dirty_;
read_only_ = other.read_only_;
mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_;
curx_ = other.curx_;
cury_ = other.cury_;
rx_ = other.rx_;
nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_;
rows_ = other.rows_;
content_ = other.content_;
rows_cache_dirty_ = other.rows_cache_dirty_;
filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_;
dirty_ = other.dirty_;
read_only_ = other.read_only_;
mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = other.filetype_;
// Recreate undo system for this instance
undo_tree_ = std::make_unique<UndoTree>();
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
@@ -137,10 +306,12 @@ Buffer::Buffer(Buffer &&other) noexcept
undo_sys_(std::move(other.undo_sys_))
{
// Move syntax/highlighting state
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// Update UndoSystem's buffer reference to point to this object
if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this);
@@ -173,11 +344,12 @@ Buffer::operator=(Buffer &&other) noexcept
undo_sys_ = std::move(other.undo_sys_);
// Move syntax/highlighting state
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_;
filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_;
// Update UndoSystem's buffer reference to point to this object
if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this);
@@ -229,6 +401,10 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
mark_set_ = false;
mark_curx_ = mark_cury_ = 0;
// Empty PieceTable
content_.Clear();
rows_cache_dirty_ = true;
return true;
}
@@ -238,50 +414,24 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
return false;
}
// Detect if file ends with a newline so we can preserve a final empty line
// in our in-memory representation (mg-style semantics).
bool ends_with_nl = false;
{
in.seekg(0, std::ios::end);
std::streamoff sz = in.tellg();
if (sz > 0) {
in.seekg(-1, std::ios::end);
char last = 0;
in.read(&last, 1);
ends_with_nl = (last == '\n');
} else {
in.clear();
}
// Rewind to start for line-by-line read
in.clear();
// Read entire file into PieceTable as-is
std::string data;
in.seekg(0, std::ios::end);
auto sz = in.tellg();
if (sz > 0) {
data.resize(static_cast<std::size_t>(sz));
in.seekg(0, std::ios::beg);
in.read(data.data(), static_cast<std::streamsize>(data.size()));
}
rows_.clear();
std::string line;
while (std::getline(in, line)) {
// std::getline strips the '\n', keep raw line content only
// Handle potential Windows CRLF: strip trailing '\r'
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
rows_.emplace_back(line);
}
// If the file ended with a newline and we didn't already get an
// empty final row from getline (e.g., when the last textual line
// had content followed by '\n'), append an empty row to represent
// the cursor position past the last newline.
if (ends_with_nl) {
if (rows_.empty() || !rows_.back().empty()) {
rows_.emplace_back(std::string());
}
}
nrows_ = rows_.size();
filename_ = norm;
is_file_backed_ = true;
dirty_ = false;
content_.Clear();
if (!data.empty())
content_.Append(data.data(), data.size());
rows_cache_dirty_ = true;
nrows_ = 0; // not used under PieceTable
filename_ = norm;
is_file_backed_ = true;
dirty_ = false;
RefreshOnDiskIdentity();
// Reset/initialize undo system for this loaded file
if (!undo_tree_)
@@ -308,24 +458,16 @@ Buffer::Save(std::string &err) const
err = "Buffer is not file-backed; use SaveAs()";
return false;
}
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) {
err = "Failed to open for write: " + filename_;
const std::size_t sz = content_.Size();
const char *data = sz ? content_.Data() : nullptr;
if (sz && !data) {
err = "Internal error: buffer materialization failed";
return false;
}
for (std::size_t i = 0; i < rows_.size(); ++i) {
const char *d = rows_[i].Data();
std::size_t n = rows_[i].Size();
if (d && n)
out.write(d, static_cast<std::streamsize>(n));
if (i + 1 < rows_.size()) {
out.put('\n');
}
}
if (!out.good()) {
err = "Write error";
if (!atomic_write_file(filename_, data ? data : "", sz, err))
return false;
}
// Update observed on-disk identity after a successful save.
const_cast<Buffer *>(this)->RefreshOnDiskIdentity();
// Note: const method cannot change dirty_. Intentionally const to allow UI code
// to decide when to flip dirty flag after successful save.
return true;
@@ -354,29 +496,19 @@ Buffer::SaveAs(const std::string &path, std::string &err)
out_path = path;
}
// Write to the given path
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) {
err = "Failed to open for write: " + out_path;
const std::size_t sz = content_.Size();
const char *data = sz ? content_.Data() : nullptr;
if (sz && !data) {
err = "Internal error: buffer materialization failed";
return false;
}
for (std::size_t i = 0; i < rows_.size(); ++i) {
const char *d = rows_[i].Data();
std::size_t n = rows_[i].Size();
if (d && n)
out.write(d, static_cast<std::streamsize>(n));
if (i + 1 < rows_.size()) {
out.put('\n');
}
}
if (!out.good()) {
err = "Write error";
if (!atomic_write_file(out_path, data ? data : "", sz, err))
return false;
}
filename_ = out_path;
is_file_backed_ = true;
dirty_ = false;
RefreshOnDiskIdentity();
return true;
}
@@ -389,7 +521,7 @@ Buffer::AsString() const
if (this->Dirty()) {
ss << "*";
}
ss << ">: " << rows_.size() << " lines";
ss << ">: " << content_.LineCount() << " lines";
return ss.str();
}
@@ -400,111 +532,166 @@ Buffer::insert_text(int row, int col, std::string_view text)
{
if (row < 0)
row = 0;
if (static_cast<std::size_t>(row) > rows_.size())
row = static_cast<int>(rows_.size());
if (rows_.empty())
rows_.emplace_back("");
if (static_cast<std::size_t>(row) >= rows_.size())
rows_.emplace_back("");
auto y = static_cast<std::size_t>(row);
auto x = static_cast<std::size_t>(col);
if (x > rows_[y].size())
x = rows_[y].size();
std::string remain(text);
while (true) {
auto pos = remain.find('\n');
if (pos == std::string::npos) {
rows_[y].insert(x, remain);
break;
}
// Insert up to newline
std::string seg = remain.substr(0, pos);
rows_[y].insert(x, seg);
x += seg.size();
// Split line at x
std::string tail = rows_[y].substr(x);
rows_[y].erase(x);
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
y += 1;
x = 0;
remain.erase(0, pos + 1);
if (col < 0)
col = 0;
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
static_cast<std::size_t>(col));
if (!text.empty()) {
content_.Insert(off, text.data(), text.size());
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnInsert(row, col, text);
}
// Do not set dirty here; UndoSystem will manage state/dirty externally
}
// ===== Adapter helpers for PieceTable-backed Buffer =====
std::string_view
Buffer::GetLineView(std::size_t row) const
{
// Get byte range for the logical line and return a view into materialized data
auto range = content_.GetLineRange(row); // [start,end) in bytes
const char *base = content_.Data(); // materializes if needed
if (!base)
return std::string_view();
const std::size_t start = range.first;
const std::size_t len = (range.second > range.first) ? (range.second - range.first) : 0;
return std::string_view(base + start, len);
}
void
Buffer::ensure_rows_cache() const
{
std::lock_guard<std::mutex> lock(buffer_mutex_);
if (!rows_cache_dirty_)
return;
rows_.clear();
const std::size_t lc = content_.LineCount();
rows_.reserve(lc);
for (std::size_t i = 0; i < lc; ++i) {
rows_.emplace_back(content_.GetLine(i));
}
// Keep nrows_ in sync for any legacy code that still reads it
const_cast<Buffer *>(this)->nrows_ = rows_.size();
rows_cache_dirty_ = false;
}
std::size_t
Buffer::content_LineCount_() const
{
return content_.LineCount();
}
#if defined(KTE_TESTS)
std::string
Buffer::BytesForTests() const
{
const std::size_t sz = content_.Size();
if (sz == 0)
return std::string();
const char *data = content_.Data();
if (!data)
return std::string();
return std::string(data, data + sz);
}
#endif
void
Buffer::delete_text(int row, int col, std::size_t len)
{
if (rows_.empty() || len == 0)
if (len == 0)
return;
if (row < 0)
row = 0;
if (static_cast<std::size_t>(row) >= rows_.size())
return;
const auto y = static_cast<std::size_t>(row);
const auto x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size());
if (col < 0)
col = 0;
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
static_cast<std::size_t>(col));
std::size_t r = static_cast<std::size_t>(row);
std::size_t c = static_cast<std::size_t>(col);
std::size_t remaining = len;
while (remaining > 0 && y < rows_.size()) {
auto &line = rows_[y];
const std::size_t in_line = std::min<std::size_t>(remaining, line.size() - std::min(x, line.size()));
if (x < line.size() && in_line > 0) {
line.erase(x, in_line);
remaining -= in_line;
const std::size_t lc = content_.LineCount();
while (remaining > 0 && r < lc) {
const std::string line = content_.GetLine(r); // logical line (without trailing '\n')
const std::size_t L = line.size();
if (c < L) {
const std::size_t take = std::min(remaining, L - c);
c += take;
remaining -= take;
}
if (remaining == 0)
break;
// If at or beyond end of line and there is a next line, join it (deleting the implied '\n')
if (y + 1 < rows_.size()) {
line += rows_[y + 1];
rows_.erase(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1));
// deleting the newline consumes one virtual character
if (remaining > 0) {
// Treat the newline as one deletion unit if len spans it
// We already joined, so nothing else to do here.
}
// Consume newline between lines as one char, if there is a next line
if (r + 1 < lc) {
remaining -= 1; // the newline
r += 1;
c = 0;
} else {
break;
// At last line and still remaining: delete to EOF
const std::size_t total = content_.Size();
const std::size_t actual = (total > start) ? (total - start) : 0;
if (actual == 0)
return;
content_.Delete(start, actual);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, actual);
return;
}
}
// Compute end offset at (r,c)
std::size_t end = content_.LineColToByteOffset(r, c);
if (end > start) {
const std::size_t actual = end - start;
content_.Delete(start, actual);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, actual);
}
}
void
Buffer::split_line(int row, const int col)
{
if (row < 0) {
int c = col;
if (row < 0)
row = 0;
}
if (static_cast<std::size_t>(row) >= rows_.size()) {
rows_.resize(static_cast<std::size_t>(row) + 1);
}
const auto y = static_cast<std::size_t>(row);
const auto x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size());
const auto tail = rows_[y].substr(x);
rows_[y].erase(x);
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
if (c < 0)
c = 0;
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
static_cast<std::size_t>(c));
const char nl = '\n';
content_.Insert(off, &nl, 1);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
}
void
Buffer::join_lines(int row)
{
if (row < 0) {
if (row < 0)
row = 0;
}
const auto y = static_cast<std::size_t>(row);
if (y + 1 >= rows_.size()) {
std::size_t r = static_cast<std::size_t>(row);
if (r + 1 >= content_.LineCount())
return;
}
rows_[y] += rows_[y + 1];
rows_.erase(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1));
const int col = static_cast<int>(content_.GetLine(r).size());
// Delete the newline between line r and r+1
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
content_.Delete(end_of_line, 1);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, 1);
}
@@ -513,9 +700,18 @@ Buffer::insert_row(int row, const std::string_view text)
{
if (row < 0)
row = 0;
if (static_cast<std::size_t>(row) > rows_.size())
row = static_cast<int>(rows_.size());
rows_.insert(rows_.begin() + row, Line(std::string(text)));
std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row), 0);
if (!text.empty())
content_.Insert(off, text.data(), text.size());
const char nl = '\n';
content_.Insert(off + text.size(), &nl, 1);
rows_cache_dirty_ = true;
if (swap_rec_) {
// Avoid allocation: emit the row text insertion (if any) and the newline insertion.
if (!text.empty())
swap_rec_->OnInsert(row, 0, text);
swap_rec_->OnInsert(row, static_cast<int>(text.size()), std::string_view("\n", 1));
}
}
@@ -524,9 +720,31 @@ Buffer::delete_row(int row)
{
if (row < 0)
row = 0;
if (static_cast<std::size_t>(row) >= rows_.size())
std::size_t r = static_cast<std::size_t>(row);
if (r >= content_.LineCount())
return;
rows_.erase(rows_.begin() + row);
auto range = content_.GetLineRange(r); // [start,end)
// If not last line, ensure we include the separating newline by using end as-is (which points to next line start)
// If last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
const std::size_t start = range.first;
const std::size_t end = range.second;
const std::size_t actual = (end > start) ? (end - start) : 0;
if (actual == 0)
return;
content_.Delete(start, actual);
rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, 0, actual);
}
void
Buffer::replace_all_bytes(const std::string_view bytes)
{
content_.Clear();
if (!bytes.empty())
content_.Append(bytes.data(), bytes.size());
rows_cache_dirty_ = true;
}

268
Buffer.h
View File

@@ -1,5 +1,37 @@
/*
* 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_), rendered column (rx_)
* - 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
* - rx_: Rendered column accounting for tab expansion
*
* 2. File backing:
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
* - File identity tracking detects external modifications
*
* 3. Legacy Line wrapper:
* - Buffer::Line provides a string-like interface for legacy command code
* - New code should prefer direct PieceTable operations
* - See DEVELOPER_GUIDE.md for migration guidance
*
* 4. Content access:
* - Rows(): Materialized line cache (legacy, being phased out)
* - GetLineView(): Zero-copy line access via string_view (preferred)
* - Direct PieceTable access for new editing operations
*/
#pragma once
@@ -9,12 +41,12 @@
#include <vector>
#include <string_view>
#include "AppendBuffer.h"
#include "PieceTable.h"
#include "UndoSystem.h"
#include <cstdint>
#include <memory>
#include "syntax/HighlighterEngine.h"
#include "Highlight.h"
#include <mutex>
// Forward declaration for swap journal integration
namespace kte {
@@ -42,6 +74,14 @@ public:
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
// External modification detection.
// Returns true if the file on disk differs from the last observed identity recorded
// on open/save.
[[nodiscard]] bool ExternallyModifiedOnDisk() const;
// Refresh the stored on-disk identity to match current stat (used after open/save).
void RefreshOnDiskIdentity();
// Accessors
[[nodiscard]] std::size_t Curx() const
{
@@ -63,7 +103,7 @@ public:
[[nodiscard]] std::size_t Nrows() const
{
return nrows_;
return content_LineCount_();
}
@@ -79,7 +119,8 @@ public:
}
// Line wrapper backed by AppendBuffer (GapBuffer/PieceTable)
// Line wrapper used by legacy command paths.
// Keep this lightweight: store materialized bytes only for that line.
class Line {
public:
Line() = default;
@@ -108,119 +149,102 @@ public:
// capacity helpers
void Clear()
{
buf_.Clear();
s_.clear();
}
// size/access
[[nodiscard]] std::size_t size() const
{
return buf_.Size();
return s_.size();
}
[[nodiscard]] bool empty() const
{
return size() == 0;
return s_.empty();
}
// read-only raw view
[[nodiscard]] const char *Data() const
{
return buf_.Data();
return s_.data();
}
[[nodiscard]] std::size_t Size() const
{
return buf_.Size();
return s_.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';
return (i < s_.size()) ? s_[i] : '\0';
}
// conversions
explicit operator std::string() const
{
return {buf_.Data() ? buf_.Data() : "", buf_.Size()};
return s_;
}
// 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};
return pos < s_.size() ? s_.substr(pos) : std::string();
}
[[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};
return pos < s_.size() ? s_.substr(pos, len) : std::string();
}
// minimal find() to support search within a line
[[nodiscard]] std::size_t find(const std::string &needle, const std::size_t pos = 0) const
{
// Materialize to std::string for now; Line is backed by AppendBuffer
const auto s = static_cast<std::string>(*this);
return s.find(needle, pos);
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);
});
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);
});
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);
});
if (pos > s_.size())
pos = s_.size();
s_.insert(pos, seg);
}
Line &operator+=(const Line &other)
{
buf_.Append(other.buf_.Data(), other.buf_.Size());
s_ += other.s_;
return *this;
}
Line &operator+=(const std::string &s)
{
buf_.Append(s.data(), s.size());
s_ += s;
return *this;
}
@@ -234,37 +258,47 @@ public:
private:
void assign_from(const std::string &s)
{
buf_.Clear();
if (!s.empty())
buf_.Append(s.data(), s.size());
s_ = s;
}
template<typename F>
void material_edit(F fn)
{
std::string tmp = static_cast<std::string>(*this);
fn(tmp);
assign_from(tmp);
}
AppendBuffer buf_;
std::string s_;
};
[[nodiscard]] const std::vector<Line> &Rows() const
{
ensure_rows_cache();
return rows_;
}
[[nodiscard]] std::vector<Line> &Rows()
{
ensure_rows_cache();
return rows_;
}
// Lightweight, lazy per-line accessors that avoid materializing all rows.
// Prefer these over Rows() in hot paths to reduce memory overhead on large files.
[[nodiscard]] std::string GetLineString(std::size_t row) const
{
return content_.GetLine(row);
}
[[nodiscard]] std::pair<std::size_t, std::size_t> GetLineRange(std::size_t row) const
{
return content_.GetLineRange(row);
}
// Zero-copy view of a line. Points into the materialized backing store; becomes
// invalid after subsequent edits. Use immediately.
[[nodiscard]] std::string_view GetLineView(std::size_t row) const;
[[nodiscard]] const std::string &Filename() const
{
return filename_;
@@ -376,6 +410,71 @@ 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;
}
[[nodiscard]] std::size_t VisualLineStartY() const
{
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_;
}
[[nodiscard]] std::size_t VisualLineEndY() const
{
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_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;
// Syntax highlighting integration (per-buffer)
@@ -409,13 +508,13 @@ public:
}
kte::HighlighterEngine *Highlighter()
[[nodiscard]] kte::HighlighterEngine *Highlighter()
{
return highlighter_.get();
}
const kte::HighlighterEngine *Highlighter() const
[[nodiscard]] const kte::HighlighterEngine *Highlighter() const
{
return highlighter_.get();
}
@@ -435,6 +534,12 @@ public:
}
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
{
return swap_rec_;
}
// Raw, low-level editing APIs used by UndoSystem apply().
// These must NOT trigger undo recording. They also do not move the cursor.
void insert_text(int row, int col, std::string_view text);
@@ -449,24 +554,61 @@ public:
void delete_row(int row);
// 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.
void replace_all_bytes(std::string_view bytes);
// Undo system accessors (created per-buffer)
UndoSystem *Undo();
[[nodiscard]] UndoSystem *Undo();
[[nodiscard]] const UndoSystem *Undo() const;
#if defined(KTE_TESTS)
// Test-only: return the raw buffer bytes (including newlines) as a string.
[[nodiscard]] std::string BytesForTests() const;
#endif
private:
struct FileIdentity {
bool valid = false;
std::uint64_t mtime_ns = 0;
std::uint64_t size = 0;
std::uint64_t dev = 0;
std::uint64_t ino = 0;
};
[[nodiscard]] static bool stat_identity(const std::string &path, FileIdentity &out);
[[nodiscard]] bool current_disk_identity(FileIdentity &out) const;
mutable FileIdentity on_disk_identity_{};
// State mirroring original C struct (without undo_tree)
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
std::size_t rx_ = 0; // render x (tabs expanded)
std::size_t nrows_ = 0; // number of rows
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
std::vector<Line> rows_; // buffer rows (without trailing newlines)
mutable std::vector<Line> rows_; // materialized cache of rows (without trailing newlines)
// PieceTable is the source of truth.
PieceTable content_{};
mutable bool rows_cache_dirty_ = true; // invalidate on edits / I/O
// Helper to rebuild rows_ from content_
void ensure_rows_cache() const;
// Helper to query content_.LineCount() while keeping header minimal
std::size_t content_LineCount_() const;
std::string filename_;
bool is_file_backed_ = false;
bool dirty_ = false;
bool read_only_ = false;
bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0;
bool is_file_backed_ = false;
bool dirty_ = false;
bool read_only_ = false;
bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0;
bool visual_line_active_ = false;
std::size_t visual_line_anchor_y_ = 0;
std::size_t visual_line_active_y_ = 0;
// Per-buffer undo state
std::unique_ptr<struct UndoTree> undo_tree_;
@@ -479,4 +621,6 @@ private:
std::unique_ptr<kte::HighlighterEngine> highlighter_;
// Non-owning pointer to swap recorder managed by Editor/SwapManager
kte::SwapRecorder *swap_rec_ = nullptr;
mutable std::mutex buffer_mutex_;
};

View File

@@ -4,14 +4,13 @@ project(kte)
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.4.1")
set(KTE_VERSION "1.6.6")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.")
option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" ON)
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
@@ -64,16 +63,24 @@ endif ()
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
if (${BUILD_GUI})
if (BUILD_GUI)
include(cmake/imgui.cmake)
endif ()
# NCurses for terminal mode
set(CURSES_NEED_NCURSES)
set(CURSES_NEED_WIDE)
set(CURSES_NEED_NCURSES TRUE)
set(CURSES_NEED_WIDE TRUE)
find_package(Curses REQUIRED)
include_directories(${CURSES_INCLUDE_DIR})
# 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
@@ -128,7 +135,6 @@ if (BUILD_GUI)
endif ()
set(COMMON_SOURCES
GapBuffer.cc
PieceTable.cc
Buffer.cc
Editor.cc
@@ -210,14 +216,13 @@ set(FONT_HEADERS
fonts/Syne.h
fonts/Triplicate.h
fonts/Unispace.h
fonts/BerkeleyMono.h
)
set(COMMON_HEADERS
GapBuffer.h
PieceTable.h
Buffer.h
Editor.h
AppendBuffer.h
Command.h
HelpText.h
KKeymap.h
@@ -259,6 +264,7 @@ if (BUILD_GUI)
ImGuiFrontend.h
ImGuiInputHandler.h
ImGuiRenderer.h
fonts/BerkeleyMono.h
)
endif ()
endif ()
@@ -270,9 +276,6 @@ add_executable(kte
${COMMON_HEADERS}
)
if (KTE_USE_PIECE_TABLE)
target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1)
endif ()
if (KTE_UNDO_DEBUG)
target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1)
endif ()
@@ -299,34 +302,67 @@ install(TARGETS kte
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
if (BUILD_TESTS)
# test_undo executable for testing undo/redo system
add_executable(test_undo
test_undo.cc
${COMMON_SOURCES}
${COMMON_HEADERS}
# Unified unit test runner
add_executable(kte_tests
tests/TestRunner.cc
tests/Test.h
tests/TestHarness.h
tests/test_daily_driver_harness.cc
tests/test_daily_workflows.cc
tests/test_buffer_io.cc
tests/test_buffer_rows.cc
tests/test_command_semantics.cc
tests/test_kkeymap.cc
tests/test_swap_recorder.cc
tests/test_swap_writer.cc
tests/test_swap_replay.cc
tests/test_swap_recovery_prompt.cc
tests/test_swap_cleanup.cc
tests/test_swap_git_editor.cc
tests/test_piece_table.cc
tests/test_search.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
# minimal engine sources required by Buffer
PieceTable.cc
Buffer.cc
Editor.cc
Command.cc
HelpText.cc
Swap.cc
KKeymap.cc
SwapRecorder.h
OptimizedSearch.cc
UndoNode.cc
UndoTree.cc
UndoSystem.cc
${SYNTAX_SOURCES}
)
if (KTE_USE_PIECE_TABLE)
target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1)
endif ()
# Allow test-only introspection hooks (guarded in headers) without affecting production builds.
target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
if (KTE_UNDO_DEBUG)
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1)
endif ()
# Allow tests to include project headers like "Buffer.h"
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(test_undo ${CURSES_LIBRARIES})
# Keep tests free of ncurses/GUI deps
if (KTE_ENABLE_TREESITTER)
if (TREESITTER_INCLUDE_DIR)
target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR})
target_include_directories(kte_tests PRIVATE ${TREESITTER_INCLUDE_DIR})
endif ()
if (TREESITTER_LIBRARY)
target_link_libraries(test_undo ${TREESITTER_LIBRARY})
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
endif ()
endif ()
endif ()
if (${BUILD_GUI})
if (BUILD_GUI)
# ImGui::CreateContext();
# ImGuiIO& io = ImGui::GetIO();
@@ -381,12 +417,18 @@ if (${BUILD_GUI})
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
@ONLY)
# Ensure proper macOS bundle properties and RPATH so our bundled
# frameworks are preferred over system/Homebrew ones.
set_target_properties(kge PROPERTIES
MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
MACOSX_BUNDLE_BUNDLE_NAME "kge"
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist")
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist"
# Prefer the app's bundled frameworks at runtime
INSTALL_RPATH "@executable_path/../Frameworks"
BUILD_WITH_INSTALL_RPATH TRUE
)
add_dependencies(kge kte)
add_custom_command(TARGET kge POST_BUILD
@@ -410,4 +452,20 @@ if (${BUILD_GUI})
# Install kge man page only when GUI is built
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
# 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, useful after macdeployqt to ensure non-Qt dylibs are internalized.
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-Qt dylibs"
VERBATIM)
add_dependencies(kge_fixup_bundle kge)
endif ()
endif ()
endif ()

1060
Command.cc

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,7 @@ enum class CommandId {
VisualFontPickerToggle,
// Buffers
BufferSwitchStart, // begin buffer switch prompt
BufferNew, // create a new empty, unnamed buffer (C-k i)
BufferClose,
BufferNext,
BufferPrev,
@@ -46,6 +47,7 @@ enum class CommandId {
MoveFileStart, // move to beginning of file
MoveFileEnd, // move to end of file
ToggleMark, // toggle mark at cursor
VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /)
JumpToMark, // jump to mark, set mark to previous cursor
KillRegion, // kill region between mark and cursor (to kill ring)
CopyRegion, // copy region to kill ring (Alt-w)

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# 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 \
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"]

250
Editor.cc
View File

@@ -1,6 +1,7 @@
#include <algorithm>
#include <utility>
#include <cstdio>
#include <filesystem>
#include <utility>
#include "Editor.h"
#include "syntax/HighlighterRegistry.h"
@@ -8,6 +9,41 @@
#include "syntax/NullHighlighter.h"
namespace {
static std::string
buffer_bytes_via_views(const Buffer &b)
{
const std::size_t nrows = b.Nrows();
std::string out;
for (std::size_t i = 0; i < nrows; i++) {
auto v = b.GetLineView(i);
out.append(v.data(), v.size());
}
return out;
}
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
Editor::Editor()
{
swap_ = std::make_unique<kte::SwapManager>();
@@ -128,8 +164,8 @@ Editor::AddBuffer(const Buffer &buf)
buffers_.push_back(buf);
// Attach swap recorder
if (swap_) {
buffers_.back().SetSwapRecorder(swap_.get());
swap_->Attach(&buffers_.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
}
if (buffers_.size() == 1) {
curbuf_ = 0;
@@ -143,8 +179,8 @@ Editor::AddBuffer(Buffer &&buf)
{
buffers_.push_back(std::move(buf));
if (swap_) {
buffers_.back().SetSwapRecorder(swap_.get());
swap_->Attach(&buffers_.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
}
if (buffers_.size() == 1) {
curbuf_ = 0;
@@ -162,25 +198,24 @@ Editor::OpenFile(const std::string &path, std::string &err)
Buffer &cur = buffers_[curbuf_];
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
const bool clean = !cur.Dirty();
const auto &rows = cur.Rows();
const bool rows_empty = rows.empty();
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0);
const std::size_t nrows = cur.Nrows();
const bool rows_empty = (nrows == 0);
const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
if (unnamed && clean && (rows_empty || single_empty_line)) {
bool ok = cur.OpenFromFile(path, err);
if (!ok)
return false;
// Ensure swap recorder is attached for this buffer
if (swap_) {
cur.SetSwapRecorder(swap_.get());
swap_->Attach(&cur);
cur.SetSwapRecorder(swap_->RecorderFor(&cur));
swap_->NotifyFilenameChanged(cur);
}
// Setup highlighting using registry (extension + shebang)
cur.EnsureHighlighter();
std::string first = "";
const auto &rows = cur.Rows();
if (!rows.empty())
first = static_cast<std::string>(rows[0]);
if (cur.Nrows() > 0)
first = cur.GetLineString(0);
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
if (!ft.empty()) {
cur.SetFiletype(ft);
@@ -197,6 +232,8 @@ Editor::OpenFile(const std::string &path, std::string &err)
eng->InvalidateFrom(0);
}
}
// Defensive: ensure any active prompt is closed after a successful open
CancelPrompt();
return true;
}
}
@@ -205,20 +242,13 @@ Editor::OpenFile(const std::string &path, std::string &err)
if (!b.OpenFromFile(path, err)) {
return false;
}
if (swap_) {
b.SetSwapRecorder(swap_.get());
// path is known, notify
swap_->Attach(&b);
swap_->NotifyFilenameChanged(b);
}
// NOTE: swap recorder/attach must happen after the buffer is stored in its
// final location (vector) because swap manager keys off Buffer*.
// Initialize syntax highlighting by extension + shebang via registry (v2)
b.EnsureHighlighter();
std::string first = "";
{
const auto &rows = b.Rows();
if (!rows.empty())
first = static_cast<std::string>(rows[0]);
}
if (b.Nrows() > 0)
first = b.GetLineString(0);
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
if (!ft.empty()) {
b.SetFiletype(ft);
@@ -237,11 +267,182 @@ Editor::OpenFile(const std::string &path, std::string &err)
}
// Add as a new buffer and switch to it
std::size_t idx = AddBuffer(std::move(b));
if (swap_) {
swap_->NotifyFilenameChanged(buffers_[idx]);
}
SwitchTo(idx);
// Defensive: ensure any active prompt is closed after a successful open
CancelPrompt();
return true;
}
void
Editor::RequestOpenFile(const std::string &path, const std::size_t line1)
{
PendingOpen p;
p.path = path;
p.line1 = line1;
pending_open_.push_back(std::move(p));
}
bool
Editor::HasPendingOpens() const
{
return !pending_open_.empty();
}
Editor::RecoveryPromptKind
Editor::PendingRecoveryPrompt() const
{
return pending_recovery_prompt_;
}
void
Editor::CancelRecoveryPrompt()
{
pending_recovery_prompt_ = RecoveryPromptKind::None;
pending_recovery_open_ = PendingOpen{};
pending_recovery_swap_path_.clear();
pending_recovery_replay_err_.clear();
}
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_;
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::string rerr;
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
SetStatus("Swap recovery failed: " + rerr);
return false;
}
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) {
if (yes) {
(void) std::remove(swp.c_str());
}
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 (!rerr_s.empty()) {
SetStatus("Opened " + req.path + " (swap unreadable)");
} else {
SetStatus("Opened " + req.path);
}
return true;
}
return false;
}
bool
Editor::ProcessPendingOpens()
{
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;
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) {
Buffer tmp;
std::string oerr;
if (tmp.OpenFromFile(req.path, oerr)) {
const std::string orig = buffer_bytes_via_views(tmp);
std::string rerr;
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
const std::string rec = buffer_bytes_via_views(tmp);
if (rec != orig) {
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
pending_recovery_open_ = req;
pending_recovery_swap_path_ = swp;
StartPrompt(PromptKind::Confirm, "Recover", "");
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)");
return opened_any;
}
} else {
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
pending_recovery_open_ = req;
pending_recovery_swap_path_ = swp;
pending_recovery_replay_err_ = rerr;
StartPrompt(PromptKind::Confirm, "Swap", "");
SetStatus(
"Swap file unreadable for " + req.path +
". Delete it? (y/N, C-g cancel)");
return opened_any;
}
}
}
std::string err;
if (!OpenFile(req.path, err)) {
SetStatus(err);
opened_any = false;
continue;
}
apply_pending_line(*this, req.line1);
SetStatus("Opened " + req.path);
opened_any = true;
// Open at most one per call; frontends can call us again next frame.
break;
}
return opened_any;
}
bool
Editor::SwitchTo(std::size_t index)
{
@@ -280,6 +481,13 @@ Editor::CloseBuffer(std::size_t index)
if (index >= buffers_.size()) {
return false;
}
if (swap_) {
// Always remove swap file when closing a buffer on normal exit.
// Swap files are for crash recovery; on clean close, we don't need them.
// This prevents stale swap files from accumulating (e.g., when used as git editor).
swap_->Detach(&buffers_[index], true);
buffers_[index].SetSwapRecorder(nullptr);
}
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
if (buffers_.empty()) {
curbuf_ = 0;

View File

@@ -1,9 +1,47 @@
/*
* Editor.h - top-level editor state and buffer management
*
* Editor is the top-level coordinator in kte. It manages:
*
* - Buffer collection: Multiple open documents (buffers_), current buffer selection
* - UI state: Dimensions, status messages, prompts, search state
* - Kill ring: Shared clipboard for cut/copy/paste operations across buffers
* - Universal argument: Repeat count mechanism (C-u)
* - Mode flags: Editor modes (normal, k-command, search, prompt, etc.)
* - Swap/crash recovery: SwapManager integration for journaling
* - File operations: Opening files, managing pending opens, recovery prompts
*
* Key responsibilities:
*
* 1. Buffer lifecycle:
* - AddBuffer(): Add new buffers to the collection
* - OpenFile(): Load files into buffers
* - SwitchTo(): Change active buffer
* - CloseBuffer(): Remove buffers with dirty checks
*
* 2. UI coordination:
* - SetDimensions(): Terminal/window size for viewport calculations
* - SetStatus(): Status line messages with timestamps
* - Prompt system: Multi-step prompts for file open, buffer switch, etc.
* - Search state: Active search, query, match position, origin tracking
*
* 3. Shared editor state:
* - Kill ring: Circular buffer of killed text (max 60 entries)
* - Universal argument: C-u digit collection for command repetition
* - Mode tracking: Current input mode (normal, k-command, ESC, prompt)
*
* 4. Integration points:
* - Commands operate on Editor and current Buffer
* - Frontend (Terminal/GUI) queries Editor for rendering
* - SwapManager journals all buffer modifications
*
* Design note: Editor owns the buffer collection but doesn't directly edit content.
* Commands modify buffers through Buffer's API, and Editor coordinates the UI state.
*/
#pragma once
#include <cstddef>
#include <ctime>
#include <deque>
#include <string>
#include <vector>
@@ -497,6 +535,30 @@ public:
bool OpenFile(const std::string &path, std::string &err);
// Request that a file be opened. The request is processed by calling
// ProcessPendingOpens() (typically once per frontend frame).
void RequestOpenFile(const std::string &path, std::size_t line1 = 0);
// If no modal prompt is active, process queued open requests.
// Returns true if a file was opened during this call.
bool ProcessPendingOpens();
[[nodiscard]] bool HasPendingOpens() const;
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
// prompt is active and the user's answer should be routed to ResolveRecoveryPrompt().
enum class RecoveryPromptKind {
None = 0,
RecoverOrDiscard, // y = recover swap, else discard swap and open clean
DeleteCorruptSwap // y = delete corrupt swap, else keep it
};
[[nodiscard]] RecoveryPromptKind PendingRecoveryPrompt() const;
bool ResolveRecoveryPrompt(bool yes);
void CancelRecoveryPrompt();
// Buffer switching/closing
bool SwitchTo(std::size_t index);
@@ -550,6 +612,11 @@ public:
}
private:
struct PendingOpen {
std::string path;
std::size_t line1{0}; // 1-based; 0 = none
};
std::size_t rows_ = 0, cols_ = 0;
int mode_ = 0;
int kill_ = 0; // KILL CHAIN
@@ -593,6 +660,13 @@ private:
std::string prompt_text_;
std::string pending_overwrite_path_;
// Deferred open + swap recovery prompt state
std::deque<PendingOpen> pending_open_;
RecoveryPromptKind pending_recovery_prompt_ = RecoveryPromptKind::None;
PendingOpen pending_recovery_open_{};
std::string pending_recovery_swap_path_;
std::string pending_recovery_replay_err_;
// GUI-only state (safe no-op in terminal builds)
bool file_picker_visible_ = false;
std::string file_picker_dir_;

View File

@@ -12,7 +12,7 @@ public:
virtual ~Frontend() = default;
// Initialize the frontend (create window/terminal, etc.)
virtual bool Init(Editor &ed) = 0;
virtual bool Init(int &argc, char **argv, Editor &ed) = 0;
// Execute one iteration (poll input, dispatch, draw). Set running=false to exit.
virtual void Step(Editor &ed, bool &running) = 0;

View File

@@ -1,204 +0,0 @@
#include <algorithm>
#include <cassert>
#include <cstring>
#include "GapBuffer.h"
GapBuffer::GapBuffer() = default;
GapBuffer::GapBuffer(std::size_t initialCapacity)
: buffer_(nullptr), size_(0), capacity_(0)
{
if (initialCapacity > 0) {
Reserve(initialCapacity);
}
}
GapBuffer::GapBuffer(const GapBuffer &other)
: buffer_(nullptr), size_(0), capacity_(0)
{
if (other.capacity_ > 0) {
Reserve(other.capacity_);
if (other.size_ > 0) {
std::memcpy(buffer_, other.buffer_, other.size_);
size_ = other.size_;
}
setTerminator();
}
}
GapBuffer &
GapBuffer::operator=(const GapBuffer &other)
{
if (this == &other)
return *this;
if (other.capacity_ > capacity_) {
Reserve(other.capacity_);
}
if (other.size_ > 0) {
std::memcpy(buffer_, other.buffer_, other.size_);
}
size_ = other.size_;
setTerminator();
return *this;
}
GapBuffer::GapBuffer(GapBuffer &&other) noexcept
: buffer_(other.buffer_), size_(other.size_), capacity_(other.capacity_)
{
other.buffer_ = nullptr;
other.size_ = 0;
other.capacity_ = 0;
}
GapBuffer &
GapBuffer::operator=(GapBuffer &&other) noexcept
{
if (this == &other)
return *this;
delete[] buffer_;
buffer_ = other.buffer_;
size_ = other.size_;
capacity_ = other.capacity_;
other.buffer_ = nullptr;
other.size_ = 0;
other.capacity_ = 0;
return *this;
}
GapBuffer::~GapBuffer()
{
delete[] buffer_;
}
void
GapBuffer::Reserve(const std::size_t newCapacity)
{
if (newCapacity <= capacity_) [[likely]]
return;
// Allocate space for terminator as well
char *nb = new char[newCapacity + 1];
if (size_ > 0 && buffer_) {
std::memcpy(nb, buffer_, size_);
}
delete[] buffer_;
buffer_ = nb;
capacity_ = newCapacity;
setTerminator();
}
void
GapBuffer::AppendChar(const char c)
{
ensureCapacityFor(1);
buffer_[size_++] = c;
setTerminator();
}
void
GapBuffer::Append(const char *s, const std::size_t len)
{
if (!s || len == 0) [[unlikely]]
return;
ensureCapacityFor(len);
std::memcpy(buffer_ + size_, s, len);
size_ += len;
setTerminator();
}
void
GapBuffer::Append(const GapBuffer &other)
{
if (other.size_ == 0)
return;
Append(other.buffer_, other.size_);
}
void
GapBuffer::PrependChar(char c)
{
ensureCapacityFor(1);
// shift right by 1
if (size_ > 0) [[likely]] {
std::memmove(buffer_ + 1, buffer_, size_);
}
buffer_[0] = c;
++size_;
setTerminator();
}
void
GapBuffer::Prepend(const char *s, std::size_t len)
{
if (!s || len == 0) [[unlikely]]
return;
ensureCapacityFor(len);
if (size_ > 0) [[likely]] {
std::memmove(buffer_ + len, buffer_, size_);
}
std::memcpy(buffer_, s, len);
size_ += len;
setTerminator();
}
void
GapBuffer::Prepend(const GapBuffer &other)
{
if (other.size_ == 0)
return;
Prepend(other.buffer_, other.size_);
}
void
GapBuffer::Clear()
{
size_ = 0;
setTerminator();
}
void
GapBuffer::ensureCapacityFor(std::size_t delta)
{
if (capacity_ - size_ >= delta) [[likely]]
return;
auto required = size_ + delta;
Reserve(growCapacity(capacity_, required));
}
std::size_t
GapBuffer::growCapacity(std::size_t current, std::size_t required)
{
// geometric growth, at least required
std::size_t newCap = current ? current : 8;
while (newCap < required)
newCap = newCap + (newCap >> 1); // 1.5x growth
return newCap;
}
void
GapBuffer::setTerminator() const
{
if (!buffer_) {
return;
}
buffer_[size_] = '\0';
}

View File

@@ -1,76 +0,0 @@
/*
* GapBuffer.h - C++ replacement for abuf append/prepend buffer utilities
*/
#pragma once
#include <cstddef>
class GapBuffer {
public:
GapBuffer();
explicit GapBuffer(std::size_t initialCapacity);
GapBuffer(const GapBuffer &other);
GapBuffer &operator=(const GapBuffer &other);
GapBuffer(GapBuffer &&other) noexcept;
GapBuffer &operator=(GapBuffer &&other) noexcept;
~GapBuffer();
void Reserve(std::size_t newCapacity);
void AppendChar(char c);
void Append(const char *s, std::size_t len);
void Append(const GapBuffer &other);
void PrependChar(char c);
void Prepend(const char *s, std::size_t len);
void Prepend(const GapBuffer &other);
// Content management
void Clear();
// Accessors
char *Data()
{
return buffer_;
}
[[nodiscard]] const char *Data() const
{
return buffer_;
}
[[nodiscard]] std::size_t Size() const
{
return size_;
}
[[nodiscard]] std::size_t Capacity() const
{
return capacity_;
}
private:
void ensureCapacityFor(std::size_t delta);
static std::size_t growCapacity(std::size_t current, std::size_t required);
void setTerminator() const;
char *buffer_ = nullptr;
std::size_t size_ = 0; // number of valid bytes (excluding terminator)
std::size_t capacity_ = 0; // capacity of buffer_ excluding space for terminator
};

View File

@@ -22,7 +22,9 @@ HelpText::Text()
" C-k ' Toggle read-only\n"
" C-k - Unindent region (mark required)\n"
" C-k = Indent region (mark required)\n"
" C-k / Toggle visual line mode\n"
" C-k ; Command prompt (:\\ )\n"
" C-k SPACE Toggle mark\n"
" C-k C-d Kill entire line\n"
" C-k C-q Quit now (no confirm)\n"
" C-k C-x Save and quit\n"
@@ -34,7 +36,9 @@ HelpText::Text()
" C-k f Flush kill ring\n"
" C-k g Jump to line\n"
" C-k h Show this help\n"
" C-k i New empty buffer\n"
" C-k j Jump to mark\n"
" C-k k Center viewport on cursor\n"
" C-k l Reload buffer from disk\n"
" C-k n Previous buffer\n"
" C-k o Change working directory (prompt)\n"

View File

@@ -18,6 +18,7 @@
#include "GUITheme.h"
#include "fonts/Font.h" // embedded default font (DefaultFont)
#include "fonts/FontRegistry.h"
#include "fonts/IosevkaExtended.h"
#include "syntax/HighlighterRegistry.h"
#include "syntax/NullHighlighter.h"
@@ -29,8 +30,10 @@
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
bool
GUIFrontend::Init(Editor &ed)
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{
(void) argc;
(void) argv;
// Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed);
// editor dimensions will be initialized during the first Step() frame
@@ -261,11 +264,11 @@ GUIFrontend::Step(Editor &ed, bool &running)
// 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;
ImGuiIO &io = ImGui::GetIO();
float row_h = ImGui::GetTextLineHeightWithSpacing();
float ch_w = ImGui::CalcTextSize("M").x;
if (row_h <= 0.0f)
row_h = 16.0f;
if (ch_w <= 0.0f)
ch_w = 8.0f;
// Prefer ImGui IO display size; fall back to cached SDL window size
@@ -273,20 +276,20 @@ GUIFrontend::Step(Editor &ed, bool &running)
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_);
// Account for the GUI window padding and the status bar height used in ImGuiRenderer.
// ImGuiRenderer pushes WindowPadding = (6,6) every frame, so use the same constants here
// to avoid mismatches that would cause premature scrolling.
const float pad_x = 6.0f;
const float pad_y = 6.0f;
// 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);
// Use the same logic as ImGuiRenderer for available height and status bar reservation.
float wanted_bar_h = ImGui::GetFrameHeight();
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
// Visible content rows inside the scroll child
auto content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h));
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
// Editor::Rows includes the status line; add 1 back for it.
std::size_t rows = std::max<std::size_t>(1, content_rows + 1);
std::size_t rows = content_rows + 1;
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
// Only update if changed to avoid churn
@@ -295,6 +298,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
}
}
// Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens();
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
for (;;) {
MappedInput mi;
@@ -357,14 +363,32 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
{
const ImGuiIO &io = ImGui::GetIO();
io.Fonts->Clear();
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
ImFontConfig config;
config.MergeMode = false;
// Load Basic Latin + Latin Supplement
io.Fonts->AddFontFromMemoryCompressedTTF(
kte::Fonts::DefaultFontData,
kte::Fonts::DefaultFontSize,
size_px);
if (!font) {
font = io.Fonts->AddFontDefault();
}
(void) font;
size_px,
&config,
io.Fonts->GetGlyphRangesDefault());
// Merge Greek and Mathematical symbols from IosevkaExtended
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;
}

View File

@@ -17,7 +17,7 @@ public:
~GUIFrontend() override = default;
bool Init(Editor &ed) override;
bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override;

View File

@@ -158,8 +158,9 @@ map_key(const SDL_Keycode key,
ascii_key = static_cast<int>(key);
}
bool ctrl2 = (mod & KMOD_CTRL) != 0;
// If user typed a literal 'C' (or '^') as a control qualifier, keep k-prefix active
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
// 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)
@@ -294,25 +295,34 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
bool produced = false;
switch (e.type) {
case SDL_MOUSEWHEEL: {
// Let ImGui handle mouse wheel when it wants to capture the mouse
// (e.g., when hovering the editor child window with scrollbars).
// This enables native vertical and horizontal scrolling behavior in GUI.
if (ImGui::GetIO().WantCaptureMouse)
return false;
// Otherwise, fallback to mapping vertical wheel to editor scroll commands.
int dy = e.wheel.y;
// High-resolution trackpads can deliver fractional wheel deltas. Accumulate
// precise values and emit one scroll step per whole unit.
float dy = 0.0f;
#if SDL_VERSION_ATLEAST(2, 0, 18)
dy = e.wheel.preciseY;
#else
dy = static_cast<float>(e.wheel.y);
#endif
#ifdef SDL_MOUSEWHEEL_FLIPPED
if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED)
dy = -dy;
#endif
if (dy != 0) {
int repeat = dy > 0 ? dy : -dy;
CommandId id = dy > 0 ? CommandId::ScrollUp : CommandId::ScrollDown;
std::lock_guard<std::mutex> lk(mu_);
for (int i = 0; i < repeat; ++i) {
q_.push(MappedInput{true, id, std::string(), 0});
if (dy != 0.0f) {
wheel_accum_y_ += dy;
float abs_accum = wheel_accum_y_ >= 0.0f ? wheel_accum_y_ : -wheel_accum_y_;
int steps = static_cast<int>(abs_accum);
if (steps > 0) {
CommandId id = (wheel_accum_y_ > 0.0f) ? CommandId::ScrollUp : CommandId::ScrollDown;
std::lock_guard<std::mutex> lk(mu_);
for (int i = 0; i < steps; ++i) {
q_.push(MappedInput{true, id, std::string(), 0});
}
// remove the whole steps, keep fractional remainder
wheel_accum_y_ += (wheel_accum_y_ > 0.0f)
? -static_cast<float>(steps)
: static_cast<float>(steps);
return true; // consumed
}
return true; // consumed
}
return false;
}
@@ -429,14 +439,12 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
}
// If editor universal argument is active, consume digit TEXTINPUT
if (ed_ &&ed_
if (ed_ && ed_
->
UArg() != 0
)
{
->
UArg() != 0
) {
const char *txt = e.text.text;
if (txt && *txt) {
unsigned char c0 = static_cast<unsigned char>(txt[0]);
@@ -463,8 +471,8 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
ascii_key = static_cast<int>(c0);
}
if (ascii_key != 0) {
// Qualifier via TEXTINPUT: 'C' or '^'
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
// Qualifier via TEXTINPUT: uppercase 'C' or '^' only
if (ascii_key == 'C' || ascii_key == '^') {
k_ctrl_pending_ = true;
if (ed_)
ed_->SetStatus("C-k C _");

View File

@@ -41,4 +41,9 @@ private:
bool suppress_text_input_once_ = false;
Editor *ed_ = nullptr; // attached editor for editor-owned uarg handling
// Accumulators for high-resolution (trackpad) scrolling. We emit one scroll
// command per whole step and keep the fractional remainder.
float wheel_accum_y_ = 0.0f;
float wheel_accum_x_ = 0.0f; // reserved for future horizontal scrolling
};

View File

@@ -94,8 +94,17 @@ ImGuiRenderer::Draw(Editor &ed)
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
}
// Reserve space for status bar at bottom
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false,
// Reserve space for status bar at bottom.
// We calculate a height that is an exact multiple of the line height
// to avoid partial lines and "scroll past end" jitter.
float total_avail_h = ImGui::GetContentRegionAvail().y;
float wanted_bar_h = ImGui::GetFrameHeight();
float child_h_plan = std::max(0.0f, std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h);
float real_bar_h = total_avail_h - child_h_plan;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
ImGui::BeginChild("scroll", ImVec2(0, child_h_plan), false,
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Get child window position and scroll for click handling
@@ -138,160 +147,87 @@ ImGuiRenderer::Draw(Editor &ed)
}
prev_buf_rowoffs = buf_rowoffs;
prev_buf_coloffs = buf_coloffs;
// Synchronize cursor and scrolling.
// Ensure the cursor is visible, but avoid aggressive centering so that
// the same lines remain visible until the cursor actually goes off-screen.
{
// Compute visible row range using the child window height
float child_h = ImGui::GetWindowHeight();
long first_row = static_cast<long>(scroll_y / row_h);
long vis_rows = static_cast<long>(child_h / row_h);
if (vis_rows < 1)
vis_rows = 1;
long last_row = first_row + vis_rows - 1;
long cyr = static_cast<long>(cy);
if (cyr < first_row) {
// Scroll just enough to bring the cursor line to the top
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);
scroll_y = ImGui::GetScrollY();
first_row = static_cast<long>(scroll_y / row_h);
last_row = first_row + vis_rows - 1;
} else if (cyr > last_row) {
// Scroll just enough to bring the cursor line to the bottom
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);
scroll_y = ImGui::GetScrollY();
first_row = static_cast<long>(scroll_y / row_h);
last_row = first_row + vis_rows - 1;
}
// Horizontal scroll: ensure cursor column is visible
float child_w = ImGui::GetWindowWidth();
long vis_cols = static_cast<long>(child_w / space_w);
if (vis_cols < 1)
vis_cols = 1;
long first_col = static_cast<long>(scroll_x / space_w);
long last_col = first_col + vis_cols - 1;
// Compute cursor's rendered X position (accounting for tabs)
std::size_t cursor_rx = 0;
if (cy < lines.size()) {
std::string cur_line = static_cast<std::string>(lines[cy]);
const std::size_t tabw = 8;
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
if (cur_line[i] == '\t') {
cursor_rx += tabw - (cursor_rx % tabw);
} else {
cursor_rx += 1;
}
}
}
long cxr = static_cast<long>(cursor_rx);
if (cxr < first_col || cxr > last_col) {
float target_x = static_cast<float>(cxr) * space_w;
// Center horizontally if possible
target_x -= (child_w / 2.0f);
if (target_x < 0.f)
target_x = 0.f;
float max_x = ImGui::GetScrollMaxX();
if (max_x >= 0.f && target_x > max_x)
target_x = max_x;
ImGui::SetScrollX(target_x);
scroll_x = ImGui::GetScrollX();
}
// Phase 3: prefetch visible viewport highlights and warm around in background
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
int fr = static_cast<int>(std::max(0L, first_row));
int rc = static_cast<int>(std::max(1L, vis_rows));
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
}
}
// Cache current horizontal offset in rendered columns for click handling
const std::size_t coloffs_now = buf->Coloffs();
// Handle mouse click before rendering to avoid dependent on drawn items
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
// 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;
static bool mouse_selecting = false;
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
ImVec2 mp = ImGui::GetIO().MousePos;
// Compute content-relative position accounting for scroll
// mp.y - child_window_pos.y gives us pixels from top of child window
// Adding scroll_y gives us pixels from top of content (buffer row 0)
// Convert mouse pos to buffer row
float content_y = (mp.y - child_window_pos.y) + scroll_y;
long by_l = static_cast<long>(content_y / row_h);
if (by_l < 0)
by_l = 0;
// Convert to buffer row
std::size_t by = static_cast<std::size_t>(by_l);
if (by >= lines.size()) {
if (!lines.empty())
by = lines.size() - 1;
else
by = 0;
}
if (by >= lines.size())
by = lines.empty() ? 0 : (lines.size() - 1);
// Compute click X position relative to left edge of child window (in pixels)
// This gives us the visual offset from the start of displayed content
// Convert mouse pos to rendered x
float visual_x = mp.x - child_window_pos.x;
if (visual_x < 0.0f)
visual_x = 0.0f;
// Convert visual pixel offset to rendered column, then add coloffs_now
// to get the absolute rendered column in the buffer
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
// Empty buffer guard: if there are no lines yet, just move to 0:0
if (lines.empty()) {
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
} else {
// Convert rendered column (clicked_rx) to source column accounting for tabs
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
// Iterate through source columns, computing rendered position, to find closest match
std::size_t rx = 0; // rendered column position
std::size_t best_col = 0;
float best_dist = std::numeric_limits<float>::infinity();
float clicked_rx_f = static_cast<float>(clicked_rx);
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
// Check current position
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
if (dist < best_dist) {
best_dist = dist;
best_col = i;
}
// Advance to next position if not at end
if (i < line_clicked.size()) {
if (line_clicked[i] == '\t') {
rx += (tabw - (rx % tabw));
} else {
rx += 1;
}
}
// Convert rendered column to source column
if (lines.empty())
return {0, 0};
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
std::size_t rx = 0;
std::size_t best_col = 0;
float best_dist = std::numeric_limits<float>::infinity();
float clicked_rx_f = static_cast<float>(clicked_rx);
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
if (dist < best_dist) {
best_dist = dist;
best_col = i;
}
if (i < line_clicked.size()) {
rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1;
}
// 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));
}
return {by, best_col};
};
// Mouse-driven selection: set mark on press, update cursor on 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));
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();
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;
}
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Capture the screen position before drawing the line
@@ -370,6 +306,71 @@ ImGuiRenderer::Draw(Editor &ed)
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);
if (rx_end > coloffs_now) {
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);
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;
}
if (rx_end > coloffs_now) {
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);
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
}
// Emit entire line to an expanded buffer (tabs -> spaces)
for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src];
@@ -489,23 +490,98 @@ ImGuiRenderer::Draw(Editor &ed)
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
}
// 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 column is visible
long vis_cols = static_cast<long>(std::round(child_w_actual / space_w));
if (vis_cols < 1)
vis_cols = 1;
long first_col = static_cast<long>(scroll_x_now / space_w);
long last_col = first_col + vis_cols - 1;
std::size_t cursor_rx = 0;
if (cy < lines.size()) {
std::string cur_line = static_cast<std::string>(lines[cy]);
const std::size_t tabw = 8;
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
if (cur_line[i] == '\t') {
cursor_rx += tabw - (cursor_rx % tabw);
} else {
cursor_rx += 1;
}
}
}
long cxr = static_cast<long>(cursor_rx);
if (cxr < first_col || cxr > last_col) {
float target_x = static_cast<float>(cxr) * space_w;
target_x -= (child_w_actual / 2.0f);
if (target_x < 0.f)
target_x = 0.f;
float max_x = ImGui::GetScrollMaxX();
if (max_x >= 0.f && target_x > max_x)
target_x = max_x;
ImGui::SetScrollX(target_x);
}
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
int fr = static_cast<int>(std::max(0L, first_row));
int rc = static_cast<int>(std::max(1L, vis_rows));
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
}
}
ImGui::EndChild();
ImGui::PopStyleVar(2); // WindowPadding, ItemSpacing
// Status bar spanning full width
ImGui::Separator();
// Compute full content width and draw a filled background rectangle
// Status bar area starting right after the scroll child
ImVec2 win_pos = ImGui::GetWindowPos();
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
float x0 = win_pos.x + cr_min.x;
float x1 = win_pos.x + cr_max.x;
ImVec2 cursor = ImGui::GetCursorScreenPos();
float bar_h = ImGui::GetFrameHeight();
ImVec2 p0(x0, cursor.y);
ImVec2 p1(x1, cursor.y + bar_h);
ImVec2 win_sz = ImGui::GetWindowSize();
float x0 = win_pos.x;
float x1 = win_pos.x + win_sz.x;
float y0 = ImGui::GetCursorScreenPos().y;
float bar_h = real_bar_h;
ImVec2 p0(x0, y0);
ImVec2 p1(x1, y0 + bar_h);
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
// If a prompt is active, replace the entire status bar with the prompt text
if (ed.PromptActive()) {
std::string label = ed.PromptLabel();
@@ -560,7 +636,7 @@ ImGuiRenderer::Draw(Editor &ed)
(size_t) std::max<size_t>(
1, (size_t) (tail.size() / 4)))
: 1;
start += skip;
start += skip;
std::string candidate = tail.substr(start);
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
if (cand_sz.x <= avail_px) {
@@ -591,11 +667,9 @@ ImGuiRenderer::Draw(Editor &ed)
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - msg_sz.y) * 0.5f));
ImGui::TextUnformatted(final_msg.c_str());
ImGui::PopClipRect();
// Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
} else {
// Build left text
std::string left;
@@ -618,11 +692,11 @@ ImGuiRenderer::Draw(Editor &ed)
std::size_t total = ed.BufferCount();
if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/";
left += std::to_string(static_cast<unsigned long long>(total));
left += "] ";
left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/";
left += std::to_string(static_cast<unsigned long long>(total));
left += "] ";
}
}
left += fname;
@@ -631,9 +705,9 @@ ImGuiRenderer::Draw(Editor &ed)
// 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";
left += " ";
left += std::to_string(lcount);
left += "L";
}
// Build right text (cursor/mark)
@@ -671,20 +745,21 @@ ImGuiRenderer::Draw(Editor &ed)
float max_left = std::max(0.0f, right_x - left_x - pad);
if (max_left < left_sz.x && max_left > 10.0f) {
// Render a clipped left using a child region
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true);
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
ImGui::PushClipRect(ImVec2(left_x, y0), ImVec2(right_x - pad, y0 + bar_h),
true);
ImGui::TextUnformatted(left.c_str());
ImGui::PopClipRect();
}
} else {
// Draw left normally
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
ImGui::TextUnformatted(left.c_str());
}
// Draw right
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
p0.y + (bar_h - right_sz.y) * 0.5f));
y0 + (bar_h - right_sz.y) * 0.5f));
ImGui::TextUnformatted(right.c_str());
// Draw middle message centered in remaining space
@@ -696,14 +771,12 @@ ImGuiRenderer::Draw(Editor &ed)
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
// Clip to middle region
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true);
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
ImGui::PushClipRect(ImVec2(mid_left, y0), ImVec2(mid_right, y0 + bar_h), true);
ImGui::SetCursorScreenPos(ImVec2(msg_x, y0 + (bar_h - msg_sz.y) * 0.5f));
ImGui::TextUnformatted(msg.c_str());
ImGui::PopClipRect();
}
}
// Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
}
}
@@ -839,12 +912,8 @@ ImGuiRenderer::Draw(Editor &ed)
ed.SetFilePickerDir(e.path.string());
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
// Open file on single click
std::string err;
if (!ed.OpenFile(e.path.string(), err)) {
ed.SetStatus(std::string("open: ") + err);
} else {
ed.SetStatus(std::string("Opened: ") + e.name);
}
ed.RequestOpenFile(e.path.string());
(void) ed.ProcessPendingOpens();
ed.SetFilePickerVisible(false);
}
}

View File

@@ -17,6 +17,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'd':
out = CommandId::KillLine;
return true;
case 's':
out = CommandId::Save;
return true;
case 'q':
out = CommandId::QuitNow;
return true;
@@ -42,6 +45,12 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'a':
out = CommandId::MarkAllAndJumpEnd;
return true;
case ' ': // C-k SPACE
out = CommandId::ToggleMark;
return true;
case 'i':
out = CommandId::BufferNew; // C-k i new empty buffer
return true;
case 'k':
out = CommandId::CenterOnCursor; // C-k k center current line
return true;
@@ -111,6 +120,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case '=':
out = CommandId::IndentRegion;
return true;
case '/':
out = CommandId::VisualLineModeToggle;
return true;
case ';':
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
return true;

View File

@@ -1,5 +1,7 @@
#include <algorithm>
#include <utility>
#include <limits>
#include <ostream>
#include "PieceTable.h"
@@ -14,13 +16,32 @@ PieceTable::PieceTable(const std::size_t initialCapacity)
}
PieceTable::PieceTable(const std::size_t initialCapacity,
const std::size_t piece_limit,
const std::size_t small_piece_threshold,
const std::size_t max_consolidation_bytes)
{
add_.reserve(initialCapacity);
materialized_.reserve(initialCapacity);
piece_limit_ = piece_limit;
small_piece_threshold_ = small_piece_threshold;
max_consolidation_bytes_ = max_consolidation_bytes;
}
PieceTable::PieceTable(const PieceTable &other)
: original_(other.original_),
add_(other.add_),
pieces_(other.pieces_),
materialized_(other.materialized_),
dirty_(other.dirty_),
total_size_(other.total_size_) {}
total_size_(other.total_size_)
{
version_ = other.version_;
// caches are per-instance, mark invalid
range_cache_ = {};
find_cache_ = {};
}
PieceTable &
@@ -34,6 +55,9 @@ PieceTable::operator=(const PieceTable &other)
materialized_ = other.materialized_;
dirty_ = other.dirty_;
total_size_ = other.total_size_;
version_ = other.version_;
range_cache_ = {};
find_cache_ = {};
return *this;
}
@@ -48,6 +72,9 @@ PieceTable::PieceTable(PieceTable &&other) noexcept
{
other.dirty_ = true;
other.total_size_ = 0;
version_ = other.version_;
range_cache_ = {};
find_cache_ = {};
}
@@ -64,6 +91,9 @@ PieceTable::operator=(PieceTable &&other) noexcept
total_size_ = other.total_size_;
other.dirty_ = true;
other.total_size_ = 0;
version_ = other.version_;
range_cache_ = {};
find_cache_ = {};
return *this;
}
@@ -79,6 +109,21 @@ PieceTable::Reserve(const std::size_t newCapacity)
}
// Setter to allow tuning consolidation heuristics
void
PieceTable::SetConsolidationParams(const std::size_t piece_limit,
const std::size_t small_piece_threshold,
const std::size_t max_consolidation_bytes)
{
piece_limit_ = piece_limit;
small_piece_threshold_ = small_piece_threshold;
max_consolidation_bytes_ = max_consolidation_bytes;
}
// (removed helper) — we'll invalidate caches inline inside mutating methods
void
PieceTable::AppendChar(char c)
{
@@ -151,6 +196,11 @@ PieceTable::Clear()
materialized_.clear();
total_size_ = 0;
dirty_ = true;
line_index_.clear();
line_index_dirty_ = true;
version_++;
range_cache_ = {};
find_cache_ = {};
}
@@ -168,9 +218,12 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
std::size_t expectStart = last.start + last.len;
if (expectStart == start) {
last.len += len;
last.len += len;
total_size_ += len;
dirty_ = true;
dirty_ = true;
version_++;
range_cache_ = {};
find_cache_ = {};
return;
}
}
@@ -178,7 +231,11 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
pieces_.push_back(Piece{src, start, len});
total_size_ += len;
dirty_ = true;
dirty_ = true;
InvalidateLineIndex();
version_++;
range_cache_ = {};
find_cache_ = {};
}
@@ -194,21 +251,29 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
Piece &first = pieces_.front();
if (first.src == src && start + len == first.start) {
first.start = start;
first.len += len;
first.len += len;
total_size_ += len;
dirty_ = true;
dirty_ = true;
version_++;
range_cache_ = {};
find_cache_ = {};
return;
}
}
pieces_.insert(pieces_.begin(), Piece{src, start, len});
total_size_ += len;
dirty_ = true;
dirty_ = true;
InvalidateLineIndex();
version_++;
range_cache_ = {};
find_cache_ = {};
}
void
PieceTable::materialize() const
{
std::lock_guard<std::mutex> lock(mutex_);
if (!dirty_) {
return;
}
@@ -225,3 +290,511 @@ PieceTable::materialize() const
// Ensure there is a null terminator present via std::string invariants
dirty_ = false;
}
// ===== New Phase 1 implementation =====
std::pair<std::size_t, std::size_t>
PieceTable::locate(const std::size_t byte_offset) const
{
if (byte_offset >= total_size_) {
return {pieces_.size(), 0};
}
std::size_t off = byte_offset;
for (std::size_t i = 0; i < pieces_.size(); ++i) {
const auto &p = pieces_[i];
if (off < p.len) {
return {i, off};
}
off -= p.len;
}
// Should not reach here unless inconsistency; return end
return {pieces_.size(), 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
{
std::lock_guard<std::mutex> lock(mutex_);
line_index_dirty_ = true;
}
void
PieceTable::RebuildLineIndex() const
{
std::lock_guard<std::mutex> lock(mutex_);
if (!line_index_dirty_) {
return;
}
line_index_.clear();
line_index_.push_back(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);
for (std::size_t j = 0; j < pc.len; ++j) {
if (base[j] == '\n') {
// next line starts after the newline
line_index_.push_back(pos + j + 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_;
}
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;
InvalidateLineIndex();
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;
InvalidateLineIndex();
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;
InvalidateLineIndex();
// 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;
}
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;
InvalidateLineIndex();
if (idx < pieces_.size())
coalesceNeighbors(idx);
if (idx > 0)
coalesceNeighbors(idx - 1);
maybeConsolidate();
version_++;
range_cache_ = {};
find_cache_ = {};
}
// ===== 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
dirty_ = true;
InvalidateLineIndex();
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 = line_index_[line_num];
std::size_t end = (line_num + 1 < line_index_.size()) ? line_index_[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();
// Trim trailing '\n'
if (end > start) {
// To check last char, we can get it via GetRange of len 1 at end-1 without materializing whole
std::string last = GetRange(end - 1, 1);
if (!last.empty() && last[0] == '\n') {
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};
auto it = std::upper_bound(line_index_.begin(), line_index_.end(), byte_offset);
std::size_t row = (it == line_index_.begin()) ? 0 : static_cast<std::size_t>((it - line_index_.begin()) - 1);
std::size_t col = byte_offset - line_index_[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 = line_index_[row];
std::size_t end = (row + 1 < line_index_.size()) ? line_index_[row + 1] : total_size_;
// Clamp col to line length excluding trailing newline
if (end > start) {
std::string last = GetRange(end - 1, 1);
if (!last.empty() && last[0] == '\n') {
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
{
std::lock_guard<std::mutex> lock(mutex_);
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_) {
std::lock_guard<std::mutex> lock(mutex_);
// 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
{
std::lock_guard<std::mutex> lock(mutex_);
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();
{
std::lock_guard<std::mutex> lock(mutex_);
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;
{
std::lock_guard<std::mutex> lock(mutex_);
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));
}
}

View File

@@ -1,10 +1,48 @@
/*
* 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_: Initial file content (currently unused, reserved for future)
* - 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
* - 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)
* - Thread-safe for concurrent reads (mutex protects caches and lazy rebuilds)
* - Version tracking invalidates caches on mutations
*/
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <ostream>
#include <vector>
#include <limits>
#include <mutex>
class PieceTable {
@@ -13,6 +51,12 @@ public:
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 &operator=(const PieceTable &other);
@@ -68,6 +112,38 @@ public:
return materialized_.capacity();
}
// ===== 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;
// Heuristic configuration
void SetConsolidationParams(std::size_t piece_limit,
std::size_t small_piece_threshold,
std::size_t max_consolidation_bytes);
private:
enum class Source : unsigned char { Original, Add };
@@ -83,12 +159,63 @@ private:
void materialize() const;
// Helper: locate piece index and inner offset for a global byte offset
[[nodiscard]] std::pair<std::size_t, std::size_t> locate(std::size_t byte_offset) const;
// Helper: try to coalesce neighboring pieces around index
void coalesceNeighbors(std::size_t index);
// Consolidation helpers and heuristics
void maybeConsolidate();
void consolidateRange(std::size_t start_idx, std::size_t end_idx);
void appendPieceDataTo(std::string &out, const Piece &p) const;
// Line index support (rebuilt lazily on demand)
void InvalidateLineIndex() const;
void RebuildLineIndex() const;
// Underlying storages
std::string original_; // unused for builder use-case, but kept for API symmetry
std::string add_;
std::vector<Piece> pieces_;
mutable std::string materialized_;
mutable bool dirty_ = true;
std::size_t total_size_ = 0;
mutable bool dirty_ = true;
// Monotonic content version. Increment on any mutation that affects content layout
mutable std::uint64_t version_ = 0;
std::size_t total_size_ = 0;
// 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;
// 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();
};
mutable RangeCache range_cache_;
mutable FindCache find_cache_;
mutable std::mutex mutex_;
};

View File

@@ -123,8 +123,7 @@ protected:
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
const Buffer *buf = ed_->CurrentBuffer();
if (buf) {
const auto &lines = buf->Rows();
const std::size_t nrows = lines.size();
const std::size_t nrows = buf->Nrows();
const std::size_t rowoffs = buf->Rowoffs();
const std::size_t coloffs = buf->Coloffs();
const std::size_t cy = buf->Cury();
@@ -144,9 +143,10 @@ protected:
// Iterate visible lines
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
const auto &line = static_cast<const std::string &>(lines[i]);
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
const int baseline = y + fm.ascent();
// Get line as string for regex/iterator usage and general string ops.
const std::string line = buf->GetLineString(i);
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
const int baseline = y + fm.ascent();
// Helper: convert src col -> rx with tab expansion
auto src_to_rx_line = [&](std::size_t src_col) -> std::size_t {
@@ -451,11 +451,11 @@ protected:
std::size_t total = ed_->BufferCount();
if (total > 0) {
std::size_t idx1 = ed_->CurrentBufferIndex() + 1; // 1-based
left += QStringLiteral(" [");
left += QString::number(static_cast<qlonglong>(idx1));
left += QStringLiteral("/");
left += QString::number(static_cast<qlonglong>(total));
left += QStringLiteral("] ");
left += QStringLiteral(" [");
left += QString::number(static_cast<qlonglong>(idx1));
left += QStringLiteral("/");
left += QString::number(static_cast<qlonglong>(total));
left += QStringLiteral("] ");
} else {
left += QStringLiteral(" ");
}
@@ -475,9 +475,9 @@ protected:
// total lines suffix " <n>L"
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += QStringLiteral(" ");
left += QString::number(static_cast<qlonglong>(lcount));
left += QStringLiteral("L");
left += QStringLiteral(" ");
left += QString::number(static_cast<qlonglong>(lcount));
left += QStringLiteral("L");
}
// Build right segment: cursor and mark
@@ -600,12 +600,12 @@ protected:
int d_cols = 0;
if (std::fabs(v_scroll_accum_) >= 1.0 && (!horiz_mode || std::fabs(v_scroll_accum_) > std::fabs(
h_scroll_accum_))) {
d_rows = static_cast<int>(v_scroll_accum_);
d_rows = static_cast<int>(v_scroll_accum_);
v_scroll_accum_ -= d_rows;
}
if (std::fabs(h_scroll_accum_) >= 1.0 && (horiz_mode || std::fabs(h_scroll_accum_) >= std::fabs(
v_scroll_accum_))) {
d_cols = static_cast<int>(h_scroll_accum_);
d_cols = static_cast<int>(h_scroll_accum_);
h_scroll_accum_ -= d_cols;
}
@@ -656,11 +656,9 @@ private:
} // namespace
bool
GUIFrontend::Init(Editor &ed)
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{
int argc = 0;
char **argv = nullptr;
app_ = new QApplication(argc, argv);
app_ = new QApplication(argc, argv);
window_ = new MainWindow(input_);
window_->show();
@@ -775,6 +773,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
if (app_)
app_->processEvents();
// Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens();
// Drain input queue
for (;;) {
MappedInput mi;
@@ -801,14 +802,8 @@ GUIFrontend::Step(Editor &ed, bool &running)
const QStringList files = dlg.selectedFiles();
if (!files.isEmpty()) {
const QString fp = files.front();
std::string err;
if (ed.OpenFile(fp.toStdString(), err)) {
ed.SetStatus(std::string("Opened: ") + fp.toStdString());
} else if (!err.empty()) {
ed.SetStatus(std::string("Open failed: ") + err);
} else {
ed.SetStatus("Open failed");
}
ed.RequestOpenFile(fp.toStdString());
(void) ed.ProcessPendingOpens();
// Update picker dir for next time
QFileInfo info(fp);
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());

View File

@@ -18,7 +18,7 @@ public:
~GUIFrontend() override = default;
bool Init(Editor &ed) override;
bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override;

View File

@@ -283,12 +283,11 @@ QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
const bool ctrl_like = (mods & Qt::ControlModifier);
// 1) Universal argument digits (when active), consume digits without enqueuing commands
if (ed_ &&ed_
if (ed_ && ed_
->
UArg() != 0
)
{
->
UArg() != 0
) {
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
int d = e.key() - Qt::Key_0;
@@ -379,10 +378,9 @@ QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
#if defined(__APPLE__)
if (esc_meta_ || (mods & Qt::AltModifier)) {
#else
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
#endif
int ascii_key = 0;
if (e.key() == Qt::Key_Backspace) {

View File

@@ -32,27 +32,27 @@ Project Goals
Keybindings
-----------
kte maintains kes command model while internals evolve. Highlights (subject to refinement):
kte maintains kes command model while internals evolve. Highlights (
subject to refinement):
- Kcommand prefix: `C-k` enters kcommand mode; exit with `ESC` or
`C-g`.
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
`C-k q` (quit with confirm), `C-k C-q` (quit immediately).
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-k
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u`
(universal argument).
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
region), `C-y` (yank), `C-u` (universal argument).
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
(close), `C-k C-r` (reload).
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g`
(goto line).
(close), `C-k l` (reload).
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
See `ke.md` for the canonical ke reference retained for now.
Build and Run
-------------
Prerequisites: C++17 compiler, CMake, and ncurses development headers/libs.
Prerequisites: C++20 compiler, CMake, and ncurses development
headers/libs.
Dependencies by platform
------------------------
@@ -62,30 +62,38 @@ Dependencies by platform
- `brew install ncurses`
- Optional GUI (enable with `-DBUILD_GUI=ON`):
- `brew install sdl2 freetype`
- OpenGL is provided by the system framework on macOS; no package needed.
- OpenGL is provided by the system framework on macOS; no
package needed.
- Debian/Ubuntu
- Terminal (default):
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
- Optional GUI (enable with `-DBUILD_GUI=ON`):
- `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
- The `mesa-common-dev` package provides OpenGL headers/libs (`libGL`).
-
`sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
- The `mesa-common-dev` package provides OpenGL headers/libs (
`libGL`).
- NixOS/Nix
- Terminal (default):
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
- Optional GUI (enable with `-DBUILD_GUI=ON`):
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
- With flakes/devshell (example `flake.nix` inputs not provided): include
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell.
- Ad-hoc shell:
`nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
- With flakes/devshell (example `flake.nix` inputs not provided):
include
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your
devShell.
Notes
-----
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable it by
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable
it by
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
installed for your platform.
- If you previously configured with GUI ON and want to disable it, reconfigure
- If you previously configured with GUI ON and want to disable it,
reconfigure
the build directory with `-DBUILD_GUI=OFF`.
Example build:
@@ -113,7 +121,8 @@ built as `kge`) or request the GUI from `kte`:
GUI build example
-----------------
To build with the optional GUI (after installing the GUI dependencies listed above):
To build with the optional GUI (after installing the GUI dependencies
listed above):
```
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON

2502
REWRITE.md Normal file

File diff suppressed because it is too large Load Diff

1117
Swap.cc

File diff suppressed because it is too large Load Diff

145
Swap.h
View File

@@ -7,11 +7,14 @@
#include <string_view>
#include <vector>
#include <unordered_map>
#include <memory>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <atomic>
#include "SwapRecorder.h"
class Buffer;
namespace kte {
@@ -29,50 +32,88 @@ 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
};
// Lightweight interface that Buffer can call without depending on full manager impl
class SwapRecorder {
public:
virtual ~SwapRecorder() = default;
// 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
virtual void RecordInsert(Buffer &buf, int row, int col, std::string_view text) = 0;
virtual void RecordDelete(Buffer &buf, int row, int col, std::size_t len) = 0;
virtual void RecordSplit(Buffer &buf, int row, int col) = 0;
virtual void RecordJoin(Buffer &buf, int row) = 0;
virtual void NotifyFilenameChanged(Buffer &buf) = 0;
virtual void SetSuspended(Buffer &buf, bool on) = 0;
// 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 SwapRecorder {
class SwapManager final {
public:
SwapManager();
~SwapManager() override;
~SwapManager();
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
void Attach(Buffer *buf);
// Detach and close journal.
void Detach(Buffer *buf);
// 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);
// SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs)
void NotifyFilenameChanged(Buffer &buf) override;
// 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);
// SwapRecorder
void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override;
// Best-effort pruning of old swap files under the swap directory.
// Never touches non-`.swp` files.
void PruneSwapDir();
void RecordDelete(Buffer &buf, int row, int col, std::size_t len) override;
// 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);
void RecordSplit(Buffer &buf, int row, int col) override;
// 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);
void RecordJoin(Buffer &buf, int row) override;
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);
// 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`.
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
// 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);
// Test-only hook to keep swap path logic centralized.
// (Avoid duplicating naming rules in unit tests.)
#ifdef KTE_TESTS
static std::string ComputeSwapPathForTests(const Buffer &buf)
{
return ComputeSidecarPath(buf);
}
#endif
// RAII guard to suspend recording for internal operations
class SuspendGuard {
@@ -88,17 +129,44 @@ public:
};
// Per-buffer toggle
void SetSuspended(Buffer &buf, bool on) override;
void SetSuspended(Buffer &buf, bool on);
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 RecordSplit(Buffer &buf, int row, int col);
void RecordJoin(Buffer &buf, int row);
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
struct JournalCtx {
std::string path;
void *file{nullptr}; // FILE*
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};
};
struct Pending {
@@ -106,26 +174,35 @@ private:
SwapRecType type{SwapRecType::INS};
std::vector<std::uint8_t> payload; // framed payload only
bool urgent_flush{false};
std::uint64_t seq{0};
};
// 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 bool write_header(JournalCtx &ctx);
static std::string SwapDirRoot();
static bool open_ctx(JournalCtx &ctx);
static bool write_header(int fd);
static bool open_ctx(JournalCtx &ctx, const std::string &path);
static void close_ctx(JournalCtx &ctx);
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record);
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
static void put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v);
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
static void put_u24(std::uint8_t dst[3], 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);
@@ -136,9 +213,13 @@ private:
// State
SwapConfig cfg_{};
std::unordered_map<Buffer *, JournalCtx> journals_;
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
std::mutex 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_;
};

19
SwapRecorder.h Normal file
View File

@@ -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

View File

@@ -8,8 +8,10 @@
bool
TerminalFrontend::Init(Editor &ed)
TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
{
(void) argc;
(void) argv;
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
{
struct termios tio{};
@@ -42,13 +44,15 @@ TerminalFrontend::Init(Editor &ed)
meta(stdscr, TRUE);
// Make ESC key sequences resolve quickly so ESC+<key> works as meta
#ifdef set_escdelay
set_escdelay(50);
set_escdelay(TerminalFrontend::kEscDelayMs);
#endif
nodelay(stdscr, TRUE);
// Make getch() block briefly instead of busy-looping; reduces CPU when idle
// Equivalent to nodelay(FALSE) with a small timeout.
timeout(16); // ~16ms (about 60Hz)
curs_set(1);
// Enable mouse support if available
mouseinterval(0);
mousemask(ALL_MOUSE_EVENTS, nullptr);
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, nullptr);
int r = 0, c = 0;
getmaxyx(stdscr, r, c);
@@ -57,6 +61,21 @@ TerminalFrontend::Init(Editor &ed)
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;
}
@@ -75,14 +94,14 @@ TerminalFrontend::Step(Editor &ed, bool &running)
}
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
// Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens();
MappedInput mi;
if (input_.Poll(mi)) {
if (mi.hasCommand) {
Execute(ed, mi.id, mi.arg, mi.count);
}
} else {
// Avoid busy loop
usleep(1000);
}
if (ed.QuitRequested()) {
@@ -101,5 +120,10 @@ TerminalFrontend::Shutdown()
(void) tcsetattr(STDIN_FILENO, TCSANOW, &orig_tio_);
have_orig_tio_ = false;
}
// Restore previous SIGINT handler
if (have_old_sigint_) {
(void) sigaction(SIGINT, &old_sigint_, nullptr);
have_old_sigint_ = false;
}
endwin();
}

View File

@@ -3,6 +3,7 @@
*/
#pragma once
#include <termios.h>
#include <signal.h>
#include "Frontend.h"
#include "TerminalInputHandler.h"
@@ -15,7 +16,12 @@ public:
~TerminalFrontend() override = default;
bool Init(Editor &ed) override;
// Configurable ESC key delay (ms) for ncurses' set_escdelay().
// Controls how long ncurses waits to distinguish ESC vs. meta sequences.
// Adjust if your terminal needs a different threshold.
static constexpr int kEscDelayMs = 50;
bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override;
@@ -29,4 +35,7 @@ private:
// Saved terminal attributes to restore on shutdown
bool have_orig_tio_ = false;
struct termios orig_tio_{};
// Saved SIGINT handler to restore on shutdown
bool have_old_sigint_ = false;
struct sigaction old_sigint_{};
};

View File

@@ -3,6 +3,7 @@
#include "TerminalInputHandler.h"
#include "KKeymap.h"
#include "Command.h"
#include "Editor.h"
namespace {
@@ -23,95 +24,123 @@ map_key_to_command(const int ch,
bool &k_prefix,
bool &esc_meta,
bool &k_ctrl_pending,
bool &mouse_selecting,
Editor *ed,
MappedInput &out)
{
// Handle special keys from ncurses
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
switch (ch) {
case KEY_MOUSE: {
k_prefix = false;
k_ctrl_pending = false;
MEVENT ev{};
if (getmouse(&ev) == OK) {
// Mouse wheel → scroll viewport without moving cursor
case KEY_ENTER:
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Newline, "", 0};
return true;
case KEY_MOUSE: {
k_prefix = false;
k_ctrl_pending = false;
MEVENT ev{};
if (getmouse(&ev) == OK) {
// Mouse wheel → scroll viewport without moving cursor
#ifdef BUTTON4_PRESSED
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
out = {true, CommandId::ScrollUp, "", 0};
return true;
}
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
out = {true, CommandId::ScrollUp, "", 0};
return true;
}
#endif
#ifdef BUTTON5_PRESSED
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
out = {true, CommandId::ScrollDown, "", 0};
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
out = {true, CommandId::ScrollDown, "", 0};
return true;
}
#endif
// React to left button click/press
if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED |
REPORT_MOUSE_POSITION))) {
char buf[64];
// Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
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));
if (Buffer *b = ed->CurrentBuffer()) {
b->SetMark(b->Curx(), b->Cury());
}
out.hasCommand = false;
return true;
}
#endif
// React to left button click/press
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
char buf[64];
// Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
if (mouse_selecting && moved) {
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
out.hasCommand = false;
return true;
}
case KEY_LEFT:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveLeft, "", 0};
return true;
case KEY_RIGHT:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveRight, "", 0};
return true;
case KEY_UP:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveUp, "", 0};
return true;
case KEY_DOWN:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveDown, "", 0};
return true;
case KEY_HOME:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveHome, "", 0};
return true;
case KEY_END:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveEnd, "", 0};
return true;
case KEY_PPAGE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageUp, "", 0};
return true;
case KEY_NPAGE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageDown, "", 0};
return true;
case KEY_DC:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::DeleteChar, "", 0};
return true;
case KEY_RESIZE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Refresh, "", 0};
return true;
default:
break;
// No actionable mouse event
out.hasCommand = false;
return true;
}
case KEY_LEFT:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveLeft, "", 0};
return true;
case KEY_RIGHT:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveRight, "", 0};
return true;
case KEY_UP:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveUp, "", 0};
return true;
case KEY_DOWN:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveDown, "", 0};
return true;
case KEY_HOME:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveHome, "", 0};
return true;
case KEY_END:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveEnd, "", 0};
return true;
case KEY_PPAGE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageUp, "", 0};
return true;
case KEY_NPAGE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageDown, "", 0};
return true;
case KEY_DC:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::DeleteChar, "", 0};
return true;
case KEY_RESIZE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Refresh, "", 0};
return true;
default:
break;
}
// ESC as cancel of prefix; many terminals send meta sequences as ESC+...
@@ -172,8 +201,9 @@ map_key_to_command(const int ch,
ctrl = true;
ascii_key = 'a' + (ch - 1);
}
// If user typed literal 'C'/'c' or '^' as a qualifier, keep k-prefix and set pending
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
// 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 _");
@@ -285,6 +315,7 @@ TerminalInputHandler::decode_(MappedInput &out)
ch,
k_prefix_, esc_meta_,
k_ctrl_pending_,
mouse_selecting_,
ed_,
out);
if (!consumed)

View File

@@ -30,5 +30,8 @@ private:
// Simple meta (ESC) state for ESC sequences like ESC b/f
bool esc_meta_ = false;
// Mouse drag selection state
bool mouse_selecting_ = false;
Editor *ed_ = nullptr; // attached editor for uarg handling
};

View File

@@ -1,3 +1,6 @@
#include <clocale>
#define _XOPEN_SOURCE_EXTENDED 1
#include <cwchar>
#include <algorithm>
#include <cstdio>
#include <filesystem>
@@ -104,13 +107,82 @@ TerminalRenderer::Draw(Editor &ed)
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
bool hl_on = false;
bool cur_on = false;
int written = 0;
// Mark selection (mark -> cursor), in source coordinates
bool sel_active = false;
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
if (buf->MarkSet()) {
sel_sy = buf->MarkCury();
sel_sx = buf->MarkCurx();
sel_ey = buf->Cury();
sel_ex = buf->Curx();
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
std::swap(sel_sy, sel_ey);
std::swap(sel_sx, sel_ex);
}
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
}
// Visual-line selection: full-line selection range
const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool {
if (!sel_active)
return false;
if (y < sel_sy || y > sel_ey)
return false;
if (sel_sy == sel_ey)
return sx >= sel_sx && sx < sel_ex;
if (y == sel_sy)
return sx >= sel_sx;
if (y == sel_ey)
return sx < sel_ex;
return true;
};
int written = 0;
if (li < lines.size()) {
std::string line = static_cast<std::string>(lines[li]);
src_i = 0;
render_col = 0;
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 = 8;
const std::size_t next_tab = tab_width - (rc % tab_width);
rc += next_tab;
} else {
int w = wcwidth(wch);
if (w < 0)
w = 1;
rc += static_cast<std::size_t>(w);
}
si += static_cast<std::size_t>(wch_len);
}
vsel_line_rx = rc;
}
src_i = 0;
render_col = 0;
// Syntax highlighting: fetch per-line spans (sanitized copy)
std::vector<kte::HighlightSpan> sane_spans;
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
@@ -153,39 +225,50 @@ TerminalRenderer::Draw(Editor &ed)
}
return kte::TokenKind::Default;
};
auto apply_token_attr = [&](kte::TokenKind k) {
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
attrset(A_NORMAL);
auto token_attr = [&](kte::TokenKind k) -> attr_t {
switch (k) {
case kte::TokenKind::Keyword:
case kte::TokenKind::Type:
case kte::TokenKind::Constant:
case kte::TokenKind::Function:
attron(A_BOLD);
break;
case kte::TokenKind::Comment:
attron(A_DIM);
break;
case kte::TokenKind::String:
case kte::TokenKind::Char:
case kte::TokenKind::Number:
// standout a bit using A_UNDERLINE if available
attron(A_UNDERLINE);
break;
default:
break;
case kte::TokenKind::Keyword:
case kte::TokenKind::Type:
case kte::TokenKind::Constant:
case kte::TokenKind::Function:
return A_BOLD;
case kte::TokenKind::Comment:
return A_DIM;
case kte::TokenKind::String:
case kte::TokenKind::Char:
case kte::TokenKind::Number:
return A_UNDERLINE;
default:
return 0;
}
};
while (written < cols) {
char ch = ' ';
bool from_src = false;
wchar_t wch = L' ';
int wch_len = 1;
int disp_w = 1;
if (src_i < line.size()) {
unsigned char c = static_cast<unsigned char>(line[src_i]);
if (c == '\t') {
// Decode UTF-8
std::mbstate_t state = std::mbstate_t();
size_t res = std::mbrtowc(
&wch, &line[src_i], line.size() - src_i, &state);
if (res == (size_t) -1 || res == (size_t) -2) {
// Invalid or incomplete; treat as single byte
wch = static_cast<unsigned char>(line[src_i]);
wch_len = 1;
} else if (res == 0) {
wch = L'\0';
wch_len = 1;
} else {
wch_len = static_cast<int>(res);
}
if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col % tabw);
if (render_col + next_tab <= coloffs) {
render_col += next_tab;
++src_i;
src_i += wch_len;
continue;
}
// Emit spaces for tab
@@ -194,102 +277,107 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t to_skip = std::min<std::size_t>(
next_tab, coloffs - render_col);
render_col += to_skip;
next_tab -= to_skip;
next_tab -= to_skip;
}
// Now render visible spaces
while (next_tab > 0 && written < cols) {
bool in_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_mode && is_src_in_hl(src_i);
bool in_cur =
has_current && li == cur_my && src_i >= cur_mx
&& src_i < cur_mend;
// Toggle highlight attributes
int attr = 0;
if (in_hl)
attr |= A_STANDOUT;
if (in_cur)
attr |= A_BOLD;
if ((attr & A_STANDOUT) && !hl_on) {
attron(A_STANDOUT);
hl_on = true;
}
if (!(attr & A_STANDOUT) && hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if ((attr & A_BOLD) && !cur_on) {
attron(A_BOLD);
cur_on = true;
}
if (!(attr & A_BOLD) && cur_on) {
attroff(A_BOLD);
cur_on = false;
}
// Apply syntax attribute only if not in search highlight
if (!in_hl) {
apply_token_attr(token_at(src_i));
&&
src_i < cur_mend;
attr_t a = A_NORMAL;
a |= token_attr(token_at(src_i));
if (in_sel) {
a |= A_REVERSE;
} else {
if (in_hl)
a |= A_STANDOUT;
if (in_cur)
a |= A_BOLD;
}
attrset(a);
addch(' ');
++written;
++render_col;
--next_tab;
}
++src_i;
src_i += wch_len;
continue;
} else {
// normal char
disp_w = wcwidth(wch);
if (disp_w < 0)
disp_w = 1; // non-printable or similar
if (render_col < coloffs) {
++render_col;
++src_i;
render_col += disp_w;
src_i += wch_len;
continue;
}
ch = static_cast<char>(c);
from_src = true;
}
} else {
// beyond EOL, fill spaces
ch = ' ';
wch = L' ';
wch_len = 1;
disp_w = 1;
from_src = false;
}
if (written + disp_w > cols) {
// would overflow, just break
break;
}
bool in_mark = from_src && is_src_in_mark_sel(li, src_i);
bool in_vsel = false;
if (vsel_on_line) {
if (from_src) {
in_vsel = !vsel_spot_is_eol && src_i == vsel_spot_src;
} else {
in_vsel = vsel_spot_is_eol && render_col == vsel_line_rx;
}
}
bool in_sel = in_mark || in_vsel;
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
bool in_cur =
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i <
cur_mend;
if (in_hl && !hl_on) {
attron(A_STANDOUT);
hl_on = true;
}
if (!in_hl && hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if (in_cur && !cur_on) {
attron(A_BOLD);
cur_on = true;
}
if (!in_cur && cur_on) {
attroff(A_BOLD);
cur_on = false;
}
if (!in_hl && from_src) {
apply_token_attr(token_at(src_i));
}
addch(static_cast<unsigned char>(ch));
++written;
++render_col;
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
src_i < cur_mend;
attr_t a = A_NORMAL;
if (from_src)
++src_i;
a |= token_attr(token_at(src_i));
if (in_sel) {
a |= A_REVERSE;
} else {
if (in_hl)
a |= A_STANDOUT;
if (in_cur)
a |= A_BOLD;
}
attrset(a);
if (from_src) {
cchar_t cch;
wchar_t warr[2] = {wch, L'\0'};
setcchar(&cch, warr, 0, 0, nullptr);
add_wch(&cch);
} else {
addch(' ');
}
written += disp_w;
render_col += disp_w;
if (from_src)
src_i += wch_len;
if (src_i >= line.size() && written >= cols)
break;
}
}
if (hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if (cur_on) {
attroff(A_BOLD);
cur_on = false;
}
attrset(A_NORMAL);
clrtoeol();
}
@@ -297,23 +385,35 @@ TerminalRenderer::Draw(Editor &ed)
// Place terminal cursor at logical position accounting for tabs and coloffs.
// Recompute the rendered X using the same logic as the drawing loop to avoid
// any drift between the command-layer computation and the terminal renderer.
std::size_t cy = buf->Cury();
std::size_t cx = buf->Curx();
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
std::size_t cy = buf->Cury();
std::size_t cx = buf->Curx();
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
std::size_t rx_recomputed = 0;
if (cy < lines.size()) {
const std::string line_for_cursor = static_cast<std::string>(lines[cy]);
std::size_t src_i_cur = 0;
std::size_t render_col_cur = 0;
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) {
unsigned char ccur = static_cast<unsigned char>(line_for_cursor[src_i_cur]);
if (ccur == '\t') {
std::size_t next_tab = tabw - (render_col_cur % tabw);
render_col_cur += next_tab;
++src_i_cur;
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);
if (res == (size_t) -1 || res == (size_t) -2) {
render_col_cur += 1;
src_i_cur += 1;
} else if (res == 0) {
src_i_cur += 1;
} else {
++render_col_cur;
++src_i_cur;
if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col_cur % tabw);
render_col_cur += next_tab;
} else {
int dw = wcwidth(wch);
render_col_cur += (dw < 0) ? 1 : dw;
}
src_i_cur += res;
}
}
rx_recomputed = render_col_cur;
@@ -403,9 +503,9 @@ TerminalRenderer::Draw(Editor &ed)
{
const char *app = "kte";
left.reserve(256);
left += app;
left += " ";
left += KTE_VERSION_STR; // already includes leading 'v'
left += app;
left += " ";
left += KTE_VERSION_STR; // already includes leading 'v'
const Buffer *b = buf;
std::string fname;
if (b) {
@@ -426,11 +526,11 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t total = ed.BufferCount();
if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // human-friendly 1-based
left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/";
left += std::to_string(static_cast<unsigned long long>(total));
left += "] ";
left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/";
left += std::to_string(static_cast<unsigned long long>(total));
left += "] ";
}
}
left += fname;
@@ -442,9 +542,9 @@ TerminalRenderer::Draw(Editor &ed)
// Append total line count as "<n>L"
if (b) {
unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
left += " ";
left += std::to_string(lcount);
left += "L";
left += " ";
left += std::to_string(lcount);
left += "L";
}
}

View File

@@ -4,8 +4,10 @@
bool
TestFrontend::Init(Editor &ed)
TestFrontend::Init(int &argc, char **argv, Editor &ed)
{
(void) argc;
(void) argv;
ed.SetDimensions(24, 80);
return true;
}
@@ -14,6 +16,9 @@ TestFrontend::Init(Editor &ed)
void
TestFrontend::Step(Editor &ed, bool &running)
{
// Allow deferred opens (including swap recovery prompts) to run.
ed.ProcessPendingOpens();
MappedInput mi;
if (input_.Poll(mi)) {
if (mi.hasCommand) {

View File

@@ -13,7 +13,7 @@ public:
~TestFrontend() override = default;
bool Init(Editor &ed) override;
bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override;

View File

@@ -15,7 +15,9 @@ struct UndoNode {
UndoType type{};
int row{};
int col{};
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
std::string text;
UndoNode *child = nullptr; // next in current timeline
UndoNode *next = nullptr; // redo branch
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
UndoNode *child = nullptr; // next in current timeline
UndoNode *next = nullptr; // redo branch
};

View File

@@ -20,10 +20,11 @@ public:
available_.pop();
// Node comes zeroed; ensure links are reset
node->text.clear();
node->child = nullptr;
node->next = nullptr;
node->row = node->col = 0;
node->type = UndoType{};
node->parent = nullptr;
node->child = nullptr;
node->next = nullptr;
node->row = node->col = 0;
node->type = UndoType{};
return node;
}
@@ -34,10 +35,11 @@ public:
return;
// Clear heavy fields to free memory held by strings
node->text.clear();
node->child = nullptr;
node->next = nullptr;
node->row = node->col = 0;
node->type = UndoType{};
node->parent = nullptr;
node->child = nullptr;
node->next = nullptr;
node->row = node->col = 0;
node->type = UndoType{};
available_.push(node);
}

View File

@@ -8,69 +8,262 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
: buf_(&owner), tree_(tree) {}
std::uint64_t
UndoSystem::BeginGroup()
{
// Ensure any pending typed run is sealed so the group is a distinct undo step.
commit();
if (active_group_id_ == 0)
active_group_id_ = next_group_id_++;
return active_group_id_;
}
void
UndoSystem::EndGroup()
{
commit();
active_group_id_ = 0;
}
void
UndoSystem::Begin(UndoType type)
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
(void) type;
if (!buf_)
return;
const int row = static_cast<int>(buf_->Cury());
const int col = static_cast<int>(buf_->Curx());
// Some operations should always be standalone undo steps.
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow);
if (always_standalone) {
commit();
}
if (tree_.pending) {
if (tree_.pending->type == type) {
// Typed-run coalescing rules.
switch (type) {
case UndoType::Insert:
case UndoType::Paste: {
// Cursor must be at the end of the pending insert.
if (tree_.pending->row == row
&& col == tree_.pending->col + static_cast<int>(tree_.pending->text.size())) {
pending_mode_ = PendingAppendMode::Append;
return;
}
break;
}
case UndoType::Delete: {
if (tree_.pending->row == row) {
// Two common delete shapes:
// 1) backspace-run: cursor moves left each time (so new col is pending.col - 1)
// 2) delete-run: cursor stays, always deleting at the same col
if (col == tree_.pending->col) {
pending_mode_ = PendingAppendMode::Append;
return;
}
if (col + 1 == tree_.pending->col) {
// Extend a backspace run to the left; update the start column now.
tree_.pending->col = col;
pending_mode_ = PendingAppendMode::Prepend;
return;
}
}
break;
}
case UndoType::Newline:
case UndoType::DeleteRow:
break;
}
}
// Can't coalesce: seal the previous pending step.
commit();
}
// Start a new pending node.
tree_.pending = new UndoNode{};
tree_.pending->type = type;
tree_.pending->row = row;
tree_.pending->col = col;
tree_.pending->group_id = active_group_id_;
tree_.pending->text.clear();
tree_.pending->parent = nullptr;
tree_.pending->child = nullptr;
tree_.pending->next = nullptr;
pending_mode_ = PendingAppendMode::Append;
}
void
UndoSystem::Append(char ch)
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
(void) ch;
if (!tree_.pending)
return;
if (pending_mode_ == PendingAppendMode::Prepend) {
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
} else {
tree_.pending->text.push_back(ch);
}
}
void
UndoSystem::Append(std::string_view text)
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
(void) text;
if (!tree_.pending)
return;
if (text.empty())
return;
if (pending_mode_ == PendingAppendMode::Prepend) {
tree_.pending->text.insert(0, text.data(), text.size());
} else {
tree_.pending->text.append(text.data(), text.size());
}
}
void
UndoSystem::commit()
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
if (!tree_.pending)
return;
// Drop empty text batches for text-based operations.
if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
|| tree_.pending->type == UndoType::Paste)
&& tree_.pending->text.empty()) {
delete tree_.pending;
tree_.pending = nullptr;
pending_mode_ = PendingAppendMode::Append;
return;
}
if (!tree_.root) {
tree_.root = tree_.pending;
tree_.pending->parent = nullptr;
tree_.current = tree_.pending;
} else if (!tree_.current) {
// We are at the "pre-first-edit" state (undo past the first node).
// In branching history, preserve the existing root chain as an alternate branch.
tree_.pending->parent = nullptr;
tree_.pending->next = tree_.root;
tree_.root = tree_.pending;
tree_.current = tree_.pending;
} else {
// Branching semantics: attach as a new redo branch under current.
// Make the new edit the active child by inserting it at the head.
tree_.pending->parent = tree_.current;
if (!tree_.current->child) {
tree_.current->child = tree_.pending;
} else {
tree_.pending->next = tree_.current->child;
tree_.current->child = tree_.pending;
}
tree_.current = tree_.pending;
}
tree_.pending = nullptr;
pending_mode_ = PendingAppendMode::Append;
update_dirty_flag();
}
void
UndoSystem::undo()
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
// Seal any in-progress typed run before undo.
commit();
if (!tree_.current)
return;
debug_log("undo");
const std::uint64_t gid = tree_.current->group_id;
do {
UndoNode *node = tree_.current;
apply(node, -1);
tree_.current = node->parent;
} while (gid != 0 && tree_.current && tree_.current->group_id == gid);
update_dirty_flag();
}
void
UndoSystem::redo()
UndoSystem::redo(int branch_index)
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
commit();
UndoNode **head = nullptr;
if (!tree_.current) {
head = &tree_.root;
} else {
head = &tree_.current->child;
}
if (!head || !*head)
return;
if (branch_index < 0)
branch_index = 0;
// Select the Nth sibling from the branch list and make it the active head.
UndoNode *prev = nullptr;
UndoNode *sel = *head;
for (int i = 0; i < branch_index && sel; ++i) {
prev = sel;
sel = sel->next;
}
if (!sel)
return;
if (prev) {
prev->next = sel->next;
sel->next = *head;
*head = sel;
}
debug_log("redo");
UndoNode *node = *head;
const std::uint64_t gid = node->group_id;
apply(node, +1);
tree_.current = node;
while (gid != 0 && tree_.current && tree_.current->child
&& tree_.current->child->group_id == gid) {
UndoNode *child = tree_.current->child;
apply(child, +1);
tree_.current = child;
}
update_dirty_flag();
}
void
UndoSystem::mark_saved()
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
commit();
tree_.saved = tree_.current;
update_dirty_flag();
}
void
UndoSystem::discard_pending()
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
if (tree_.pending) {
delete tree_.pending;
tree_.pending = nullptr;
}
pending_mode_ = PendingAppendMode::Append;
}
void
UndoSystem::clear()
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
discard_pending();
free_node(tree_.root);
tree_.root = nullptr;
tree_.current = nullptr;
tree_.saved = nullptr;
active_group_id_ = 0;
next_group_id_ = 1;
update_dirty_flag();
}
@@ -79,34 +272,46 @@ UndoSystem::apply(const UndoNode *node, int direction)
{
if (!node)
return;
// Cursor positioning: keep the point at a sensible location after undo/redo.
// Low-level Buffer edit primitives do not move the cursor.
switch (node->type) {
case UndoType::Insert:
case UndoType::Paste:
if (direction > 0) {
buf_->insert_text(node->row, node->col, node->text);
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
static_cast<std::size_t>(node->row));
} else {
buf_->delete_text(node->row, node->col, node->text.size());
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
}
break;
case UndoType::Delete:
if (direction > 0) {
buf_->delete_text(node->row, node->col, node->text.size());
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} else {
buf_->insert_text(node->row, node->col, node->text);
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
static_cast<std::size_t>(node->row));
}
break;
case UndoType::Newline:
if (direction > 0) {
buf_->split_line(node->row, node->col);
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
} else {
buf_->join_lines(node->row);
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
}
break;
case UndoType::DeleteRow:
if (direction > 0) {
buf_->delete_row(node->row);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} else {
buf_->insert_row(node->row, node->text);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
}
break;
}

View File

@@ -1,3 +1,44 @@
/*
* UndoSystem.h - undo/redo system with tree-based branching
*
* UndoSystem manages the undo/redo history for a Buffer. It provides:
*
* - Tree-based undo: Multiple redo branches at each node (not just linear history)
* - Atomic grouping: Multiple operations can be undone/redone as a single step
* - Dirty tracking: Marks when buffer matches last saved state
* - Efficient storage: Nodes stored in UndoTree, operations applied to Buffer
*
* Key concepts:
*
* 1. Undo tree structure:
* - Each edit creates a node in the tree
* - Undo moves up the tree (toward root)
* - Redo moves down the tree (toward leaves)
* - Multiple redo branches preserved (not lost on new edits after undo)
*
* 2. Operation lifecycle:
* - Begin(type): Start recording an operation (insert/delete)
* - Append(text): Add content to the pending operation
* - commit(): Finalize and add to undo tree
* - discard_pending(): Cancel without recording
*
* 3. Atomic grouping:
* - BeginGroup()/EndGroup(): Bracket multiple operations
* - All operations in a group share the same group_id
* - Undo/redo treats the entire group as one step
*
* 4. Integration with Buffer:
* - UndoSystem holds a reference to its owning Buffer
* - apply() executes undo/redo by calling Buffer's editing methods
* - Buffer's dirty flag updated automatically
*
* Usage pattern:
* undo_system.Begin(UndoType::Insert);
* undo_system.Append("text");
* undo_system.commit(); // Now undoable
*
* See also: UndoTree.h (storage), UndoNode.h (node structure)
*/
#pragma once
#include <string_view>
#include <cstddef>
@@ -12,6 +53,12 @@ class UndoSystem {
public:
explicit UndoSystem(Buffer &owner, UndoTree &tree);
// Begin an atomic group: subsequent committed nodes with the same group_id will be
// undone/redone as a single step. Returns the active group id.
std::uint64_t BeginGroup();
void EndGroup();
void Begin(UndoType type);
void Append(char ch);
@@ -22,7 +69,10 @@ public:
void undo();
void redo();
// Redo the current node's active child branch.
// If `branch_index` > 0, selects that redo sibling (0-based) and makes it active.
// When current is null (pre-first-edit), branches are selected among `tree_.root` siblings.
void redo(int branch_index = 0);
void mark_saved();
@@ -32,7 +82,20 @@ public:
void UpdateBufferReference(Buffer &new_buf);
#if defined(KTE_TESTS)
// Test-only introspection hook.
const UndoTree &TreeForTests() const
{
return tree_;
}
#endif
private:
enum class PendingAppendMode : std::uint8_t {
Append,
Prepend,
};
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
void free_node(UndoNode *node);
@@ -48,6 +111,11 @@ private:
void update_dirty_flag();
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
std::uint64_t active_group_id_ = 0;
std::uint64_t next_group_id_ = 1;
Buffer *buf_;
UndoTree &tree_;
};

View File

@@ -1,206 +0,0 @@
/*
* BufferBench.cc - microbenchmarks for GapBuffer and PieceTable
*
* This benchmark exercises the public APIs shared by both structures as used
* in Buffer::Line: Reserve, AppendChar, Append, PrependChar, Prepend, Clear.
*
* Run examples:
* ./kte_bench_buffer # defaults
* ./kte_bench_buffer 200000 8 4096 # N=200k, rounds=8, chunk=4096
*/
#include <chrono>
#include <cstdint>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <random>
#include <string>
#include <vector>
#include <typeinfo>
#include "GapBuffer.h"
#include "PieceTable.h"
using clock_t = std::chrono::steady_clock;
using us = std::chrono::microseconds;
struct Result {
std::string name;
std::string scenario;
double micros = 0.0;
std::size_t bytes = 0;
};
static void
print_header()
{
std::cout << std::left << std::setw(14) << "Structure"
<< std::left << std::setw(18) << "Scenario"
<< std::right << std::setw(12) << "time(us)"
<< std::right << std::setw(14) << "bytes"
<< std::right << std::setw(14) << "MB/s"
<< "\n";
std::cout << std::string(72, '-') << "\n";
}
static void
print_row(const Result &r)
{
double mb = r.bytes / (1024.0 * 1024.0);
double mbps = (r.micros > 0.0) ? (mb / (r.micros / 1'000'000.0)) : 0.0;
std::cout << std::left << std::setw(14) << r.name
<< std::left << std::setw(18) << r.scenario
<< std::right << std::setw(12) << std::fixed << std::setprecision(2) << r.micros
<< std::right << std::setw(14) << r.bytes
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << mbps
<< "\n";
}
template<typename Buf>
Result
bench_sequential_append(std::size_t N, int rounds)
{
Result r;
r.name = typeid(Buf).name();
r.scenario = "seq_append";
const char c = 'x';
auto start = clock_t::now();
std::size_t bytes = 0;
for (int t = 0; t < rounds; ++t) {
Buf b;
b.Reserve(N);
for (std::size_t i = 0; i < N; ++i) {
b.AppendChar(c);
}
bytes += N;
}
auto end = clock_t::now();
r.micros = std::chrono::duration_cast<us>(end - start).count();
r.bytes = bytes;
return r;
}
template<typename Buf>
Result
bench_sequential_prepend(std::size_t N, int rounds)
{
Result r;
r.name = typeid(Buf).name();
r.scenario = "seq_prepend";
const char c = 'x';
auto start = clock_t::now();
std::size_t bytes = 0;
for (int t = 0; t < rounds; ++t) {
Buf b;
b.Reserve(N);
for (std::size_t i = 0; i < N; ++i) {
b.PrependChar(c);
}
bytes += N;
}
auto end = clock_t::now();
r.micros = std::chrono::duration_cast<us>(end - start).count();
r.bytes = bytes;
return r;
}
template<typename Buf>
Result
bench_chunk_append(std::size_t N, std::size_t chunk, int rounds)
{
Result r;
r.name = typeid(Buf).name();
r.scenario = "chunk_append";
std::string payload(chunk, 'y');
auto start = clock_t::now();
std::size_t bytes = 0;
for (int t = 0; t < rounds; ++t) {
Buf b;
b.Reserve(N);
std::size_t written = 0;
while (written < N) {
std::size_t now = std::min(chunk, N - written);
b.Append(payload.data(), now);
written += now;
}
bytes += N;
}
auto end = clock_t::now();
r.micros = std::chrono::duration_cast<us>(end - start).count();
r.bytes = bytes;
return r;
}
template<typename Buf>
Result
bench_mixed(std::size_t N, std::size_t chunk, int rounds)
{
Result r;
r.name = typeid(Buf).name();
r.scenario = "mixed";
std::string payload(chunk, 'z');
auto start = clock_t::now();
std::size_t bytes = 0;
for (int t = 0; t < rounds; ++t) {
Buf b;
b.Reserve(N);
std::size_t written = 0;
while (written < N) {
// alternate append/prepend with small chunks
std::size_t now = std::min(chunk, N - written);
if ((written / chunk) % 2 == 0) {
b.Append(payload.data(), now);
} else {
b.Prepend(payload.data(), now);
}
written += now;
}
bytes += N;
}
auto end = clock_t::now();
r.micros = std::chrono::duration_cast<us>(end - start).count();
r.bytes = bytes;
return r;
}
int
main(int argc, char **argv)
{
// Parameters
std::size_t N = 100'000; // bytes per round
int rounds = 5; // iterations
std::size_t chunk = 1024; // chunk size for chunked scenarios
if (argc >= 2)
N = static_cast<std::size_t>(std::stoull(argv[1]));
if (argc >= 3)
rounds = std::stoi(argv[2]);
if (argc >= 4)
chunk = static_cast<std::size_t>(std::stoull(argv[3]));
std::cout << "KTE Buffer Microbenchmarks" << "\n";
std::cout << "N=" << N << ", rounds=" << rounds << ", chunk=" << chunk << "\n\n";
print_header();
// Run for GapBuffer
print_row(bench_sequential_append<GapBuffer>(N, rounds));
print_row(bench_sequential_prepend<GapBuffer>(N, rounds));
print_row(bench_chunk_append<GapBuffer>(N, chunk, rounds));
print_row(bench_mixed<GapBuffer>(N, chunk, rounds));
// Run for PieceTable
print_row(bench_sequential_append<PieceTable>(N, rounds));
print_row(bench_sequential_prepend<PieceTable>(N, rounds));
print_row(bench_chunk_append<PieceTable>(N, chunk, rounds));
print_row(bench_mixed<PieceTable>(N, chunk, rounds));
return 0;
}

View File

@@ -1,318 +0,0 @@
/*
* PerformanceSuite.cc - broader performance and verification benchmarks
*/
#include <algorithm>
#include <cassert>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <random>
#include <string>
#include <typeinfo>
#include <vector>
#include "GapBuffer.h"
#include "PieceTable.h"
#include "OptimizedSearch.h"
using clock_t = std::chrono::steady_clock;
using us = std::chrono::microseconds;
namespace {
struct Stat {
double micros{0.0};
std::size_t bytes{0};
std::size_t ops{0};
};
static void
print_header(const std::string &title)
{
std::cout << "\n" << title << "\n";
std::cout << std::left << std::setw(18) << "Case"
<< std::left << std::setw(18) << "Type"
<< std::right << std::setw(12) << "time(us)"
<< std::right << std::setw(14) << "bytes"
<< std::right << std::setw(14) << "ops/s"
<< std::right << std::setw(14) << "MB/s"
<< "\n";
std::cout << std::string(90, '-') << "\n";
}
static void
print_row(const std::string &caseName, const std::string &typeName, const Stat &s)
{
double mb = s.bytes / (1024.0 * 1024.0);
double sec = s.micros / 1'000'000.0;
double mbps = sec > 0 ? (mb / sec) : 0.0;
double opss = sec > 0 ? (static_cast<double>(s.ops) / sec) : 0.0;
std::cout << std::left << std::setw(18) << caseName
<< std::left << std::setw(18) << typeName
<< std::right << std::setw(12) << std::fixed << std::setprecision(2) << s.micros
<< std::right << std::setw(14) << s.bytes
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << opss
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << mbps
<< "\n";
}
} // namespace
class PerformanceSuite {
public:
void benchmarkBufferOperations(std::size_t N, int rounds, std::size_t chunk)
{
print_header("Buffer Operations");
run_buffer_case<GapBuffer>("append_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
for (std::size_t i = 0; i < count; ++i)
b.AppendChar('a');
});
run_buffer_case<GapBuffer>("prepend_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
for (std::size_t i = 0; i < count; ++i)
b.PrependChar('a');
});
run_buffer_case<GapBuffer>("chunk_mix", N, rounds, chunk, [&](auto &b, std::size_t) {
std::string payload(chunk, 'x');
std::size_t written = 0;
while (written < N) {
std::size_t now = std::min(chunk, N - written);
if (((written / chunk) & 1) == 0)
b.Append(payload.data(), now);
else
b.Prepend(payload.data(), now);
written += now;
}
});
run_buffer_case<PieceTable>("append_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
for (std::size_t i = 0; i < count; ++i)
b.AppendChar('a');
});
run_buffer_case<PieceTable>("prepend_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
for (std::size_t i = 0; i < count; ++i)
b.PrependChar('a');
});
run_buffer_case<PieceTable>("chunk_mix", N, rounds, chunk, [&](auto &b, std::size_t) {
std::string payload(chunk, 'x');
std::size_t written = 0;
while (written < N) {
std::size_t now = std::min(chunk, N - written);
if (((written / chunk) & 1) == 0)
b.Append(payload.data(), now);
else
b.Prepend(payload.data(), now);
written += now;
}
});
}
void benchmarkSearchOperations(std::size_t textLen, std::size_t patLen, int rounds)
{
print_header("Search Operations");
std::mt19937_64 rng(0xC0FFEE);
std::uniform_int_distribution<int> dist('a', 'z');
std::string text(textLen, '\0');
for (auto &ch: text)
ch = static_cast<char>(dist(rng));
std::string pattern(patLen, '\0');
for (auto &ch: pattern)
ch = static_cast<char>(dist(rng));
// Ensure at least one hit
if (textLen >= patLen && patLen > 0) {
std::size_t pos = textLen / 2;
std::memcpy(&text[pos], pattern.data(), patLen);
}
// OptimizedSearch find_all vs std::string reference
OptimizedSearch os;
Stat s{};
auto start = clock_t::now();
std::size_t matches = 0;
std::size_t bytesScanned = 0;
for (int r = 0; r < rounds; ++r) {
auto hits = os.find_all(text, pattern, 0);
matches += hits.size();
bytesScanned += text.size();
// Verify with reference
std::vector<std::size_t> ref;
std::size_t from = 0;
while (true) {
auto p = text.find(pattern, from);
if (p == std::string::npos)
break;
ref.push_back(p);
from = p + (patLen ? patLen : 1);
}
assert(ref == hits);
}
auto end = clock_t::now();
s.micros = std::chrono::duration_cast<us>(end - start).count();
s.bytes = bytesScanned;
s.ops = matches;
print_row("find_all", "OptimizedSearch", s);
}
void benchmarkMemoryAllocation(std::size_t N, int rounds)
{
print_header("Memory Allocation (allocations during editing)");
// Measure number of allocations by simulating editing patterns.
auto run_session = [&](auto &&buffer) {
// alternate small appends and prepends
const std::size_t chunk = 32;
std::string payload(chunk, 'q');
for (int r = 0; r < rounds; ++r) {
buffer.Clear();
for (std::size_t i = 0; i < N; i += chunk)
buffer.Append(payload.data(), std::min(chunk, N - i));
for (std::size_t i = 0; i < N / 2; i += chunk)
buffer.Prepend(payload.data(), std::min(chunk, N / 2 - i));
}
};
// Local allocation counters for this TU via overriding operators
reset_alloc_counters();
GapBuffer gb;
run_session(gb);
auto gap_allocs = current_allocs();
print_row("edit_session", "GapBuffer", Stat{
0.0, static_cast<std::size_t>(gap_allocs.bytes),
static_cast<std::size_t>(gap_allocs.count)
});
reset_alloc_counters();
PieceTable pt;
run_session(pt);
auto pt_allocs = current_allocs();
print_row("edit_session", "PieceTable", Stat{
0.0, static_cast<std::size_t>(pt_allocs.bytes),
static_cast<std::size_t>(pt_allocs.count)
});
}
private:
template<typename Buf, typename Fn>
void run_buffer_case(const std::string &caseName, std::size_t N, int rounds, std::size_t chunk, Fn fn)
{
Stat s{};
auto start = clock_t::now();
std::size_t bytes = 0;
std::size_t ops = 0;
for (int t = 0; t < rounds; ++t) {
Buf b;
b.Reserve(N);
fn(b, N);
// compare to reference string where possible (only for append_char/prepend_char)
bytes += N;
ops += N / (chunk ? chunk : 1);
}
auto end = clock_t::now();
s.micros = std::chrono::duration_cast<us>(end - start).count();
s.bytes = bytes;
s.ops = ops;
print_row(caseName, typeid(Buf).name(), s);
}
// Simple global allocation tracking for this TU
struct AllocStats {
std::uint64_t count{0};
std::uint64_t bytes{0};
};
static AllocStats &alloc_stats()
{
static AllocStats s;
return s;
}
static void reset_alloc_counters()
{
alloc_stats() = {};
}
static AllocStats current_allocs()
{
return alloc_stats();
}
// Friend global new/delete defined below
friend void *operator new(std::size_t sz) noexcept(false);
friend void operator delete(void *p) noexcept;
friend void *operator new[](std::size_t sz) noexcept(false);
friend void operator delete[](void *p) noexcept;
};
// Override new/delete only in this translation unit to track allocations made here
void *
operator new(std::size_t sz) noexcept(false)
{
auto &s = PerformanceSuite::alloc_stats();
s.count++;
s.bytes += sz;
if (void *p = std::malloc(sz))
return p;
throw std::bad_alloc();
}
void
operator delete(void *p) noexcept
{
std::free(p);
}
void *
operator new[](std::size_t sz) noexcept(false)
{
auto &s = PerformanceSuite::alloc_stats();
s.count++;
s.bytes += sz;
if (void *p = std::malloc(sz))
return p;
throw std::bad_alloc();
}
void
operator delete[](void *p) noexcept
{
std::free(p);
}
int
main(int argc, char **argv)
{
std::size_t N = 200'000; // bytes per round for buffer cases
int rounds = 3;
std::size_t chunk = 1024;
if (argc >= 2)
N = static_cast<std::size_t>(std::stoull(argv[1]));
if (argc >= 3)
rounds = std::stoi(argv[2]);
if (argc >= 4)
chunk = static_cast<std::size_t>(std::stoull(argv[3]));
std::cout << "KTE Performance Suite" << "\n";
std::cout << "N=" << N << ", rounds=" << rounds << ", chunk=" << chunk << "\n";
PerformanceSuite suite;
suite.benchmarkBufferOperations(N, rounds, chunk);
suite.benchmarkSearchOperations(1'000'000, 16, rounds);
suite.benchmarkMemoryAllocation(N, rounds);
return 0;
}

78
cmake/fix_bundle.cmake Normal file
View File

@@ -0,0 +1,78 @@
cmake_minimum_required(VERSION 3.15)
# Fix up a macOS .app bundle by copying non-Qt dylibs into
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
#
# Usage:
# cmake -DAPP_BUNDLE=/path/to/kge.app -P cmake/fix_bundle.cmake
if (NOT APP_BUNDLE)
message(FATAL_ERROR "APP_BUNDLE not set. Invoke with -DAPP_BUNDLE=/path/to/App.app")
endif ()
get_filename_component(APP_DIR "${APP_BUNDLE}" ABSOLUTE)
set(EXECUTABLE "${APP_DIR}/Contents/MacOS/kge")
if (NOT EXISTS "${EXECUTABLE}")
message(FATAL_ERROR "Executable not found at: ${EXECUTABLE}")
endif ()
include(BundleUtilities)
# Directories to search when resolving prerequisites. We include Homebrew so that
# if any deps are currently resolved from there, fixup_bundle will copy them into
# the bundle and rewrite install names to be self-contained.
set(DIRS
"/usr/local/lib"
"/opt/homebrew/lib"
"/opt/homebrew/opt"
)
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
# libs it references recursively. Qt frameworks already live in the bundle after
# macdeployqt; this step is primarily for non-Qt dylibs (glib, icu, pcre2, zstd,
# dbus, etc.).
# fixup_bundle often fails if copied libraries are read-only.
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
set(gp_otool "/usr/bin/otool")
set(CMAKE_INSTALL_NAME_TOOL "/usr/bin/install_name_tool")
set(CMAKE_OTOOL "/usr/bin/otool")
set(ENV{PATH} "/usr/bin:/bin:/usr/sbin:/sbin")
execute_process(COMMAND chmod -R u+w "${APP_DIR}/Contents/Frameworks")
fixup_bundle("${APP_DIR}" "" "${DIRS}")
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
# for it to be allowed to run.
# We sign deep, but sometimes explicit signing of components is more reliable.
message(STATUS "Re-signing ${APP_DIR} after fixup...")
# 1. Sign dylibs in Frameworks
file(GLOB_RECURSE DYLIBS "${APP_DIR}/Contents/Frameworks/*.dylib")
foreach (DYLIB ${DYLIBS})
message(STATUS "Signing ${DYLIB}...")
execute_process(COMMAND /usr/bin/codesign --force --sign - "${DYLIB}")
endforeach ()
# 2. Sign nested executables
message(STATUS "Signing nested kte...")
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kte")
# 3. Sign the main executable explicitly
message(STATUS "Signing main kge...")
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kge")
# 4. Sign the main bundle
execute_process(
COMMAND /usr/bin/codesign --force --deep --sign - "${APP_DIR}"
RESULT_VARIABLE CODESIGN_RESULT
)
if (NOT CODESIGN_RESULT EQUAL 0)
message(FATAL_ERROR "Codesign failed with error: ${CODESIGN_RESULT}")
endif ()
message(STATUS "fix_bundle.cmake completed for ${APP_DIR}")

View File

@@ -1,13 +1,15 @@
{
lib,
pkgs ? import <nixpkgs> {},
lib ? pkgs.lib,
stdenv,
cmake,
ncurses,
SDL2,
libGL,
xorg,
kdePackages,
qt6Packages ? kdePackages.qt6Packages,
installShellFiles,
graphical ? false,
graphical-qt ? false,
...
@@ -37,12 +39,13 @@ stdenv.mkDerivation {
xorg.libX11
]
++ lib.optionals graphical-qt [
qt5Full
qtcreator ## not sure if this is actually needed
kdePackages.qt6ct
qt6Packages.qtbase
qt6Packages.wrapQtAppsHook
];
cmakeFlags = [
"-DBUILD_GUI=${if graphical or graphical-qt then "ON" else "OFF"}"
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
"-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}"
"-DCMAKE_BUILD_TYPE=Debug"
];
@@ -52,17 +55,23 @@ stdenv.mkDerivation {
mkdir -p $out/bin
cp kte $out/bin/
installManPage ../docs/kte.1
''
+ lib.optionalString graphical ''
cp kge $out/bin/
installManPage ../docs/kge.1
mkdir -p $out/share/icons
cp ../kge.png $out/share/icons/
''
+ ''
${lib.optionalString graphical ''
mkdir -p $out/bin
${if graphical-qt then ''
cp kge $out/bin/kge-qt
'' else ''
cp kge $out/bin/kge
''}
installManPage ../docs/kge.1
mkdir -p $out/share/icons/hicolor/256x256/apps
cp ../kge.png $out/share/icons/hicolor/256x256/apps/kge.png
''}
runHook postInstall
'';
}

28
docker-build.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Helper script to test Linux builds using Docker/Podman
# This script mounts the current source tree into a Linux container,
# builds kte in terminal-only mode, and runs the test suite.
set -e
# Detect whether to use docker or podman
if command -v docker &> /dev/null; then
CONTAINER_CMD="docker"
elif command -v podman &> /dev/null; then
CONTAINER_CMD="podman"
else
echo "Error: Neither docker nor podman found in PATH"
exit 1
fi
IMAGE_NAME="kte-linux"
# Check if image exists, if not, build it
if ! $CONTAINER_CMD image inspect "$IMAGE_NAME" &> /dev/null; then
echo "Building $IMAGE_NAME image..."
$CONTAINER_CMD build -t "$IMAGE_NAME" .
fi
# Run the container with the current directory mounted
echo "Running Linux build and tests..."
$CONTAINER_CMD run --rm -v "$(pwd):/kte" "$IMAGE_NAME"

245
docs/BENCHMARKS.md Normal file
View File

@@ -0,0 +1,245 @@
# kte Benchmarking and Testing Guide
This document describes the benchmarking infrastructure and testing
improvements added to ensure high performance and correctness of core
operations.
## Overview
The kte test suite now includes comprehensive benchmarks and migration
coverage tests to:
- Measure performance of core operations (PieceTable, Buffer, syntax
highlighting)
- Ensure no performance regressions from refactorings
- Validate correctness of API migrations (Buffer::Rows() →
GetLineString/GetLineView)
- Provide performance baselines for future optimizations
## Running Tests
### All Tests (including benchmarks)
```bash
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
```
### Test Organization
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
search, etc.
- **15 benchmark tests**: Performance measurements for critical
operations
- **30 migration coverage tests**: Edge cases and correctness validation
Total: **98 tests**
## Benchmark Results
### Buffer Iteration Patterns (5,000 lines)
| Pattern | Time | Speedup vs Rows() |
|-----------------------------------------|---------|-------------------|
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
**Key Insight**: `GetLineView()` provides zero-copy access and is
dramatically faster than materializing the entire rows cache.
### PieceTable Operations (10,000 lines)
| Operation | Time |
|-----------------------------|---------|
| Sequential inserts (10K) | 2.1 ms |
| Random inserts (5K) | 32.9 ms |
| `GetLine()` sequential | 4.7 ms |
| `GetLineRange()` sequential | 1.3 ms |
### Buffer Operations
| Operation | Time |
|--------------------------------------|---------|
| `Nrows()` (1M calls) | 13.0 ms |
| `GetLineString()` (10K lines) | 4.8 ms |
| `GetLineView()` (10K lines) | 1.6 ms |
| `Rows()` materialization (10K lines) | 6.2 ms |
### Syntax Highlighting
| Operation | Time | Notes |
|------------------------------------|---------|----------------|
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
| HighlighterEngine cache population | 19.9 ms | |
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
### Large File Performance
| Operation | Time |
|---------------------------------|---------|
| Insert 50K lines | 0.53 ms |
| Iterate 50K lines (GetLineView) | 2.7 ms |
| Random access (10K accesses) | 1.8 ms |
## API Differences: GetLineString vs GetLineView
Understanding the difference between these APIs is critical:
### `GetLineString(row)`
- Returns: `std::string` (copy)
- Content: Line text **without** trailing newline
- Use case: When you need to modify the string or store it
- Example: `"hello"` for line `"hello\n"`
### `GetLineView(row)`
- Returns: `std::string_view` (zero-copy)
- Content: Raw line range **including** trailing newline
- Use case: Read-only access, maximum performance
- Example: `"hello\n"` for line `"hello\n"`
- **Warning**: View becomes invalid after buffer modifications
### `Rows()`
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
- Content: Lines **without** trailing newlines
- Use case: Legacy code, being phased out
- Performance: Slower due to materialization overhead
## Migration Coverage Tests
The `test_migration_coverage.cc` file provides 30 tests covering:
### Edge Cases
- Empty buffers
- Single lines (with/without newlines)
- Very long lines (10,000 characters)
- Many empty lines (1,000 newlines)
### Consistency
- `GetLineString()` vs `GetLineView()` vs `Rows()`
- Consistency after edits (insert, delete, split, join)
### Boundary Conditions
- First line access
- Last line access
- Line range boundaries
### Special Characters
- Tabs, carriage returns, null bytes
- Unicode (UTF-8 multibyte characters)
### Stress Tests
- Large files (10,000 lines)
- Many small operations (100+ inserts)
- Alternating insert/delete patterns
### Regression Tests
- Shebang detection pattern (Editor.cc)
- Empty buffer check pattern (Editor.cc)
- Syntax highlighter pattern (all highlighters)
- Swap snapshot pattern (Swap.cc)
## Performance Recommendations
Based on benchmark results:
1. **Prefer `GetLineView()` for read-only access**
- 11x faster than `Rows()` for iteration
- Zero-copy, minimal overhead
- Use immediately (view invalidates on edit)
2. **Use `GetLineString()` when you need a copy**
- Still 1.7x faster than `Rows()`
- Safe to store and modify
- Strips trailing newlines automatically
3. **Avoid `Rows()` in hot paths**
- Materializes entire line cache
- Slower for large files
- Being phased out (legacy API)
4. **Cache `Nrows()` in tight loops**
- Very fast (13ms for 1M calls)
- But still worth caching in inner loops
5. **Leverage HighlighterEngine caching**
- 38x speedup on cache hits
- Automatically invalidates on edits
- Prefetch viewport for smooth scrolling
## Adding New Benchmarks
To add a new benchmark:
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
2. Use `BenchmarkTimer` to measure critical sections:
```cpp
{
BenchmarkTimer timer("Operation description");
// ... code to benchmark ...
}
```
3. Print section headers with `std::cout` for clarity
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
Example:
```cpp
TEST(Benchmark_MyOperation) {
std::cout << "\n=== My Operation Benchmark ===\n";
// Setup
Buffer buf;
std::string data = generate_test_data();
buf.insert_text(0, 0, data);
std::size_t result = 0;
{
BenchmarkTimer timer("My operation on 10K lines");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
result += my_operation(buf, i);
}
}
EXPECT_TRUE(result > 0);
}
```
## Continuous Performance Monitoring
Run benchmarks regularly to detect regressions:
```bash
# Run tests and save output
./cmake-build-debug/kte_tests > benchmark_results.txt
# Compare with baseline
diff benchmark_baseline.txt benchmark_results.txt
```
Look for:
- Significant time increases (>20%) in any benchmark
- New operations that are slower than expected
- Cache effectiveness degradation
## Conclusion
The benchmark suite provides:
- **Performance validation**: Ensures migrations don't regress
performance
- **Optimization guidance**: Identifies fastest APIs for each use case
- **Regression detection**: Catches performance issues early
- **Documentation**: Demonstrates correct API usage patterns
All 98 tests pass with 0 failures, confirming both correctness and
performance of the migrated codebase.

652
docs/DEVELOPER_GUIDE.md Normal file
View File

@@ -0,0 +1,652 @@
# kte Developer Guide
Welcome to kte development! This guide will help you understand the
codebase, make changes, and contribute effectively.
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Core Components](#core-components)
3. [Code Organization](#code-organization)
4. [Building and Testing](#building-and-testing)
5. [Making Changes](#making-changes)
6. [Code Style](#code-style)
7. [Common Tasks](#common-tasks)
## Architecture Overview
kte follows a clean separation of concerns with three main layers:
```
┌─────────────────────────────────────────┐
│ Frontend Layer (Terminal/ImGui/Qt) │
│ - TerminalFrontend / ImGuiFrontend │
│ - InputHandler + Renderer interfaces │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Command Layer │
│ - Command registry and execution │
│ - All editing operations │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Core Model Layer │
│ - Editor (top-level state) │
│ - Buffer (document model) │
│ - PieceTable (text storage) │
│ - UndoSystem (undo/redo) │
│ - SwapManager (crash recovery) │
└─────────────────────────────────────────┘
```
### Design Principles
- **Frontend Independence**: Core editing logic is independent of UI.
Frontends implement `Frontend`, `InputHandler`, and `Renderer`
interfaces.
- **Command Pattern**: All editing operations go through the command
system, enabling consistent undo/redo and testing.
- **Piece Table**: Efficient text storage using a piece table data
structure that avoids copying large buffers.
- **Lazy Materialization**: Text is materialized on-demand to minimize
memory allocations.
## Core Components
### Editor (`Editor.h/.cc`)
The top-level editor state container. Manages:
- Multiple buffers
- Editor modes (normal, k-command prefix, prompts)
- Kill ring (clipboard history)
- Universal argument state
- Search state
- Status messages
- Swap file management
**Key Insight**: Editor is primarily a state holder with many
getter/setter pairs. It doesn't contain editing logic - that's in
commands.
### Buffer (`Buffer.h/.cc`)
Represents an open document. Manages:
- File I/O (open, save, external modification detection)
- Cursor position and viewport offsets
- Mark (selection start point)
- Visual line mode state
- Syntax highlighting integration
- Undo system integration
- Swap recording integration
**Key Insight**: Buffer wraps a PieceTable and provides a higher-level
interface. The nested `Buffer::Line` class is a legacy wrapper that has
been largely phased out in favor of direct PieceTable operations.
**Line Access APIs**: Buffer provides three ways to access line content:
- `GetLineView(row)` - Zero-copy `string_view` (fastest, 11x faster than
Rows())
- `GetLineString(row)` - Returns `std::string` copy (1.7x faster than
Rows())
- `Rows()` - Materializes all lines into cache (legacy, avoid in new
code)
See `docs/BENCHMARKS.md` for detailed performance analysis and usage
guidance.
### PieceTable (`PieceTable.h/.cc`)
The core text storage data structure. Provides:
- Efficient insert/delete operations without copying entire buffer
- Line-based queries (line count, get line, line ranges)
- Position conversion (byte offset ↔ line/column)
- Substring extraction
- Search functionality
- Automatic consolidation to prevent piece fragmentation
**Key Insight**: PieceTable uses lazy materialization - the full text is
only assembled when `Data()` is called. Most operations work directly on
the piece list.
### UndoSystem (`UndoSystem.h/.cc`, `UndoTree.h/.cc`, `UndoNode.h/.cc`)
Implements undo/redo with a tree structure supporting:
- Linear undo/redo
- Branching history (future enhancement)
- Checkpointing and compaction
- Memory-efficient node pooling
**Key Insight**: The undo system records operations at the PieceTable
level, not at the command level.
### Command System (`Command.h/.cc`)
All editing operations are implemented as commands:
- File operations (save, open, close)
- Navigation (move cursor, page up/down, word movement)
- Editing (insert, delete, kill, yank)
- Search and replace
- Buffer management
- Configuration (syntax, theme, font)
**Key Insight**: `Command.cc` is currently a monolithic 5000-line file.
This is the biggest maintainability challenge in the codebase.
### Frontend Abstraction
Three interfaces define the frontend contract:
- **Frontend** (`Frontend.h`): Top-level lifecycle (Init/Step/Shutdown)
- **InputHandler** (`InputHandler.h`): Converts UI events to commands
- **Renderer** (`Renderer.h`): Draws the editor state
Implementations:
- **Terminal**: ncurses-based (`TerminalFrontend`,
`TerminalInputHandler`, `TerminalRenderer`)
- **ImGui**: Dear ImGui-based (`ImGuiFrontend`, `ImGuiInputHandler`,
`ImGuiRenderer`)
- **Qt**: Qt-based (`QtFrontend`, `QtInputHandler`, `QtRenderer`)
- **Test**: Programmatic testing (`TestFrontend`, `TestInputHandler`,
`TestRenderer`)
## Code Organization
### Directory Structure
```
kte/
├── *.h, *.cc # Core implementation (root level)
├── main.cc # Entry point
├── docs/ # Documentation
│ ├── ke.md # Original ke editor reference (keybindings)
│ ├── swap.md # Swap file design
│ ├── syntax.md # Syntax highlighting
│ ├── themes.md # Theme system
│ └── plans/ # Design documents
├── tests/ # Test suite
│ ├── Test.h # Minimal test framework
│ ├── TestRunner.cc # Test runner
│ └── test_*.cc # Individual test files
├── syntax/ # Syntax highlighting engines
├── fonts/ # Embedded fonts for GUI
├── themes/ # Color themes
└── ext/ # External dependencies (imgui)
```
### File Naming Conventions
- Headers: `ComponentName.h`
- Implementation: `ComponentName.cc`
- Tests: `test_feature_name.cc`
### Key Files by Size
Large files that may need attention:
- `Command.cc` (4995 lines) - **Needs refactoring**: Consider splitting
into logical groups
- `Swap.cc` (1300 lines) - Crash recovery system (migrated to direct
PieceTable operations)
- `QtFrontend.cc` (985 lines) - Qt integration
- `ImGuiRenderer.cc` (930 lines) - ImGui rendering
- `PieceTable.cc` (800 lines) - Core data structure
- `Buffer.cc` (763 lines) - Document model
## Building and Testing
### Build System
kte uses CMake with multiple build profiles:
```bash
# Debug build (terminal only)
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug
cmake --build cmake-build-debug
# Release build with GUI
cmake -S . -B cmake-build-release -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=ON
cmake --build cmake-build-release
# Build specific target
cmake --build cmake-build-debug --target kte_tests
```
### CMake Targets
- `kte` - Terminal editor executable
- `kge` - GUI editor executable (when `BUILD_GUI=ON`)
- `kte_tests` - Test suite
- `imgui` - Dear ImGui library (when `BUILD_GUI=ON`)
### Running Tests
```bash
# Build and run all tests
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
# Run tests with verbose output
./cmake-build-debug/kte_tests
```
### Test Organization
The test suite uses a minimal custom framework (`Test.h`):
```cpp
TEST(TestName) {
// Test body
ASSERT_EQ(actual, expected);
ASSERT_TRUE(condition);
EXPECT_TRUE(condition); // Non-fatal
}
```
Test files by category:
- **Core Data Structures**:
- `test_piece_table.cc` - PieceTable operations, line indexing,
random edits
- `test_buffer_rows.cc` - Buffer row operations
- `test_buffer_io.cc` - File I/O (open, save, SaveAs)
- **Editing Operations**:
- `test_command_semantics.cc` - Command execution
- `test_kkeymap.cc` - Keybinding system
- `test_visual_line_mode.cc` - Visual line selection
- **Search and Replace**:
- `test_search.cc` - Search functionality
- `test_search_replace_flow.cc` - Interactive search/replace
- **Text Reflow**:
- `test_reflow_paragraph.cc` - Paragraph reformatting
- `test_reflow_indented_bullets.cc` - Indented list handling
- **Undo System**:
- `test_undo.cc` - Undo/redo operations
- **Swap Files** (Crash Recovery):
- `test_swap_recorder.cc` - Recording operations
- `test_swap_writer.cc` - Writing swap files
- `test_swap_replay.cc` - Replaying operations
- `test_swap_recovery_prompt.cc` - Recovery UI
- `test_swap_cleanup.cc` - Cleanup logic
- `test_swap_git_editor.cc` - Git editor integration
- **Performance and Migration**:
- `test_benchmarks.cc` - Performance benchmarks for core operations
- `test_migration_coverage.cc` - Buffer::Line migration validation
- **Integration Tests**:
- `test_daily_workflows.cc` - Real-world editing scenarios
- `test_daily_driver_harness.cc` - Workflow test infrastructure
**Total**: 98 tests across 22 test files. See `docs/BENCHMARKS.md` for
performance benchmark results.
### Docker/Podman for Linux Builds
A minimal `Dockerfile` is provided for **testing Linux builds** without
requiring a native Linux system. The Dockerfile creates a build
environment container with all necessary dependencies. Your source tree
is mounted into the container at runtime, allowing you to test
compilation and run tests on Linux.
**Important**: This is intended for testing Linux builds, not for
running
kte locally. The container expects the source tree to be mounted when
run.
This is particularly useful for:
- **macOS/Windows developers** testing Linux compatibility
- **CI/CD pipelines** ensuring cross-platform builds
- **Reproducible builds** with a known Alpine Linux 3.19 environment
#### Prerequisites
Install Docker or Podman:
- **macOS**: `brew install podman` (Docker Desktop also works)
- **Linux**: Use your distribution's package manager
- **Windows**: Docker Desktop or Podman Desktop
If using Podman on macOS, start the VM:
```bash
podman machine init
podman machine start
```
#### Building the Docker Image
The Dockerfile installs all build dependencies including GUI support (
g++ 13.2.1, CMake 3.27.8, ncurses-dev, SDL2, OpenGL/Mesa, Freetype). It
does not copy or build the source code.
From the project root:
```bash
# Build the environment image
docker build -t kte-linux .
# Or with Podman
podman build -t kte-linux .
```
#### Testing Linux Builds
Mount your source tree and run the build + tests:
```bash
# Build and test (default command)
docker run --rm -v "$(pwd):/kte" kte-linux
# Expected output: "98 tests passed, 0 failed"
```
The default command builds both `kte` (terminal) and `kge` (GUI)
executables with full GUI support (`-DBUILD_GUI=ON`) and runs the
complete test suite.
#### Custom Build Commands
```bash
# Open a shell in the build environment
docker run --rm -it -v "$(pwd):/kte" kte-linux /bin/bash
# Then inside the container:
cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON
cmake --build build --target kte # Terminal version
cmake --build build --target kge # GUI version
cmake --build build --target kte_tests
./build/kte_tests
# Or run kte directly
./build/kte --help
# Terminal-only build (smaller, faster)
cmake -B build -DBUILD_GUI=OFF -DBUILD_TESTS=ON
cmake --build build --target kte
```
#### Running kte Interactively
To test kte's terminal UI on Linux:
```bash
# Run kte with a file from your host system
docker run --rm -it -v "$(pwd):/kte" kte-linux sh -c "cmake -B build -DBUILD_GUI=OFF && cmake --build build --target kte && ./build/kte README.md"
```
#### CI/CD Integration
Example GitHub Actions workflow:
```yaml
- name: Test Linux Build
run: |
docker build -t kte-linux .
docker run --rm -v "${{ github.workspace }}:/kte" kte-linux
```
#### Troubleshooting
**"Cannot connect to Podman socket"** (macOS):
```bash
podman machine start
```
**"Permission denied"** (Linux):
```bash
# Add your user to the docker group
sudo usermod -aG docker $USER
# Log out and back in
```
**Build fails with ncurses errors**:
The Dockerfile explicitly installs `ncurses-dev` (wide-character
ncurses). If you modify the Dockerfile, ensure this dependency remains.
**"No such file or directory" errors**:
Ensure you're mounting the source tree with `-v "$(pwd):/kte"` when
running the container.
### Writing Tests
When adding new functionality:
1. **Add a test first** - Write a failing test that demonstrates the
desired behavior
2. **Use descriptive names** - Test names should explain what's being
validated
3. **Test edge cases** - Empty buffers, EOF, beginning of file, etc.
4. **Use TestFrontend** - For integration tests, use the programmatic
test frontend
Example test structure:
```cpp
TEST(Feature_Behavior_Scenario) {
// Setup
Buffer buf;
buf.insert_text(0, 0, "test content\n");
// Exercise
buf.delete_text(0, 5, 4);
// Verify
ASSERT_EQ(buf.GetLineString(0), std::string("test\n"));
}
```
## Making Changes
### Development Workflow
1. **Understand the change scope**:
- Pure UI change? → Modify frontend only
- New editing operation? → Add command in `Command.cc`
- Core data structure? → Modify `PieceTable` or `Buffer`
2. **Find relevant code**:
- Use `git grep` or IDE search to find similar functionality
- Check `Command.cc` for existing command patterns
- Look at tests to understand expected behavior
3. **Make the change**:
- Follow existing code style (see below)
- Add or update tests
- Update documentation if needed
4. **Test thoroughly**:
- Run the full test suite
- Manually test in both terminal and GUI (if applicable)
- Test edge cases (empty files, large files, EOF, etc.)
### Common Pitfalls
- **Don't modify `Buffer::Rows()` directly** - Use the PieceTable API (
`insert_text`, `delete_text`, etc.) to ensure undo and swap recording
work correctly.
- **Prefer efficient line access** - Use `GetLineView()` for read-only
access (11x faster than `Rows()`), or `GetLineString()` when you need
a copy. Avoid `Rows()` in new code.
- **Remember to invalidate caches** - If you modify PieceTable
internals, ensure line index and materialization caches are
invalidated.
- **Cursor visibility** - After editing operations, call
`ensure_cursor_visible()` to update viewport offsets.
- **Undo boundaries** - Use `buf.Undo()->BeginGroup()` and `EndGroup()`
to group related operations.
- **GetLineView() lifetime** - The returned `string_view` is only valid
until the next buffer modification. Use immediately or copy to
`std::string`.
## Code Style
kte uses C++20 with these conventions:
### Naming
- **Classes/Structs**: `PascalCase` (e.g., `PieceTable`, `Buffer`)
- **Functions/Methods**: `PascalCase` (e.g., `GetLine`, `Insert`)
- **Variables**: `snake_case` with trailing underscore for members (
e.g., `total_size_`, `line_index_`)
- **Constants**: `snake_case` or `UPPER_CASE` depending on context
- **Private members**: Trailing underscore (e.g., `pieces_`, `dirty_`)
### Formatting
- **Indentation**: Tabs (width 8 in most files, but follow existing
style)
- **Braces**: Opening brace on same line for functions, control
structures
- **Line length**: No strict limit, but keep reasonable (~100-120 chars)
- **Includes**: Group by category (system, external, project) with blank
lines between
### Comments
- **File headers**: Brief description of the file's purpose
- **Function comments**: Explain non-obvious behavior, not what the code
obviously does
- **Inline comments**: Explain *why*, not *what*
- **TODO comments**: Use `TODO:` prefix for future work
Example:
```cpp
// Consolidate small pieces to prevent fragmentation.
// This is a heuristic: we only consolidate when piece count exceeds
// a threshold, and we cap the bytes processed per consolidation run.
void maybeConsolidate() {
if (pieces_.size() < piece_limit_)
return;
// ... implementation
}
```
## Common Tasks
### Adding a New Command
1. **Define the command function** in `Command.cc`:
```cpp
bool cmd_my_feature(CommandContext &ctx) {
Editor &ed = ctx.ed;
Buffer *buf = ed.CurrentBuffer();
if (!buf) return false;
// Implement the command
buf->insert_text(buf->Cury(), buf->Curx(), "text");
return true;
}
```
2. **Register the command** in `InstallDefaultCommands()`:
```cpp
CommandRegistry::Register({
CommandId::MyFeature,
"my-feature",
"Description of what it does",
cmd_my_feature
});
```
3. **Add keybinding** in the appropriate `InputHandler` (e.g.,
`TerminalInputHandler.cc`).
4. **Write tests** in `tests/test_command_semantics.cc` or a new test
file.
### Adding a New Frontend
1. **Implement the three interfaces**:
- `Frontend` - Lifecycle management
- `InputHandler` - Event → Command translation
- `Renderer` - Draw the editor state
2. **Study existing implementations**:
- `TerminalFrontend` - Simplest, good starting point
- `ImGuiFrontend` - More complex, shows GUI patterns
3. **Register in `main.cc`** to make it selectable.
### Modifying the PieceTable
The PieceTable is performance-critical. When making changes:
1. **Understand the piece list** - Each piece references a range in
either `original_` or `add_` buffer
2. **Maintain invariants**:
- `total_size_` must match sum of piece lengths
- Line index must be invalidated on content changes
- Version must increment on mutations
3. **Test thoroughly** - Use `test_piece_table.cc` random edit test as a
reference model
4. **Profile if needed** - Large file performance is a key goal
### Adding Syntax Highlighting
1. **Create a new highlighter** in `syntax/` directory:
- Inherit from `HighlighterEngine`
- Implement `HighlightLine()` method
2. **Register in `HighlighterRegistry`** (
`syntax/HighlighterRegistry.cc`)
3. **Add file extension mapping** in the registry
4. **Test with sample files** of that language
### Debugging Tips
- **Use the test frontend** - Write a test that reproduces the issue
- **Enable assertions** - Build in Debug mode
- **Check swap files** - Look in `/tmp/kte-swap-*` for recorded
operations
- **Print debugging** - Use `std::cerr` (stdout is used by ncurses)
- **GDB/LLDB** - Standard debuggers work fine with kte
## Getting Help
- **Read the code** - kte is designed to be understandable; follow the
data flow
- **Check existing tests** - Tests often show how to use APIs correctly
- **Look at git history** - See how similar features were implemented
- **Read design docs** - Check `docs/plans/` for design rationale
## Future Improvements
Areas where the codebase could be improved:
1. **Split Command.cc** - Break into logical groups (editing,
navigation, file ops, etc.)
2. **Complete Buffer::Line migration** - A few legacy editing functions
in Command.cc still use `Buffer::Rows()` directly (see lines 86-90
comment)
3. **Add more inline documentation** - Especially for complex algorithms
4. **Improve test coverage** - Add more edge case tests (current: 98
tests)
5. **Performance profiling** - Continue monitoring performance with
benchmark suite
6. **API documentation** - Consider adding Doxygen-style comments
---
Welcome aboard! Start small, read the code, and don't hesitate to ask
questions.

View File

@@ -0,0 +1,601 @@
# PieceTable Migration Plan
## Executive Summary
This document outlines the plan to remove GapBuffer support from kte and
migrate to using a **single PieceTable per Buffer**, rather than the
current vector-of-Lines architecture where each Line contains either a
GapBuffer or PieceTable.
## Current Architecture Analysis
### Text Storage
**Current Implementation:**
- `Buffer` contains `std::vector<Line> rows_`
- Each `Line` wraps an `AppendBuffer` (type alias)
- `AppendBuffer` is either `GapBuffer` (default) or `PieceTable` (via
`KTE_USE_PIECE_TABLE`)
- Each line is independently managed with its own buffer
- Operations are line-based with coordinate pairs (row, col)
**Key Files:**
- `Buffer.h/cc` - Buffer class with vector of Lines
- `AppendBuffer.h` - Type selector (GapBuffer vs PieceTable)
- `GapBuffer.h/cc` - Per-line gap buffer implementation
- `PieceTable.h/cc` - Per-line piece table implementation
- `UndoSystem.h/cc` - Records operations with (row, col, text)
- `UndoNode.h` - Undo operation types (Insert, Delete, Paste, Newline,
DeleteRow)
- `Command.cc` - High-level editing commands
### Current Buffer API
**Low-level editing operations (used by UndoSystem):**
```cpp
void insert_text(int row, int col, std::string_view text);
void delete_text(int row, int col, std::size_t len);
void split_line(int row, int col);
void join_lines(int row);
void insert_row(int row, std::string_view text);
void delete_row(int row);
```
**Line access:**
```cpp
std::vector<Line> &Rows();
const std::vector<Line> &Rows() const;
```
**Line API (Buffer::Line):**
```cpp
std::size_t size() const;
const char *Data() const;
char operator[](std::size_t i) const;
std::string substr(std::size_t pos, std::size_t len) const;
std::size_t find(const std::string &needle, std::size_t pos) const;
void erase(std::size_t pos, std::size_t len);
void insert(std::size_t pos, const std::string &seg);
Line &operator+=(const Line &other);
Line &operator+=(const std::string &s);
```
### Current PieceTable Limitations
The existing `PieceTable` class only supports:
- `Append(char/string)` - add to end
- `Prepend(char/string)` - add to beginning
- `Clear()` - empty the buffer
- `Data()` / `Size()` - access content (materializes on demand)
**Missing capabilities needed for buffer-wide storage:**
- Insert at arbitrary byte position
- Delete at arbitrary byte position
- Line indexing and line-based queries
- Position conversion (byte offset ↔ line/col)
- Efficient line boundary tracking
## Target Architecture
### Design Overview
**Single PieceTable per Buffer:**
- `Buffer` contains one `PieceTable content_` (replaces
`std::vector<Line> rows_`)
- Text stored as continuous byte sequence with `\n` as line separators
- Line index cached for efficient line-based operations
- All operations work on byte offsets internally
- Buffer provides line/column API as convenience layer
### Enhanced PieceTable Design
```cpp
class PieceTable {
public:
// Existing API (keep for compatibility if needed)
void Append(const char *s, std::size_t len);
void Prepend(const char *s, std::size_t len);
void Clear();
const char *Data() const;
std::size_t Size() const;
// NEW: Core byte-based editing operations
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
void Delete(std::size_t byte_offset, std::size_t len);
// NEW: Line-based queries
std::size_t LineCount() const;
std::string GetLine(std::size_t line_num) const;
std::pair<std::size_t, std::size_t> GetLineRange(std::size_t line_num) const; // (start, end) byte offsets
// NEW: Position conversion
std::pair<std::size_t, std::size_t> ByteOffsetToLineCol(std::size_t byte_offset) const;
std::size_t LineColToByteOffset(std::size_t row, std::size_t col) const;
// NEW: Substring extraction
std::string GetRange(std::size_t byte_offset, std::size_t len) const;
// NEW: Search support
std::size_t Find(const std::string &needle, std::size_t start_offset) const;
private:
// Existing members
std::string original_;
std::string add_;
std::vector<Piece> pieces_;
mutable std::string materialized_;
mutable bool dirty_;
std::size_t total_size_;
// NEW: Line index for efficient line operations
struct LineInfo {
std::size_t byte_offset; // absolute byte offset from buffer start
std::size_t piece_idx; // which piece contains line start
std::size_t offset_in_piece; // byte offset within that piece
};
mutable std::vector<LineInfo> line_index_;
mutable bool line_index_dirty_;
// NEW: Line index management
void RebuildLineIndex() const;
void InvalidateLineIndex();
};
```
### Buffer API Changes
```cpp
class Buffer {
public:
// NEW: Direct content access
PieceTable &Content() { return content_; }
const PieceTable &Content() const { return content_; }
// MODIFIED: Keep existing API but implement via PieceTable
void insert_text(int row, int col, std::string_view text);
void delete_text(int row, int col, std::size_t len);
void split_line(int row, int col);
void join_lines(int row);
void insert_row(int row, std::string_view text);
void delete_row(int row);
// MODIFIED: Line access - return line from PieceTable
std::size_t Nrows() const { return content_.LineCount(); }
std::string GetLine(std::size_t row) const { return content_.GetLine(row); }
// REMOVED: Rows() - no longer have vector of Lines
// std::vector<Line> &Rows(); // REMOVE
private:
// REMOVED: std::vector<Line> rows_;
// NEW: Single piece table for all content
PieceTable content_;
// Keep existing members
std::size_t curx_, cury_, rx_;
std::size_t nrows_; // cached from content_.LineCount()
std::size_t rowoffs_, coloffs_;
std::string filename_;
bool is_file_backed_;
bool dirty_;
bool read_only_;
bool mark_set_;
std::size_t mark_curx_, mark_cury_;
std::unique_ptr<UndoTree> undo_tree_;
std::unique_ptr<UndoSystem> undo_sys_;
std::uint64_t version_;
bool syntax_enabled_;
std::string filetype_;
std::unique_ptr<kte::HighlighterEngine> highlighter_;
kte::SwapRecorder *swap_rec_;
};
```
## Migration Phases
### Phase 1: Extend PieceTable (Foundation)
**Goal:** Add buffer-wide capabilities to PieceTable without breaking
existing per-line usage.
**Tasks:**
1. Add line indexing infrastructure to PieceTable
- Add `LineInfo` struct and `line_index_` member
- Implement `RebuildLineIndex()` that scans pieces for '\n'
characters
- Implement `InvalidateLineIndex()` called by Insert/Delete
2. Implement core byte-based operations
- `Insert(byte_offset, text, len)` - split piece at offset, insert
new piece
- `Delete(byte_offset, len)` - split pieces, remove/truncate as
needed
3. Implement line-based query methods
- `LineCount()` - return line_index_.size()
- `GetLine(line_num)` - extract text between line boundaries
- `GetLineRange(line_num)` - return (start, end) byte offsets
4. Implement position conversion
- `ByteOffsetToLineCol(offset)` - binary search in line_index_
- `LineColToByteOffset(row, col)` - lookup line start, add col
5. Implement utility methods
- `GetRange(offset, len)` - extract substring
- `Find(needle, start)` - search across pieces
**Testing:**
- Write unit tests for new PieceTable methods
- Test with multi-line content
- Verify line index correctness after edits
- Benchmark performance vs current line-based approach
**Estimated Effort:** 3-5 days
### Phase 2: Create Buffer Adapter Layer (Compatibility)
**Goal:** Create compatibility layer in Buffer to use PieceTable while
maintaining existing API.
**Tasks:**
1. Add `PieceTable content_` member to Buffer (alongside existing
`rows_`)
2. Add compilation flag `KTE_USE_BUFFER_PIECE_TABLE` (like existing
`KTE_USE_PIECE_TABLE`)
3. Implement Buffer methods to delegate to content_:
```cpp
#ifdef KTE_USE_BUFFER_PIECE_TABLE
void insert_text(int row, int col, std::string_view text) {
std::size_t offset = content_.LineColToByteOffset(row, col);
content_.Insert(offset, text.data(), text.size());
}
// ... similar for other methods
#else
// Existing line-based implementation
#endif
```
4. Update file I/O to work with PieceTable
- `OpenFromFile()` - load into content_ instead of rows_
- `Save()` - serialize content_ instead of rows_
5. Update `AsString()` to materialize from content_
**Testing:**
- Run existing buffer correctness tests with new flag
- Verify undo/redo still works
- Test file I/O round-tripping
- Test with existing command operations
**Estimated Effort:** 3-4 days
### Phase 3: Migrate Command Layer (High-level Operations)
**Goal:** Update commands that directly access Rows() to use new API.
**Tasks:**
1. Audit all usages of `buf.Rows()` in Command.cc
2. Refactor helper functions:
- `extract_region_text()` - use content_.GetRange()
- `delete_region()` - convert to byte offsets, use content_.Delete()
- `insert_text_at_cursor()` - convert position, use content_
.Insert()
3. Update commands that iterate over lines:
- Use `buf.GetLine(i)` instead of `buf.Rows()[i]`
- Update line count queries to use `buf.Nrows()`
4. Update search/replace operations:
- Modify `search_compute_matches()` to work with GetLine()
- Update regex matching to work line-by-line or use content directly
**Testing:**
- Test all editing commands (insert, delete, newline, backspace)
- Test region operations (mark, copy, kill)
- Test search and replace
- Test word navigation and deletion
- Run through common editing workflows
**Estimated Effort:** 4-6 days
### Phase 4: Update Renderer and Frontend (Display)
**Goal:** Ensure all renderers work with new Buffer structure.
**Tasks:**
1. Audit renderer implementations:
- `TerminalRenderer.cc`
- `ImGuiRenderer.cc`
- `QtRenderer.cc`
- `TestRenderer.cc`
2. Update line access patterns:
- Replace `buf.Rows()[y]` with `buf.GetLine(y)`
- Handle string return instead of Line object
3. Update syntax highlighting integration:
- Ensure HighlighterEngine works with GetLine()
- Update any line-based caching
**Testing:**
- Test rendering in terminal
- Test ImGui frontend (if enabled)
- Test Qt frontend (if enabled)
- Verify syntax highlighting displays correctly
- Test scrolling and viewport updates
**Estimated Effort:** 2-3 days
### Phase 5: Remove Old Infrastructure (Cleanup) ✅ COMPLETED
**Goal:** Remove GapBuffer, AppendBuffer, and Line class completely.
**Status:** Completed on 2025-12-05
**Tasks:**
1. ✅ Remove conditional compilation:
- Removed `#ifdef KTE_USE_BUFFER_PIECE_TABLE` (PieceTable is now the
only way)
- Removed `#ifdef KTE_USE_PIECE_TABLE`
- Removed `AppendBuffer.h`
2. ✅ Delete obsolete code:
- Deleted `GapBuffer.h/cc`
- Line class now uses PieceTable internally (kept for API
compatibility)
- `rows_` kept as mutable cache rebuilt from `content_` PieceTable
3. ✅ Update CMakeLists.txt:
- Removed GapBuffer from sources
- Removed AppendBuffer.h from headers
- Removed KTE_USE_PIECE_TABLE and KTE_USE_BUFFER_PIECE_TABLE options
4. ✅ Clean up includes and dependencies
5. ✅ Update documentation
**Testing:**
- Full regression test suite
- Verify clean compilation
- Check for any lingering references
**Estimated Effort:** 1-2 days
### Phase 6: Performance Optimization (Polish)
**Goal:** Optimize the new implementation for real-world usage.
**Tasks:**
1. Profile common operations:
- Measure line access patterns
- Identify hot paths in editing
- Benchmark against old implementation
2. Optimize line index:
- Consider incremental updates instead of full rebuild
- Tune rebuild threshold
- Cache frequently accessed lines
3. Optimize piece table:
- Tune piece coalescing heuristics
- Consider piece count limits and consolidation
4. Memory optimization:
- Review materialization frequency
- Consider lazy materialization strategies
- Profile memory usage on large files
**Testing:**
- Benchmark suite with various file sizes
- Memory profiling
- Real-world usage testing
**Estimated Effort:** 3-5 days
## Files Requiring Modification
### Core Files (Must Change)
- `PieceTable.h/cc` - Add new methods (Phase 1)
- `Buffer.h/cc` - Replace rows_ with content_ (Phase 2)
- `Command.cc` - Update line access (Phase 3)
- `UndoSystem.cc` - May need updates for new Buffer API
### Renderer Files (Will Change)
- `TerminalRenderer.cc` - Update line access (Phase 4)
- `ImGuiRenderer.cc` - Update line access (Phase 4)
- `QtRenderer.cc` - Update line access (Phase 4)
- `TestRenderer.cc` - Update line access (Phase 4)
### Files Removed (Phase 5 - Completed)
- `GapBuffer.h/cc` - ✅ Deleted
- `AppendBuffer.h` - ✅ Deleted
- `test_buffer_correctness.cc` - ✅ Deleted (obsolete GapBuffer
comparison test)
- `bench/BufferBench.cc` - ✅ Deleted (obsolete GapBuffer benchmarks)
- `bench/PerformanceSuite.cc` - ✅ Deleted (obsolete GapBuffer
benchmarks)
- `Buffer::Line` class - ✅ Updated to use PieceTable internally (kept
for API compatibility)
### Build Files
- `CMakeLists.txt` - Update sources (Phase 5)
### Documentation
- `README.md` - Update architecture notes
- `docs/` - Update any architectural documentation
- `REWRITE.md` - Note C++ now matches Rust design
## Testing Strategy
### Unit Tests
- **PieceTable Tests:** New file `test_piece_table.cc`
- Test Insert/Delete at various positions
- Test line indexing correctness
- Test position conversion
- Test with edge cases (empty, single line, large files)
- **Buffer Tests:** Extend `test_buffer_correctness.cc`
- Test new Buffer API with PieceTable backend
- Test file I/O round-tripping
- Test multi-line operations
### Integration Tests
- **Undo Tests:** `test_undo.cc` should still pass
- Verify undo/redo across all operation types
- Test undo tree navigation
- **Search Tests:** `test_search_correctness.cc` should still pass
- Verify search across multiple lines
- Test regex search
### Manual Testing
- Load and edit large files (>10MB)
- Perform complex editing sequences
- Test all keybindings and commands
- Verify syntax highlighting
- Test crash recovery (swap files)
### Regression Testing
- All existing tests must pass with new implementation
- No observable behavior changes for users
- Performance should be comparable or better
## Risk Assessment
### High Risk
- **Undo System Integration:** Undo records operations with
row/col/text. Need to ensure compatibility or refactor.
- *Mitigation:* Carefully preserve undo semantics, extensive testing
- **Performance Regression:** Line index rebuilding could be expensive
on large files.
- *Mitigation:* Profile early, optimize incrementally, consider
caching strategies
### Medium Risk
- **Syntax Highlighting:** Highlighters may depend on line-based access
patterns.
- *Mitigation:* Review highlighter integration, test thoroughly
- **Renderer Updates:** Multiple renderers need updating, risk of
inconsistency.
- *Mitigation:* Update all renderers in same phase, test each
### Low Risk
- **Search/Replace:** Should work naturally with new GetLine() API.
- *Mitigation:* Test thoroughly with existing test suite
## Success Criteria
### Functional Requirements
- ✓ All existing tests pass
- ✓ All commands work identically to before
- ✓ File I/O works correctly
- ✓ Undo/redo functionality preserved
- ✓ Syntax highlighting works
- ✓ All frontends (terminal, ImGui, Qt) work
### Code Quality
- ✓ GapBuffer completely removed
- ✓ No conditional compilation for buffer type
- ✓ Clean, maintainable code
- ✓ Good test coverage for new PieceTable methods
### Performance
- ✓ Editing operations at least as fast as current
- ✓ Line access within 2x of current performance
- ✓ Memory usage reasonable (no excessive materialization)
- ✓ Large file handling acceptable (tested up to 100MB)
## Timeline Estimate
| Phase | Duration | Dependencies |
|----------------------------|----------------|--------------|
| Phase 1: Extend PieceTable | 3-5 days | None |
| Phase 2: Buffer Adapter | 3-4 days | Phase 1 |
| Phase 3: Command Layer | 4-6 days | Phase 2 |
| Phase 4: Renderer Updates | 2-3 days | Phase 3 |
| Phase 5: Cleanup | 1-2 days | Phase 4 |
| Phase 6: Optimization | 3-5 days | Phase 5 |
| **Total** | **16-25 days** | |
**Note:** Timeline assumes one developer working full-time. Actual
duration may vary based on:
- Unforeseen integration issues
- Performance optimization needs
- Testing thoroughness
- Code review iterations
## Alternatives Considered
### Alternative 1: Keep Line-based but unify GapBuffer/PieceTable
- Keep vector of Lines, but make each Line always use PieceTable
- Remove GapBuffer, remove AppendBuffer selector
- **Pros:** Smaller change, less risk
- **Cons:** Doesn't achieve architectural goal, still have per-line
overhead
### Alternative 2: Hybrid approach
- Use PieceTable for buffer, but maintain materialized Line objects as
cache
- **Pros:** Easier migration, maintains some compatibility
- **Cons:** Complex dual representation, cache invalidation issues
### Alternative 3: Complete rewrite
- Follow REWRITE.md exactly, implement in Rust
- **Pros:** Modern language, better architecture
- **Cons:** Much larger effort, different project
## Recommendation
**Proceed with planned migration** (single PieceTable per Buffer)
because:
1. Aligns with long-term architecture vision (REWRITE.md)
2. Removes unnecessary per-line buffer overhead
3. Simplifies codebase (one text representation)
4. Enables future optimizations (better undo, swap files, etc.)
5. Reasonable effort (16-25 days) for significant improvement
**Suggested Approach:**
- Start with Phase 1 (extend PieceTable) in isolated branch
- Thoroughly test new PieceTable functionality
- Proceed incrementally through phases
- Maintain working editor at end of each phase
- Merge to main after Phase 4 (before cleanup) to get testing
- Complete Phase 5-6 based on feedback
## References
- `REWRITE.md` - Rust architecture specification (lines 54-157)
- Current buffer implementation: `Buffer.h/cc`
- Current piece table: `PieceTable.h/cc`
- Undo system: `UndoSystem.h/cc`, `UndoNode.h`
- Commands: `Command.cc`

View File

@@ -12,11 +12,14 @@ Goals
Model overview
--------------
Per open buffer, maintain a sidecar swap journal next to the file:
Per open buffer, maintain a swap journal in a per-user state directory:
- Path: `.<basename>.kte.swp` in the same directory as the file (for
unnamed/unsaved buffers, use a persession temp dir like
`$TMPDIR/kte/` with a random UUID).
- Path: `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp` (or
`~/.local/state/kte/swap/...`)
where `<encoded-path>` is the file path with separators replaced (e.g.
`/home/kyle/tmp/test.txt``home!kyle!tmp!test.txt.swp`).
Unnamed/unsaved
buffers use a unique `unnamed-<pid>-<counter>.swp` name.
- Format: appendonly journal of editing operations with periodic
checkpoints.
- Crash safety: only append, fsync as per policy; checkpoint via
@@ -84,7 +87,7 @@ Recovery flow
On opening a file:
1. Detect swap sidecar `.<basename>.kte.swp`.
1. Detect swap journal `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp`.
2. Validate header, iterate records verifying CRCs.
3. Compare recorded original file identity against actual file; if
mismatch, warn user but allow recovery (content wins).
@@ -98,7 +101,7 @@ Stability & corruption mitigation
---------------------------------
- Appendonly with perrecord CRC32 guards against torn writes.
- Atomic checkpoint rotation: write `.<basename>.kte.swp.tmp`, fsync,
- Atomic checkpoint rotation: write `<encoded-path>.swp.tmp`, fsync,
then rename over old `.swp`.
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
64128 MB). Compaction creates a fresh file with a single checkpoint.
@@ -117,8 +120,8 @@ Security considerations
Interoperability & UX
---------------------
- Use a distinctive extension `.kte.swp` to avoid conflicts with other
editors.
- Use a distinctive directory (`$XDG_STATE_HOME/kte/swap`) to avoid
conflicts with other editors `.swp` conventions.
- Status bar indicator when swap is active; commands to purge/compact.
- On save: do not delete swap immediately; keep until the buffer is
clean and idle for a short grace period (allows undo of accidental

163
docs/plans/test-plan.md Normal file
View File

@@ -0,0 +1,163 @@
### Unit testing plan (headless, no interactive frontend)
#### Principles
- Headless-only: exercise core components directly (`PieceTable`, `Buffer`, `UndoSystem`, `OptimizedSearch`, and minimal `Editor` flows) without starting `kte` or `kge`.
- Deterministic and fast: avoid timers, GUI, environment-specific behavior; prefer in-memory operations and temporary files.
- Regression-focused: encode prior failures (save/newline mismatch, legacy `rows_` writes) as explicit tests to prevent recurrences.
#### Harness and execution
- Single binary: use target `kte_tests` (already present) to compile and run all tests under `tests/` with the minimal in-tree framework (`tests/Test.h`, `tests/TestRunner.cc`).
- No GUI/ncurses deps: link only engine sources (PieceTable/Buffer/Undo/Search/Undo* and syntax minimal set), not frontends.
- How to build/run:
- Debug profile:
```
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-debug -DBUILD_TESTS=ON && \
cmake --build /Users/kyle/src/kte/cmake-build-debug --target kte_tests && \
/Users/kyle/src/kte/cmake-build-debug/kte_tests
```
- Release profile:
```
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-release -DBUILD_TESTS=ON && \
cmake --build /Users/kyle/src/kte/cmake-build-release --target kte_tests && \
/Users/kyle/src/kte/cmake-build-release/kte_tests
```
---
### Test catalog (summary table)
The table below catalogs all unit tests defined in this plan. It is headless-only and maps directly to the suites AH described later. “Implemented” reflects current coverage in `kte_tests`.
| Suite | ID | Name | Description (1line) | Headless | Implemented |
|:-----:|:---:|:------------------------------------------|:-------------------------------------------------------------------------------------|:--------:|:-----------:|
| A | 1 | SaveAs then Save (append) | New buffer → write two lines → `SaveAs` → append → `Save`; verify exact bytes. | Yes | ✓ |
| A | 2 | Open existing then Save | Open seeded file, append, `Save`; verify overwrite bytes. | Yes | ✓ |
| A | 3 | Open non-existent then SaveAs | Start from non-existent path, insert `hello, world\n`, `SaveAs`; verify bytes. | Yes | ✓ |
| A | 4 | Trailing newline preservation | Verify saving preserves presence/absence of final `\n`. | Yes | Planned |
| A | 5 | Empty buffer saves | Empty → `SaveAs` → 0 bytes; then insert `\n` → `Save` → 1 byte. | Yes | Planned |
| A | 6 | Large file streaming | 14 MiB with periodic newlines; size and content integrity. | Yes | Planned |
| A | 7 | Tilde expansion | `SaveAs` with `~/...`; re-open to confirm path/content. | Yes | Planned |
| A | 8 | Error propagation | Save to unwritable path → expect failure and error message. | Yes | Planned |
| B | 1 | Insert/Delete LineCount | Basic inserts/deletes and line counting sanity. | Yes | ✓ |
| B | 2 | Line/Col conversions | `LineColToByteOffset` and reverse around boundaries. | Yes | ✓ |
| B | 3 | Delete spanning newlines | Delete ranges that cross line breaks; verify bytes/lines. | Yes | Planned |
| B | 4 | Split/Join equivalence | `split_line` followed by `join_lines` yields original bytes. | Yes | Planned |
| B | 5 | Stream vs Data equivalence | `WriteToStream` matches `GetRange`/`Data()` after edits. | Yes | Planned |
| B | 6 | UTF8 bytes stability | Multibyte sequences behave correctly (byte-based ops). | Yes | Planned |
| C | 1 | insert_text/delete_text | Edits at start/middle/end; `Rows()` mirrors PieceTable. | Yes | Planned |
| C | 2 | split_line/join_lines | Effects and snapshots across multiple positions. | Yes | Planned |
| C | 3 | insert_row/delete_row | Replace paragraph by row ops; verify bytes/linecount. | Yes | Planned |
| C | 4 | Cache invalidation | After each mutation, `Rows()` matches `LineCount()`. | Yes | Planned |
| D | 1 | Grouped insert undo | Contiguous typing undone/redone as a group. | Yes | Planned |
| D | 2 | Delete/Newline undo/redo | Backspace/Delete and Newline transitions across undo/redo. | Yes | Planned |
| D | 3 | Mark saved & dirty | Dirty/save markers interact correctly with undo/redo. | Yes | Planned |
| E | 1 | Search parity basic | `OptimizedSearch::find_all` vs `std::string` reference. | Yes | ✓ |
| E | 2 | Large text search | ~1 MiB random text/patterns parity. | Yes | Planned |
| F | 1 | Editor open & reload | Open via `Editor`, modify, reload, verify on-disk bytes. | Yes | Planned |
| F | 2 | Read-only toggle | Toggle and verify enforcement/behavior of saves. | Yes | Planned |
| F | 3 | Prompt lifecycle | Start/Accept/Cancel prompt doesnt corrupt state. | Yes | Planned |
| G | 1 | Saved only newline regression | Insert text + newline; `Save` includes both bytes. | Yes | Planned |
| G | 2 | Backspace crash regression | PieceTable-backed delete/join path remains stable. | Yes | Planned |
| G | 3 | Overwrite-confirm path | Saving over existing path succeeds and is correct. | Yes | Planned |
| H | 1 | Many small edits | 10k small edits; final bytes correct within time bounds. | Yes | Planned |
| H | 2 | Consolidation equivalence | After many edits, stream vs data produce identical bytes. | Yes | Planned |
Legend: Implemented = ✓, Planned = to be added per Coverage roadmap.
### Test suites and cases
#### A) Filesystem I/O via Buffer
1) SaveAs then Save (append)
- New buffer → `insert_text` two lines (explicit `\n`) → `SaveAs(tmp)` → insert a third line → `Save()`.
- Assert file bytes equal exact expected string.
2) Open existing then Save
- Seed a file on disk; `OpenFromFile(path)` → append line → `Save()`.
- Assert file bytes updated exactly.
3) Open non-existent then SaveAs
- `OpenFromFile(nonexistent)` → assert `IsFileBacked()==false` → insert `"hello, world\n"` → `SaveAs(path)`.
- Read back exact bytes.
4) Trailing newline preservation
- Case (a) last line without `\n`; (b) last line with `\n` → save and verify bytes unchanged.
5) Empty buffer saves
- `SaveAs(tmp)` on empty buffer → 0-byte file. Then insert `"\n"` and `Save()` → 1-byte file.
6) Large file streaming
- Insert ~14 MiB of data with periodic newlines. `SaveAs` then `Save`; verify size matches `content_.Size()` and bytes integrity.
7) Path normalization and tilde expansion
- `SaveAs("~/.../file.txt")` → verify path expands to `$HOME` and file content round-trips with `OpenFromFile`.
8) Error propagation (guarded)
- Attempt save into a non-writable path; expect `Save/SaveAs` returns false with non-empty error. Mark as skipped in environments lacking such path.
#### B) PieceTable semantics
1) Line counting and deletion across lines
- Insert `"abc\n123\nxyz"` → 3 lines; delete middle line range → 2 lines; validate `GetLine` contents.
2) Position conversions
- Validate `LineColToByteOffset` and `ByteOffsetToLineCol` at start/end of lines and EOF, especially around `\n`.
3) Delete spanning newlines
- Remove a range that crosses line boundaries; verify resulting bytes, `LineCount` and line contents.
4) Split/join equivalence
- Split at various columns; then join adjacent lines; verify bytes equal original.
5) WriteToStream vs materialized `Data()`
- After multiple inserts/deletes (without forcing `Data()`), stream to `std::ostringstream`; compare with `GetRange(0, Size())`, then call `Data()` and re-compare.
6) UTF-8 bytes stability
- Insert multibyte sequences (e.g., `"héllo"`, `"中文"`, emoji) as raw bytes; ensure line counting and conversions behave (byte-based API; no crashes/corruption).
#### C) Buffer editing helpers and rows cache correctness
1) `insert_text`/`delete_text`
- Apply at start/middle/end of lines; immediately call `Rows()` and validate contents/lengths mirror PieceTable.
2) `split_line` and `join_lines`
- Verify content effects and `Rows()` snapshots for multiple positions and consecutive operations.
3) `insert_row`/`delete_row`
- Replace a paragraph by deleting N rows then inserting N rows; verify bytes and `LineCount`.
4) Cache invalidation
- After each mutation, fetch `Rows()`; assert `Nrows() == content.LineCount()` and no stale data remains.
#### D) UndoSystem semantics
1) Grouped contiguous insert undo
- Emulate typing at a single location via repeated `insert_text`; one `undo()` should remove the whole run; `redo()` restores it.
2) Delete/newline undo/redo
- Simulate backspace/delete (`delete_text` and `join_lines`) and newline (`split_line`); verify content transitions across `undo()`/`redo()`.
3) Mark saved and dirty flag
- After successful save, call `UndoSystem::mark_saved()` (via existing pathways) and ensure dirty state pairing behaves as intended (at least: `SetDirty(false)` plus save does not break undo/redo).
#### E) Search algorithms
1) Parity with `std::string::find`
- Use `OptimizedSearch::find_all` across edge cases (empty needle/text, overlaps like `"aaaaa"` vs `"aa"`, Unicode byte sequences). Compare to reference implementation.
2) Large text
- Random ASCII text ~1 MiB; random patterns; results match reference.
#### F) Editor non-interactive flows (no frontend)
1) Open and reload
- Through `Editor`, open file; modify the underlying `Buffer` directly; invoke reload (`Buffer::OpenFromFile` or `cmd_reload_buffer` if you bring `Command.cc` into the test target). Verify bytes match the on-disk file after reload.
2) Read-only toggle
- Toggle `Buffer::ToggleReadOnly()`; confirm flag value changes and that subsequent saves still execute when not read-only (or, if enforcement exists, that mutations are appropriately restricted).
3) Prompt lifecycle (headless)
- Exercise `StartPrompt` → `AcceptPrompt` → `CancelPrompt`; ensure state resets and does not corrupt buffer/editor state.
#### G) Regression tests for reported bugs
1) “Saved only newline”
- Build buffer content via `insert_text` followed by `split_line` for newline; `Save` then validate bytes include both the text and newline.
2) Backspace crash path
- Mimic backspace behavior using PieceTable-backed helpers (`delete_text`/`join_lines`); ensure no dependency on legacy `rows_` mutation and no memory issues.
3) Overwrite-confirm path behavior
- Start with non-file-backed buffer named to collide with an existing file; perform `SaveAs(existing_path)` and assert success and correctness on disk (unit test bypasses interactive confirm, validating underlying write path).
#### H) Performance/stress sanity
1) Many small edits
- 10k single-char inserts and interleaved deletes; assert final bytes; keep within conservative runtime bounds.
2) Consolidation heuristics
- After many edits, call both `WriteToStream` and `Data()` and verify identical bytes.
---
### Coverage roadmap
- Phase 1 (already implemented and passing):
- Buffer I/O basics (A.1A.3), PieceTable basics (B.1B.2), Search parity (E.1).
- Phase 2 (add next):
- Buffer I/O edge cases (A.4A.7), deeper PieceTable ops (B.3B.6), Buffer helpers and cache (C.1C.4), Undo semantics (D.1D.2), Regression set (G.1G.3).
- Phase 3:
- Editor flows (F.1F.3), performance/stress (H.1H.2), and optional integration of `Command.cc` into the test target to exercise non-interactive command execution paths directly.
### Notes
- Use per-test temp files under the repo root or a unique temp directory; ensure cleanup after assertions.
- For HOME-dependent tests (tilde expansion), set `HOME` in the test process if not present or skip with a clear message.
- On macOS Debug, a benign allocator warning may appear; rely on process exit code for pass/fail.

237
docs/swap.md Normal file
View File

@@ -0,0 +1,237 @@
# Swap journaling (crash recovery)
kte has a small “swap” system: an append-only per-buffer journal that
records edits so they can be replayed after a crash.
This document describes the **currently implemented** swap system (stage
2), as implemented in `Swap.h` / `Swap.cc`.
## What it is (and what it is not)
- The swap file is a **journal of editing operations** (currently
inserts, deletes, and periodic full-buffer checkpoints).
- It is written by a **single background writer thread** owned by
`kte::SwapManager`.
- It is intended for **best-effort crash recovery**.
kte automatically deletes/resets swap journals after a **clean save**
and when
closing a clean buffer, so old swap files do not accumulate under normal
workflows. A best-effort prune also runs at startup to remove very old
swap
files.
## Automatic recovery prompt
When kte opens a file-backed buffer, it checks whether a corresponding
swap journal exists.
- If a swap file exists and replay succeeds *and* produces different
content than what is currently on disk, kte prompts:
```text
Recover swap edits for <path>? (y/N, C-g cancel)
```
- `y`: open the file and apply swap replay (buffer becomes dirty)
- `Enter` (default) / any non-`y`: delete the swap file (
best-effort)
and open the file normally
- `C-g`: cancel opening the file
- If a swap file exists but is unreadable/corrupt, kte prompts:
```text
Swap file unreadable for <path>. Delete it? (y/N, C-g cancel)
```
- `y`: delete the swap file (best-effort) and open the file normally
- `Enter` (default): keep the swap file and open the file normally
- `C-g`: cancel opening the file
## Where swap files live
Swap files are stored under an XDG-style per-user *state* directory:
- If `XDG_STATE_HOME` is set and non-empty:
- `$XDG_STATE_HOME/kte/swap/…`
- Otherwise, if `HOME` is set:
- `~/.local/state/kte/swap/…`
- Last resort fallback:
- `<system-temp>/kte/state/kte/swap/…` (via
`std::filesystem::temp_directory_path()`)
Swap files are always created with permissions `0600`.
### Swap file naming
For file-backed buffers, the swap filename is derived from the buffers
path:
1. Take a canonical-ish path key (`std::filesystem::weakly_canonical`,
else `absolute`, else the raw `Buffer::Filename()`).
2. Encode it so its human-identifiable:
- Strip one leading path separator (`/` or `\\`)
- Replace path separators (`/` and `\\`) with `!`
- Append `.swp`
Example:
```text
/home/kyle/tmp/test.txt -> home!kyle!tmp!test.txt.swp
```
If the resulting name would be long (over ~200 characters), kte falls
back to a shorter stable name:
```text
<basename>.<fnv1a64(path-key-as-hex)>.swp
```
For unnamed/unsaved buffers, kte uses:
```text
unnamed-<pid>-<counter>.swp
```
## Lifecycle (when swap is written)
`kte::SwapManager` is owned by `Editor` (see `Editor.cc`). Buffers are
attached for journaling when they are added/opened.
- `SwapManager::Attach(Buffer*)` starts tracking a buffer and
establishes its swap path.
- `Buffer` emits swap events from its low-level edit APIs:
- `Buffer::insert_text()` calls `SwapRecorder::OnInsert()`
- `Buffer::delete_text()` calls `SwapRecorder::OnDelete()`
- `Buffer::split_line()` / `join_lines()` are represented as
insert/delete of `\n` (they do **not** emit `SPLIT`/`JOIN` records
in stage 1).
- `SwapManager::Detach(Buffer*)` flushes queued records, `fsync()`s, and
closes the journal.
- On `Save As` / filename changes,
`SwapManager::NotifyFilenameChanged(Buffer&)` closes the existing
journal and switches to a new path.
- Note: the old swap file is currently left on disk (no
cleanup/rotation yet).
## Durability and performance
Swap writing is best-effort and asynchronous:
- Records are queued from the UI/editing thread(s).
- A background writer thread wakes at least every
`SwapConfig::flush_interval_ms` (default `200ms`) to write any queued
records.
- `fsync()` is throttled to at most once per
`SwapConfig::fsync_interval_ms` (default `1000ms`) per open swap file.
- `SwapManager::Flush()` blocks until the queue is fully written; it is
primarily used by tests and shutdown paths.
If a crash happens while writing, the swap file may end with a partial
record. Replay detects truncation/CRC mismatch and fails safely.
## On-disk format (v1)
The file is:
1. A fixed-size 64-byte header
2. Followed by a stream of records
All multi-byte integers in the swap file are **little-endian**.
### Header (64 bytes)
Layout (stage 1):
- `magic` (8 bytes): `KTE_SWP\0`
- `version` (`u32`): currently `1`
- `flags` (`u32`): currently `0`
- `created_time` (`u64`): Unix seconds
- remaining bytes are reserved/padding (currently zeroed)
### Record framing
Each record is:
```text
[type: u8][len: u24][payload: len bytes][crc32: u32]
```
- `len` is a 24-bit little-endian length of the payload (`0..0xFFFFFF`).
- `crc32` is computed over the 4-byte record header (`type + len`)
followed by the payload bytes.
### Record types
Type codes are defined in `SwapRecType` (`Swap.h`). Stage 1 primarily
emits:
- `INS` (`1`): insert bytes at `(row, col)`
- `DEL` (`2`): delete `len` bytes at `(row, col)`
Other type codes exist for forward compatibility (`SPLIT`, `JOIN`,
`META`, `CHKPT`), but are not produced by the current `SwapRecorder`
interface.
### Payload encoding (v1)
Every payload starts with:
```text
[encver: u8]
```
Currently `encver` must be `1`.
#### `INS` payload (encver = 1)
```text
[encver: u8 = 1]
[row: u32]
[col: u32]
[nbytes:u32]
[bytes: nbytes]
```
#### `DEL` payload (encver = 1)
```text
[encver: u8 = 1]
[row: u32]
[col: u32]
[len: u32]
```
`row`/`col` are 0-based and are interpreted the same way as
`Buffer::insert_text()` / `Buffer::delete_text()`.
## Replay / recovery
Swap replay is implemented as a low-level API:
-
`bool kte::SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)`
Behavior:
- The caller supplies an **already-open** `Buffer` (typically loaded
from the on-disk file) and a swap path.
- `ReplayFile()` validates header magic/version, then iterates records.
- On a truncated file or CRC mismatch, it returns `false` and sets
`err`.
- On unknown record types, it ignores them (forward compatibility).
- On failure, the buffer may have had a prefix of records applied;
callers should treat this as “recovery failed”.
Important: if the buffer is currently attached to a `SwapManager`, you
should suspend/disable recording during replay (or detach first),
otherwise replayed edits would be re-journaled.
## Tests
Swap behavior and format are validated by unit tests:
- `tests/test_swap_writer.cc` (header, permissions, record CRC framing)
- `tests/test_swap_replay.cc` (record replay and truncation handling)

View File

@@ -13,9 +13,9 @@
packages = eachSystem (system: rec {
default = kte;
full = kge;
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; };
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; };
qt = (pkgsFor system).callPackage ./default.nix { graphical-qt = true; }
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; graphical-qt = false; };
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; };
qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; };
});
};
}

5438
fonts/BerkeleyMono.h Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
#include "Font.h"
#include "IosevkaExtended.h"
#include "imgui.h"
@@ -8,16 +9,32 @@ Font::Load(const float size) const
{
const ImGuiIO &io = ImGui::GetIO();
io.Fonts->Clear();
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
ImFontConfig config;
config.MergeMode = false;
// Load Basic Latin + Latin Supplement
io.Fonts->AddFontFromMemoryCompressedTTF(
this->data_,
this->size_,
size);
size,
&config,
io.Fonts->GetGlyphRangesDefault());
if (!font) {
font = io.Fonts->AddFontDefault();
}
// Merge Greek and Mathematical symbols from IosevkaExtended as fallback
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,
&config,
extended_ranges);
(void) font;
io.Fonts->Build();
}
} // namespace kte::Fonts

View File

@@ -3,12 +3,12 @@
#include <string>
#include <utility>
#include "BrassMonoCode.h"
#include "BerkeleyMono.h"
namespace kte::Fonts {
// Provide default embedded font aliases used by GUIFrontend fallback loader
inline const unsigned int DefaultFontSize = BrassMonoCode::DefaultFontBoldCompressedSize;
inline const unsigned int *DefaultFontData = BrassMonoCode::DefaultFontBoldCompressedData;
inline const unsigned int DefaultFontSize = BerkeleyMono::DefaultFontRegularCompressedSize;
inline const unsigned int *DefaultFontData = BerkeleyMono::DefaultFontRegularCompressedData;
class Font {
public:

View File

@@ -1,5 +1,6 @@
#pragma once
#include "B612Mono.h"
#include "BerkeleyMono.h"
#include "BrassMono.h"
#include "BrassMonoCode.h"
#include "FiraCode.h"

View File

@@ -7,21 +7,41 @@ InstallDefaultFonts()
{
FontRegistry::Instance().Register(std::make_unique<Font>(
"default",
BrassMono::DefaultFontBoldCompressedData,
BrassMono::DefaultFontBoldCompressedSize
BerkeleyMono::DefaultFontBoldCompressedData,
BerkeleyMono::DefaultFontBoldCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"b612",
B612Mono::DefaultFontRegularCompressedData,
B612Mono::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"berkeley",
BerkeleyMono::DefaultFontRegularCompressedData,
BerkeleyMono::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"berkeley-bold",
BerkeleyMono::DefaultFontBoldCompressedData,
BerkeleyMono::DefaultFontBoldCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"brassmono",
BrassMono::DefaultFontRegularCompressedData,
BrassMono::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"brassmono-bold",
BrassMono::DefaultFontBoldCompressedData,
BrassMono::DefaultFontBoldCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"brassmonocode",
BrassMonoCode::DefaultFontRegularCompressedData,
BrassMonoCode::DefaultFontRegularCompressedSize
));
FontRegistry::Instance().Register(std::make_unique<Font>(
"brassmonocode-bold",
BrassMonoCode::DefaultFontBoldCompressedData,
BrassMonoCode::DefaultFontBoldCompressedSize
));

25741
fonts/Go.h

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

46
main.cc
View File

@@ -1,3 +1,4 @@
#include <clocale>
#include <cctype>
#include <cerrno>
#include <cstdio>
@@ -111,8 +112,10 @@ RunStressHighlighter(unsigned seconds)
int
main(int argc, const char *argv[])
main(int argc, char *argv[])
{
std::setlocale(LC_ALL, "");
Editor editor;
// CLI parsing using getopt_long
@@ -133,7 +136,7 @@ main(int argc, const char *argv[])
int opt;
int long_index = 0;
unsigned stress_seconds = 0;
while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "gthV", long_opts, &long_index)) != -1) {
while ((opt = getopt_long(argc, argv, "gthV", long_opts, &long_index)) != -1) {
switch (opt) {
case 'g':
req_gui = true;
@@ -192,13 +195,11 @@ main(int argc, const char *argv[])
} else if (req_term) {
use_gui = false;
} else {
// Default depends on build target: kge defaults to GUI, kte to terminal
// Default depends on build target: kge defaults to GUI, kte to terminal
#if defined(KTE_DEFAULT_GUI)
use_gui = true;
use_gui = true;
#else
use_gui = false;
use_gui = false;
#endif
}
#endif
@@ -206,6 +207,9 @@ main(int argc, const char *argv[])
// Open files passed on the CLI; support +N to jump to line N in the next file.
// If no files are provided, create an empty buffer.
if (optind < argc) {
// Seed a scratch buffer so the UI has something to show while deferred opens
// (and potential swap recovery prompts) are processed.
editor.AddBuffer(Buffer());
std::size_t pending_line = 0; // 0 = no pending line
for (int i = optind; i < argc; ++i) {
const char *arg = argv[i];
@@ -241,29 +245,9 @@ main(int argc, const char *argv[])
// Fall through: not a +number, treat as filename starting with '+'
}
std::string err;
const std::string path = arg;
if (!editor.OpenFile(path, err)) {
editor.SetStatus("open: " + err);
std::cerr << "kte: " << err << "\n";
} else if (pending_line > 0) {
// Apply pending +N to the just-opened (current) buffer
if (Buffer *b = editor.CurrentBuffer()) {
std::size_t nrows = b->Nrows();
std::size_t line = pending_line > 0 ? pending_line - 1 : 0;
// 1-based to 0-based
if (nrows > 0) {
if (line >= nrows)
line = nrows - 1;
} else {
line = 0;
}
b->SetCursor(0, line);
// Do not force viewport offsets here; the frontend/renderer
// will establish dimensions and normalize visibility on first draw.
}
pending_line = 0; // consumed
}
editor.RequestOpenFile(path, pending_line);
pending_line = 0; // consumed (if set)
}
// If we ended with a pending +N but no subsequent file, ignore it.
} else {
@@ -302,11 +286,13 @@ main(int argc, const char *argv[])
}
#endif
if (!fe->Init(editor)) {
if (!fe->Init(argc, argv, editor)) {
std::cerr << "kte: failed to initialize frontend" << std::endl;
return 1;
}
Execute(editor, CommandId::CenterOnCursor);
bool running = true;
while (running) {
fe->Step(editor, running);

View File

@@ -16,13 +16,18 @@ open .
cd ..
mkdir -p cmake-build-release-qt
cmake -S . -B cmake-build-release -DBUILD_GUI=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
cmake -S . -B cmake-build-release-qt -DBUILD_GUI=ON -DKTE_USE_QT=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
cd cmake-build-release-qt
make clean
rm -fr kge.app* kge-qt.app*
make
mv kge.app kge-qt.app
mv -f kge.app kge-qt.app
# Use the same Qt's macdeployqt as used for building; ensure it overwrites in-bundle paths
macdeployqt kge-qt.app -always-overwrite -verbose=3
# Run CMake BundleUtilities fixup to internalize non-Qt dylibs and rewrite install names
cmake -DAPP_BUNDLE="$(pwd)/kge-qt.app" -P "${PWD%/*}/cmake/fix_bundle.cmake"
zip -r kge-qt.app.zip kge-qt.app
sha256sum kge-qt.app.zip
open .

View File

@@ -60,11 +60,10 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return state;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
if (s.empty())
return state;
@@ -143,7 +142,7 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
bool closed = false;
while (j + 1 <= n) {
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
j += 2;
j += 2;
closed = true;
break;
}

View File

@@ -40,10 +40,9 @@ ErlangHighlighter::ErlangHighlighter()
void
ErlangHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
int i = 0;

View File

@@ -40,10 +40,9 @@ ForthHighlighter::ForthHighlighter()
void
ForthHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{
const auto &rows = buf.Rows();
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
int i = 0;

View File

@@ -46,10 +46,9 @@ GoHighlighter::GoHighlighter()
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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
int i = 0;
int bol = 0;
@@ -75,7 +74,7 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
bool closed = false;
while (j + 1 <= n) {
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
j += 2;
j += 2;
closed = true;
break;
}

View File

@@ -82,7 +82,7 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
// Only use cached state if it's for the current version and row still exists
if (r <= row - 1 && kv.second.version == buf_version) {
// Validate that the cached row index is still valid in the buffer
if (r >= 0 && static_cast<std::size_t>(r) < buf.Rows().size()) {
if (r >= 0 && static_cast<std::size_t>(r) < buf.Nrows()) {
if (r > best)
best = r;
}

View File

@@ -13,10 +13,9 @@ is_digit(char c)
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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
auto push = [&](int a, int b, TokenKind k) {
if (b > a)

View File

@@ -25,10 +25,9 @@ LispHighlighter::LispHighlighter()
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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
int i = 0;
int bol = 0;

View File

@@ -24,10 +24,9 @@ MarkdownHighlighter::HighlightLineStateful(const Buffer &buf, int row, const Lin
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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return state;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
// Reuse in_block_comment flag as "in fenced code" state.

View File

@@ -5,10 +5,9 @@ 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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
if (n <= 0)
return;

View File

@@ -50,10 +50,9 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS
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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return state;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""

View File

@@ -47,10 +47,9 @@ RustHighlighter::RustHighlighter()
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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
int i = 0;
while (i < n) {
@@ -72,7 +71,7 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
bool closed = false;
while (j + 1 <= n) {
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
j += 2;
j += 2;
closed = true;
break;
}

View File

@@ -14,10 +14,9 @@ push(std::vector<HighlightSpan> &out, int a, int b, TokenKind 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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
int i = 0;
// if first non-space is '#', whole line is comment

View File

@@ -47,10 +47,9 @@ SqlHighlighter::SqlHighlighter()
void
SqlHighlighter::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())
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return;
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size());
int i = 0;
@@ -75,7 +74,7 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
bool closed = false;
while (j + 1 <= n) {
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
j += 2;
j += 2;
closed = true;
break;
}

View File

@@ -44,7 +44,7 @@ CreateTreeSitterHighlighter(const char *filetype,
const void * (*get_lang)())
{
const auto *lang = reinterpret_cast<const TSLanguage *>(get_lang ? get_lang() : nullptr);
return std::make_unique < TreeSitterHighlighter > (lang, filetype ? std::string(filetype) : std::string());
return std::make_unique<TreeSitterHighlighter>(lang, filetype ? std::string(filetype) : std::string());
}
} // namespace kte

View File

@@ -1,102 +0,0 @@
// Simple buffer correctness tests comparing GapBuffer and PieceTable to std::string
#include <cassert>
#include <cstddef>
#include <cstring>
#include <random>
#include <string>
#include <vector>
#include "GapBuffer.h"
#include "PieceTable.h"
template<typename Buf>
static void
check_equals(const Buf &b, const std::string &ref)
{
assert(b.Size() == ref.size());
if (b.Size() == 0)
return;
const char *p = b.Data();
assert(p != nullptr);
assert(std::memcmp(p, ref.data(), ref.size()) == 0);
}
template<typename Buf>
static void
run_basic_cases()
{
// empty
{
Buf b;
std::string ref;
check_equals(b, ref);
}
// append chars
{
Buf b;
std::string ref;
for (int i = 0; i < 1000; ++i) {
b.AppendChar('a');
ref.push_back('a');
}
check_equals(b, ref);
}
// prepend chars
{
Buf b;
std::string ref;
for (int i = 0; i < 1000; ++i) {
b.PrependChar('b');
ref.insert(ref.begin(), 'b');
}
check_equals(b, ref);
}
// append/prepend strings
{
Buf b;
std::string ref;
const char *hello = "hello";
b.Append(hello, 5);
ref.append("hello");
b.Prepend(hello, 5);
ref.insert(0, "hello");
check_equals(b, ref);
}
// larger random blocks
{
std::mt19937 rng(42);
std::uniform_int_distribution<int> len_dist(0, 128);
std::uniform_int_distribution<int> coin(0, 1);
Buf b;
std::string ref;
for (int step = 0; step < 2000; ++step) {
int L = len_dist(rng);
std::string payload(L, '\0');
for (int i = 0; i < L; ++i)
payload[i] = static_cast<char>('a' + (i % 26));
if (coin(rng)) {
b.Append(payload.data(), payload.size());
ref.append(payload);
} else {
b.Prepend(payload.data(), payload.size());
ref.insert(0, payload);
}
}
check_equals(b, ref);
}
}
int
main()
{
run_basic_cases<GapBuffer>();
run_basic_cases<PieceTable>();
return 0;
}

View File

@@ -1,74 +0,0 @@
// Verify OptimizedSearch against std::string reference across patterns and sizes
#include <cassert>
#include <cstddef>
#include <random>
#include <string>
#include <vector>
#include "OptimizedSearch.h"
static std::vector<std::size_t>
ref_find_all(const std::string &text, const std::string &pat)
{
std::vector<std::size_t> res;
if (pat.empty())
return res;
std::size_t from = 0;
while (true) {
auto p = text.find(pat, from);
if (p == std::string::npos)
break;
res.push_back(p);
from = p + pat.size(); // non-overlapping
}
return res;
}
static void
run_case(std::size_t textLen, std::size_t patLen, unsigned seed)
{
std::mt19937 rng(seed);
std::uniform_int_distribution<int> dist('a', 'z');
std::string text(textLen, '\0');
for (auto &ch: text)
ch = static_cast<char>(dist(rng));
std::string pat(patLen, '\0');
for (auto &ch: pat)
ch = static_cast<char>(dist(rng));
// Guarantee at least one match when possible
if (textLen >= patLen && patLen > 0) {
std::size_t pos = textLen / 3;
if (pos + patLen <= text.size())
std::copy(pat.begin(), pat.end(), text.begin() + static_cast<long>(pos));
}
OptimizedSearch os;
auto got = os.find_all(text, pat, 0);
auto ref = ref_find_all(text, pat);
assert(got == ref);
}
int
main()
{
// Edge cases
run_case(0, 0, 1);
run_case(0, 1, 2);
run_case(1, 0, 3);
run_case(1, 1, 4);
// Various sizes
for (std::size_t t = 128; t <= 4096; t *= 2) {
for (std::size_t p = 1; p <= 64; p *= 2) {
run_case(t, p, static_cast<unsigned>(t + p));
}
}
// Larger random
run_case(100000, 16, 12345);
run_case(250000, 32, 67890);
return 0;
}

View File

@@ -1,338 +0,0 @@
#include <cassert>
#include <fstream>
#include <iostream>
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include "TestFrontend.h"
int
main()
{
// Install default commands
InstallDefaultCommands();
Editor editor;
TestFrontend frontend;
// Initialize frontend
if (!frontend.Init(editor)) {
std::cerr << "Failed to initialize frontend\n";
return 1;
}
// Create a temporary test file
std::string err;
const char *tmpfile = "/tmp/kte_test_undo.txt";
{
std::ofstream f(tmpfile);
if (!f) {
std::cerr << "Failed to create temp file\n";
return 1;
}
f << "\n"; // Write one newline so file isn't empty
f.close();
}
if (!editor.OpenFile(tmpfile, err)) {
std::cerr << "Failed to open test file: " << err << "\n";
return 1;
}
Buffer *buf = editor.CurrentBuffer();
assert(buf != nullptr);
// Initialize cursor to (0,0) explicitly
buf->SetCursor(0, 0);
std::cout << "test_undo: Testing undo/redo system\n";
std::cout << "====================================\n\n";
bool running = true;
// Test 1: Insert text and verify buffer contains expected text
std::cout << "Test 1: Insert text 'Hello'\n";
frontend.Input().QueueText("Hello");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
std::string line_after_insert = std::string(buf->Rows()[0]);
assert(line_after_insert == "Hello");
std::cout << " Buffer content: '" << line_after_insert << "'\n";
std::cout << " ✓ Text insertion verified\n\n";
// Test 2: Undo insertion - text should be removed
std::cout << "Test 2: Undo insertion\n";
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
std::string line_after_undo = std::string(buf->Rows()[0]);
assert(line_after_undo == "");
std::cout << " Buffer content: '" << line_after_undo << "'\n";
std::cout << " ✓ Undo successful - text removed\n\n";
// Test 3: Redo insertion - text should be restored
std::cout << "Test 3: Redo insertion\n";
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
std::string line_after_redo = std::string(buf->Rows()[0]);
assert(line_after_redo == "Hello");
std::cout << " Buffer content: '" << line_after_redo << "'\n";
std::cout << " ✓ Redo successful - text restored\n\n";
// Test 4: Branching behavior redo is discarded after new edits
std::cout << "Test 4: Branching behavior (redo discarded after new edits)\n";
// Reset to empty by undoing the last redo and the original insert, then reinsert 'abc'
// Ensure buffer is empty before starting this scenario
frontend.Input().QueueCommand(CommandId::Undo); // undo Hello
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Type a contiguous word 'abc' (single batch)
frontend.Input().QueueText("abc");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Undo once should remove the whole batch and leave empty
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Now type new text 'X' this should create a new branch and discard old redo chain
frontend.Input().QueueText("X");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Attempt Redo should be a no-op (redo branch was discarded by new edit)
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Undo and Redo along the new branch should still work
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
std::cout << " ✓ Redo discarded after new edit; new branch undo/redo works\n\n";
// Clear buffer state for next tests: undo to empty if needed
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Test 5: UTF-8 insertion and undo/redo round-trip
std::cout << "Test 5: UTF-8 insertion 'é漢' and undo/redo\n";
const std::string utf8_text = "é漢"; // multi-byte UTF-8 (2 bytes + 3 bytes)
frontend.Input().QueueText(utf8_text);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
// Undo should remove the entire contiguous insertion batch
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Redo restores it
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
std::cout << " ✓ UTF-8 insert round-trips with undo/redo\n\n";
// Clear for next test
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Test 6: Multi-line operations (newline split and join via backspace at BOL)
std::cout << "Test 6: Newline split and join via backspace at BOL\n";
// Insert "ab" then newline then "cd" → expect two lines
frontend.Input().QueueText("ab");
frontend.Input().QueueCommand(CommandId::Newline);
frontend.Input().QueueText("cd");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Split into two lines\n";
// Undo once should remove "cd" insertion leaving two lines ["ab", ""] or join depending on commit
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Current design batches typing on the second line; after undo, the second line should exist but be empty
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "");
// Undo the newline should rejoin to a single line "ab"
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "ab");
// Redo twice to get back to ["ab","cd"]
frontend.Input().QueueCommand(CommandId::Redo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Newline undo/redo round-trip\n";
// Now join via Backspace at beginning of second line
frontend.Input().QueueCommand(CommandId::MoveDown); // ensure we're on the second line
frontend.Input().QueueCommand(CommandId::MoveHome); // go to BOL on second line
frontend.Input().QueueCommand(CommandId::Backspace); // join with previous line
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Backspace at BOL joins lines\n";
// Undo/Redo the join
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Join undo/redo round-trip\n\n";
// Test 7: Typing batching a contiguous word undone in one step
std::cout << "Test 7: Typing batching (single undo removes whole word)\n";
// Clear current line first
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
// Type a word and verify one undo clears it
frontend.Input().QueueText("hello");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
std::cout << " ✓ Contiguous typing batched into single undo step\n\n";
// Test 8: Forward delete batching at a fixed anchor column
std::cout << "Test 8: Forward delete batching at fixed anchor (DeleteChar)\n";
// Prepare line content
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
frontend.Input().QueueText("abcdef");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Ensure cursor at anchor column 0
frontend.Input().QueueCommand(CommandId::MoveHome);
// Delete three chars at cursor; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::DeleteChar, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
// Single undo should restore the entire deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo should remove the same run again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
std::cout << " ✓ Forward delete batched and undo/redo round-trips\n\n";
// Test 9: Backspace batching with prepend rule (cursor moves left)
std::cout << "Test 9: Backspace batching with prepend rule\n";
// Restore to full string then backspace a run
frontend.Input().QueueCommand(CommandId::Undo); // bring back to "abcdef"
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Move to end and backspace three characters; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::MoveEnd);
frontend.Input().QueueCommand(CommandId::Backspace, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Single undo restores the deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo removes it again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
std::cout << " ✓ Backspace run batched and undo/redo round-trips\n\n";
frontend.Shutdown();
std::cout << "====================================\n";
std::cout << "All tests passed!\n";
return 0;
}

77
tests/Test.h Normal file
View File

@@ -0,0 +1,77 @@
// Minimal header-only unit test framework for kte
#pragma once
#include <functional>
#include <iostream>
#include <string>
#include <vector>
#include <chrono>
#include <sstream>
namespace ktet {
struct TestCase {
std::string name;
std::function<void()> fn;
};
inline std::vector<TestCase> &
registry()
{
static std::vector<TestCase> r;
return r;
}
struct Registrar {
Registrar(const char *name, std::function<void()> fn)
{
registry().push_back(TestCase{std::string(name), std::move(fn)});
}
};
// Assertions
struct AssertionFailure {
std::string msg;
};
inline void
expect(bool cond, const char *expr, const char *file, int line)
{
if (!cond) {
std::cerr << file << ":" << line << ": EXPECT failed: " << expr << "\n";
}
}
inline void
assert_true(bool cond, const char *expr, const char *file, int line)
{
if (!cond) {
throw AssertionFailure{std::string(file) + ":" + std::to_string(line) + ": ASSERT failed: " + expr};
}
}
template<typename A, typename B>
inline void
assert_eq_impl(const A &a, const B &b, const char *ea, const char *eb, const char *file, int line)
{
// Cast to common type to avoid signed/unsigned comparison warnings
using Common = std::common_type_t<A, B>;
if (!(static_cast<Common>(a) == static_cast<Common>(b))) {
std::ostringstream oss;
oss << file << ":" << line << ": ASSERT_EQ failed: " << ea << " == " << eb;
throw AssertionFailure{oss.str()};
}
}
} // namespace ktet
#define TEST(name) \
static void name(); \
static ::ktet::Registrar _reg_##name(#name, &name); \
static void name()
#define EXPECT_TRUE(x) ::ktet::expect((x), #x, __FILE__, __LINE__)
#define ASSERT_TRUE(x) ::ktet::assert_true((x), #x, __FILE__, __LINE__)
#define ASSERT_EQ(a,b) ::ktet::assert_eq_impl((a),(b), #a, #b, __FILE__, __LINE__)

138
tests/TestHarness.h Normal file
View File

@@ -0,0 +1,138 @@
// TestHarness.h - small helper layer for driving kte headlessly in tests
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
namespace ktet {
inline void
InstallDefaultCommandsOnce()
{
static bool installed = false;
if (!installed) {
InstallDefaultCommands();
installed = true;
}
}
class TestHarness {
public:
TestHarness()
{
InstallDefaultCommandsOnce();
editor_.SetDimensions(24, 80);
Buffer b;
b.SetVirtualName("+TEST+");
editor_.AddBuffer(std::move(b));
}
Editor &
EditorRef()
{
return editor_;
}
Buffer &
Buf()
{
return *editor_.CurrentBuffer();
}
[[nodiscard]] const Buffer &
Buf() const
{
return *editor_.CurrentBuffer();
}
bool
Exec(CommandId id, const std::string &arg = std::string(), int ucount = 0)
{
if (ucount > 0) {
editor_.SetUniversalArg(1, ucount);
} else {
editor_.UArgClear();
}
return Execute(editor_, id, arg);
}
bool
InsertText(std::string_view text)
{
if (text.find('\n') != std::string_view::npos || text.find('\r') != std::string_view::npos)
return false;
return Exec(CommandId::InsertText, std::string(text));
}
void
TypeText(std::string_view text)
{
for (char ch: text) {
if (ch == '\n') {
Exec(CommandId::Newline);
} else if (ch == '\r') {
// ignore
} else {
Exec(CommandId::InsertText, std::string(1, ch));
}
}
}
[[nodiscard]] std::string
Text() const
{
const auto &rows = Buf().Rows();
std::string out;
for (std::size_t i = 0; i < rows.size(); ++i) {
out += static_cast<std::string>(rows[i]);
if (i + 1 < rows.size())
out.push_back('\n');
}
return out;
}
[[nodiscard]] std::string
Line(std::size_t y) const
{
return Buf().GetLineString(y);
}
bool
SaveAs(const std::string &path, std::string &err)
{
return Buf().SaveAs(path, err);
}
bool
Undo(int ucount = 0)
{
return Exec(CommandId::Undo, std::string(), ucount);
}
bool
Redo(int ucount = 0)
{
return Exec(CommandId::Redo, std::string(), ucount);
}
private:
Editor editor_;
};
} // namespace ktet

36
tests/TestRunner.cc Normal file
View File

@@ -0,0 +1,36 @@
#include "Test.h"
#include <iostream>
#include <chrono>
int
main()
{
using namespace std::chrono;
auto &reg = ktet::registry();
std::cout << "kte unit tests: " << reg.size() << " test(s)\n";
int failed = 0;
auto t0 = steady_clock::now();
for (const auto &tc: reg) {
auto ts = steady_clock::now();
try {
tc.fn();
auto te = steady_clock::now();
auto ms = duration_cast<milliseconds>(te - ts).count();
std::cout << "[ OK ] " << tc.name << " (" << ms << " ms)\n";
} catch (const ktet::AssertionFailure &e) {
++failed;
std::cerr << "[FAIL] " << tc.name << " -> " << e.msg << "\n";
} catch (const std::exception &e) {
++failed;
std::cerr << "[EXCP] " << tc.name << " -> " << e.what() << "\n";
} catch (...) {
++failed;
std::cerr << "[EXCP] " << tc.name << " -> unknown exception\n";
}
}
auto t1 = steady_clock::now();
auto total_ms = duration_cast<milliseconds>(t1 - t0).count();
std::cout << "Done in " << total_ms << " ms. Failures: " << failed << "\n";
return failed == 0 ? 0 : 1;
}

411
tests/test_benchmarks.cc Normal file
View File

@@ -0,0 +1,411 @@
/*
* test_benchmarks.cc - Performance benchmarks for core kte operations
*
* This file measures the performance of critical operations to ensure
* that migrations and refactorings don't introduce performance regressions.
*
* Benchmarks cover:
* - PieceTable operations (insert, delete, GetLine, GetLineRange)
* - Buffer operations (Nrows, GetLineString, GetLineView)
* - Iteration patterns (comparing old Rows() vs new GetLineString/GetLineView)
* - Syntax highlighting on large files
*
* Each benchmark reports execution time in milliseconds.
*/
#include "Test.h"
#include "Buffer.h"
#include "PieceTable.h"
#include "syntax/CppHighlighter.h"
#include "syntax/HighlighterEngine.h"
#include <chrono>
#include <iostream>
#include <random>
#include <sstream>
#include <string>
#include <vector>
namespace {
// Benchmark timing utility
class BenchmarkTimer {
public:
BenchmarkTimer(const char *name) : name_(name), start_(std::chrono::high_resolution_clock::now()) {}
~BenchmarkTimer()
{
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start_);
double ms = duration.count() / 1000.0;
std::cout << " [BENCH] " << name_ << ": " << ms << " ms\n";
}
private:
const char *name_;
std::chrono::high_resolution_clock::time_point start_;
};
// Generate test data
std::string
generate_large_file(std::size_t num_lines, std::size_t avg_line_length)
{
std::mt19937 rng(42);
std::string result;
result.reserve(num_lines * (avg_line_length + 1));
for (std::size_t i = 0; i < num_lines; ++i) {
std::size_t line_len = avg_line_length + (rng() % 20) - 10; // ±10 chars variation
for (std::size_t j = 0; j < line_len; ++j) {
char c = 'a' + (rng() % 26);
result.push_back(c);
}
result.push_back('\n');
}
return result;
}
std::string
generate_cpp_code(std::size_t num_lines)
{
std::ostringstream oss;
oss << "#include <iostream>\n";
oss << "#include <vector>\n";
oss << "#include <string>\n\n";
oss << "namespace test {\n";
for (std::size_t i = 0; i < num_lines / 10; ++i) {
oss << "class TestClass" << i << " {\n";
oss << "public:\n";
oss << " void method" << i << "() {\n";
oss << " // Comment line\n";
oss << " int x = " << i << ";\n";
oss << " std::string s = \"test string\";\n";
oss << " for (int j = 0; j < 100; ++j) {\n";
oss << " x += j;\n";
oss << " }\n";
oss << " }\n";
oss << "};\n\n";
}
oss << "} // namespace test\n";
return oss.str();
}
} // anonymous namespace
// ============================================================================
// PieceTable Benchmarks
// ============================================================================
TEST (Benchmark_PieceTable_Sequential_Inserts)
{
std::cout << "\n=== PieceTable Sequential Insert Benchmark ===\n";
PieceTable pt;
const std::size_t num_ops = 10000;
const char *text = "line\n";
const std::size_t text_len = 5;
{
BenchmarkTimer timer("10K sequential inserts at end");
for (std::size_t i = 0; i < num_ops; ++i) {
pt.Insert(pt.Size(), text, text_len);
}
}
ASSERT_EQ(pt.LineCount(), num_ops + 1); // +1 for final empty line
}
TEST (Benchmark_PieceTable_Random_Inserts)
{
std::cout << "\n=== PieceTable Random Insert Benchmark ===\n";
PieceTable pt;
const std::size_t num_ops = 5000;
const char *text = "xyz\n";
const std::size_t text_len = 4;
std::mt19937 rng(123);
// Pre-populate with some content
std::string initial = generate_large_file(1000, 50);
pt.Insert(0, initial.data(), initial.size());
{
BenchmarkTimer timer("5K random inserts");
for (std::size_t i = 0; i < num_ops; ++i) {
std::size_t pos = rng() % (pt.Size() + 1);
pt.Insert(pos, text, text_len);
}
}
}
TEST (Benchmark_PieceTable_GetLine_Sequential)
{
std::cout << "\n=== PieceTable GetLine Sequential Benchmark ===\n";
PieceTable pt;
std::string data = generate_large_file(10000, 80);
pt.Insert(0, data.data(), data.size());
std::size_t total_chars = 0;
{
BenchmarkTimer timer("GetLine on 10K lines (sequential)");
for (std::size_t i = 0; i < pt.LineCount(); ++i) {
std::string line = pt.GetLine(i);
total_chars += line.size();
}
}
EXPECT_TRUE(total_chars > 0);
}
TEST (Benchmark_PieceTable_GetLineRange_Sequential)
{
std::cout << "\n=== PieceTable GetLineRange Sequential Benchmark ===\n";
PieceTable pt;
std::string data = generate_large_file(10000, 80);
pt.Insert(0, data.data(), data.size());
std::size_t total_ranges = 0;
{
BenchmarkTimer timer("GetLineRange on 10K lines (sequential)");
for (std::size_t i = 0; i < pt.LineCount(); ++i) {
auto range = pt.GetLineRange(i);
total_ranges += (range.second - range.first);
}
}
EXPECT_TRUE(total_ranges > 0);
}
// ============================================================================
// Buffer Benchmarks
// ============================================================================
TEST (Benchmark_Buffer_Nrows_Repeated_Calls)
{
std::cout << "\n=== Buffer Nrows Benchmark ===\n";
Buffer buf;
std::string data = generate_large_file(10000, 80);
buf.insert_text(0, 0, data);
std::size_t sum = 0;
{
BenchmarkTimer timer("1M calls to Nrows()");
for (int i = 0; i < 1000000; ++i) {
sum += buf.Nrows();
}
}
EXPECT_TRUE(sum > 0);
}
TEST (Benchmark_Buffer_GetLineString_Sequential)
{
std::cout << "\n=== Buffer GetLineString Sequential Benchmark ===\n";
Buffer buf;
std::string data = generate_large_file(10000, 80);
buf.insert_text(0, 0, data);
std::size_t total_chars = 0;
{
BenchmarkTimer timer("GetLineString on 10K lines");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
std::string line = buf.GetLineString(i);
total_chars += line.size();
}
}
EXPECT_TRUE(total_chars > 0);
}
TEST (Benchmark_Buffer_GetLineView_Sequential)
{
std::cout << "\n=== Buffer GetLineView Sequential Benchmark ===\n";
Buffer buf;
std::string data = generate_large_file(10000, 80);
buf.insert_text(0, 0, data);
std::size_t total_chars = 0;
{
BenchmarkTimer timer("GetLineView on 10K lines");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
auto view = buf.GetLineView(i);
total_chars += view.size();
}
}
EXPECT_TRUE(total_chars > 0);
}
TEST (Benchmark_Buffer_Rows_Materialization)
{
std::cout << "\n=== Buffer Rows() Materialization Benchmark ===\n";
Buffer buf;
std::string data = generate_large_file(10000, 80);
buf.insert_text(0, 0, data);
std::size_t total_chars = 0;
{
BenchmarkTimer timer("Rows() materialization + iteration on 10K lines");
const auto &rows = buf.Rows();
for (std::size_t i = 0; i < rows.size(); ++i) {
total_chars += rows[i].size();
}
}
EXPECT_TRUE(total_chars > 0);
}
TEST (Benchmark_Buffer_Iteration_Comparison)
{
std::cout << "\n=== Buffer Iteration Pattern Comparison ===\n";
Buffer buf;
std::string data = generate_large_file(5000, 80);
buf.insert_text(0, 0, data);
std::size_t sum1 = 0, sum2 = 0, sum3 = 0;
// Pattern 1: Old style with Rows()
{
BenchmarkTimer timer("Pattern 1: Rows() + iteration");
const auto &rows = buf.Rows();
for (std::size_t i = 0; i < rows.size(); ++i) {
sum1 += rows[i].size();
}
}
// Pattern 2: New style with GetLineString
{
BenchmarkTimer timer("Pattern 2: Nrows() + GetLineString");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
sum2 += buf.GetLineString(i).size();
}
}
// Pattern 3: New style with GetLineView (zero-copy)
{
BenchmarkTimer timer("Pattern 3: Nrows() + GetLineView (zero-copy)");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
sum3 += buf.GetLineView(i).size();
}
}
// sum1 and sum2 should match (both strip newlines)
ASSERT_EQ(sum1, sum2);
// sum3 includes newlines, so it will be larger
EXPECT_TRUE(sum3 > sum2);
}
// ============================================================================
// Syntax Highlighting Benchmarks
// ============================================================================
TEST (Benchmark_Syntax_CppHighlighter_Large_File)
{
std::cout << "\n=== Syntax Highlighting Benchmark ===\n";
Buffer buf;
std::string cpp_code = generate_cpp_code(1000);
buf.insert_text(0, 0, cpp_code);
buf.EnsureHighlighter();
auto highlighter = std::make_unique<kte::CppHighlighter>();
std::size_t total_spans = 0;
{
BenchmarkTimer timer("C++ highlighting on ~1000 lines");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
std::vector<kte::HighlightSpan> spans;
highlighter->HighlightLine(buf, static_cast<int>(i), spans);
total_spans += spans.size();
}
}
EXPECT_TRUE(total_spans > 0);
}
TEST (Benchmark_Syntax_HighlighterEngine_Cached)
{
std::cout << "\n=== HighlighterEngine Cache Benchmark ===\n";
Buffer buf;
std::string cpp_code = generate_cpp_code(1000);
buf.insert_text(0, 0, cpp_code);
buf.EnsureHighlighter();
auto *engine = buf.Highlighter();
if (engine) {
engine->SetHighlighter(std::make_unique<kte::CppHighlighter>());
// First pass: populate cache
{
BenchmarkTimer timer("First pass (cache population)");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
engine->GetLine(buf, static_cast<int>(i), buf.Version());
}
}
// Second pass: use cache
{
BenchmarkTimer timer("Second pass (cache hits)");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
engine->GetLine(buf, static_cast<int>(i), buf.Version());
}
}
}
}
// ============================================================================
// Large File Stress Tests
// ============================================================================
TEST (Benchmark_Large_File_50K_Lines)
{
std::cout << "\n=== Large File (50K lines) Benchmark ===\n";
Buffer buf;
std::string data = generate_large_file(50000, 80);
{
BenchmarkTimer timer("Insert 50K lines");
buf.insert_text(0, 0, data);
}
ASSERT_EQ(buf.Nrows(), (std::size_t) 50001); // +1 for final line
std::size_t total = 0;
{
BenchmarkTimer timer("Iterate 50K lines with GetLineView");
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
total += buf.GetLineView(i).size();
}
}
EXPECT_TRUE(total > 0);
}
TEST (Benchmark_Random_Access_Pattern)
{
std::cout << "\n=== Random Access Pattern Benchmark ===\n";
Buffer buf;
std::string data = generate_large_file(10000, 80);
buf.insert_text(0, 0, data);
std::mt19937 rng(456);
std::size_t total = 0;
{
BenchmarkTimer timer("10K random line accesses with GetLineView");
for (int i = 0; i < 10000; ++i) {
std::size_t line = rng() % buf.Nrows();
total += buf.GetLineView(line).size();
}
}
EXPECT_TRUE(total > 0);
}

104
tests/test_buffer_io.cc Normal file
View File

@@ -0,0 +1,104 @@
/*
* test_buffer_io.cc - Tests for Buffer file I/O operations
*
* This file validates the Buffer's file handling capabilities, which are
* critical for a text editor. Buffer manages the relationship between
* in-memory content and files on disk.
*
* Key functionality tested:
* - SaveAs() creates a new file and makes the buffer file-backed
* - Save() writes to the existing file (requires file-backed buffer)
* - OpenFromFile() loads existing files or creates empty buffers for new files
* - The dirty flag is properly managed across save operations
*
* These tests demonstrate the Buffer I/O contract that commands rely on.
* When adding new file operations, follow these patterns.
*/
#include "Test.h"
#include <fstream>
#include <cstdio>
#include <string>
#include "Buffer.h"
static std::string
read_all(const std::string &path)
{
std::ifstream in(path, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
TEST (Buffer_SaveAs_and_Save_new_file)
{
const std::string path = "./.kte_ut_buffer_io_1.tmp";
std::remove(path.c_str());
Buffer b;
// insert two lines
b.insert_text(0, 0, std::string("Hello, world!\n"));
b.insert_text(1, 0, std::string("Second line\n"));
std::string err;
ASSERT_TRUE(b.SaveAs(path, err));
ASSERT_EQ(err.empty(), true);
// append another line then Save()
b.insert_text(2, 0, std::string("Third\n"));
b.SetDirty(true);
ASSERT_TRUE(b.Save(err));
ASSERT_EQ(err.empty(), true);
std::string got = read_all(path);
ASSERT_EQ(got, std::string("Hello, world!\nSecond line\nThird\n"));
std::remove(path.c_str());
}
TEST (Buffer_Save_after_Open_existing)
{
const std::string path = "./.kte_ut_buffer_io_2.tmp";
std::remove(path.c_str());
{
std::ofstream out(path, std::ios::binary);
out << "abc\n123\n";
}
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(err.empty(), true);
b.insert_text(2, 0, std::string("tail\n"));
b.SetDirty(true);
ASSERT_TRUE(b.Save(err));
ASSERT_EQ(err.empty(), true);
std::string got = read_all(path);
ASSERT_EQ(got, std::string("abc\n123\ntail\n"));
std::remove(path.c_str());
}
TEST (Buffer_Open_nonexistent_then_SaveAs)
{
const std::string path = "./.kte_ut_buffer_io_3.tmp";
std::remove(path.c_str());
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(err.empty(), true);
ASSERT_EQ(b.IsFileBacked(), false);
b.insert_text(0, 0, std::string("hello, world"));
b.insert_text(0, 12, std::string("\n"));
b.SetDirty(true);
ASSERT_TRUE(b.SaveAs(path, err));
ASSERT_EQ(err.empty(), true);
std::string got = read_all(path);
ASSERT_EQ(got, std::string("hello, world\n"));
std::remove(path.c_str());
}

142
tests/test_buffer_rows.cc Normal file
View File

@@ -0,0 +1,142 @@
#include "Test.h"
#include "Buffer.h"
#include <algorithm>
#include <limits>
#include <string>
#include <vector>
static std::vector<std::string>
split_lines_preserve_trailing_empty(const std::string &s)
{
std::vector<std::string> out;
std::size_t start = 0;
for (std::size_t i = 0; i <= s.size(); i++) {
if (i == s.size() || s[i] == '\n') {
out.push_back(s.substr(start, i - start));
start = i + 1;
}
}
if (out.empty())
out.push_back(std::string());
return out;
}
static std::vector<std::size_t>
line_starts_for(const std::string &s)
{
std::vector<std::size_t> starts;
starts.push_back(0);
for (std::size_t i = 0; i < s.size(); i++) {
if (s[i] == '\n')
starts.push_back(i + 1);
}
return starts;
}
static std::size_t
ref_linecol_to_offset(const std::string &s, std::size_t row, std::size_t col)
{
auto starts = line_starts_for(s);
if (starts.empty())
return 0;
if (row >= starts.size())
return s.size();
std::size_t start = starts[row];
std::size_t end = (row + 1 < starts.size()) ? starts[row + 1] : s.size();
if (end > start && s[end - 1] == '\n')
end -= 1; // clamp before trailing newline
return start + std::min(col, end - start);
}
static void
check_buffer_matches_model(const Buffer &b, const std::string &model)
{
auto expected_lines = split_lines_preserve_trailing_empty(model);
const auto &rows = b.Rows();
ASSERT_EQ(rows.size(), expected_lines.size());
ASSERT_EQ(b.Nrows(), rows.size());
auto starts = line_starts_for(model);
ASSERT_EQ(starts.size(), expected_lines.size());
std::string via_views;
for (std::size_t i = 0; i < rows.size(); i++) {
ASSERT_EQ(std::string(rows[i]), expected_lines[i]);
ASSERT_EQ(b.GetLineString(i), expected_lines[i]);
std::size_t exp_start = starts[i];
std::size_t exp_end = (i + 1 < starts.size()) ? starts[i + 1] : model.size();
auto r = b.GetLineRange(i);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
auto v = b.GetLineView(i);
ASSERT_EQ(std::string(v), model.substr(exp_start, exp_end - exp_start));
via_views.append(v.data(), v.size());
}
ASSERT_EQ(via_views, model);
}
TEST(Buffer_RowsCache_MultiLineEdits_StayConsistent)
{
Buffer b;
std::string model;
check_buffer_matches_model(b, model);
// Insert text and newlines in a few different ways.
b.insert_text(0, 0, std::string("abc"));
model.insert(0, "abc");
check_buffer_matches_model(b, model);
b.split_line(0, 1); // a\nbc
model.insert(ref_linecol_to_offset(model, 0, 1), "\n");
check_buffer_matches_model(b, model);
b.insert_text(1, 2, std::string("X")); // a\nbcX
model.insert(ref_linecol_to_offset(model, 1, 2), "X");
check_buffer_matches_model(b, model);
b.join_lines(0); // abcX
{
std::size_t off = ref_linecol_to_offset(model, 0, std::numeric_limits<std::size_t>::max());
if (off < model.size() && model[off] == '\n')
model.erase(off, 1);
}
check_buffer_matches_model(b, model);
// Insert a multi-line segment in one shot.
b.insert_text(0, 2, std::string("\n123\nxyz"));
model.insert(ref_linecol_to_offset(model, 0, 2), "\n123\nxyz");
check_buffer_matches_model(b, model);
// Delete spanning across a newline.
b.delete_text(0, 1, 5);
{
std::size_t start = ref_linecol_to_offset(model, 0, 1);
std::size_t actual = std::min<std::size_t>(5, model.size() - start);
model.erase(start, actual);
}
check_buffer_matches_model(b, model);
// Insert/delete whole rows.
b.insert_row(1, std::string_view("ROW"));
model.insert(ref_linecol_to_offset(model, 1, 0), "ROW\n");
check_buffer_matches_model(b, model);
b.delete_row(1);
{
auto starts = line_starts_for(model);
if (1 < (int) starts.size()) {
std::size_t start = starts[1];
std::size_t end = (2 < starts.size()) ? starts[2] : model.size();
model.erase(start, end - start);
}
}
check_buffer_matches_model(b, model);
}

View File

@@ -0,0 +1,110 @@
#include "Test.h"
#include "TestHarness.h"
using ktet::TestHarness;
TEST(CommandSemantics_KillToEOL_KillChain_And_Yank)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, std::string("abc\ndef"));
b.SetCursor(1, 0); // a|bc
ed.KillRingClear();
ed.SetKillChain(false);
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
ASSERT_EQ(h.Text(), std::string("a\ndef"));
ASSERT_EQ(ed.KillRingHead(), std::string("bc"));
// At EOL, KillToEOL kills the newline (join).
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
ASSERT_EQ(h.Text(), std::string("adef"));
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
// Yank pastes the kill ring head and breaks the kill chain.
ASSERT_TRUE(h.Exec(CommandId::Yank));
ASSERT_EQ(h.Text(), std::string("abc\ndef"));
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
ASSERT_EQ(ed.KillChain(), false);
}
TEST(CommandSemantics_ToggleMark_JumpToMark)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, std::string("hello"));
b.SetCursor(2, 0);
ASSERT_EQ(b.MarkSet(), false);
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
ASSERT_EQ(b.MarkSet(), true);
ASSERT_EQ(b.MarkCurx(), (std::size_t) 2);
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
b.SetCursor(4, 0);
ASSERT_TRUE(h.Exec(CommandId::JumpToMark));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
ASSERT_EQ(b.Cury(), (std::size_t) 0);
// Jump-to-mark swaps: mark becomes previous cursor.
ASSERT_EQ(b.MarkSet(), true);
ASSERT_EQ(b.MarkCurx(), (std::size_t) 4);
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
}
TEST(CommandSemantics_CtrlGRefresh_ClearsMark_WhenNothingElseToCancel)
{
TestHarness h;
Buffer &b = h.Buf();
b.insert_text(0, 0, std::string("hello"));
b.SetCursor(2, 0);
ASSERT_EQ(b.MarkSet(), false);
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
ASSERT_EQ(b.MarkSet(), true);
// C-g is mapped to Refresh; when there's no prompt/search/visual-line mode to cancel,
// it should clear the mark.
ASSERT_TRUE(h.Exec(CommandId::Refresh));
ASSERT_EQ(b.MarkSet(), false);
}
TEST(CommandSemantics_CopyRegion_And_KillRegion)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, std::string("hello world"));
b.SetCursor(0, 0);
ed.KillRingClear();
ed.SetKillChain(false);
// Copy "hello" (region [0,5)).
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
b.SetCursor(5, 0);
ASSERT_TRUE(h.Exec(CommandId::CopyRegion));
ASSERT_EQ(ed.KillRingHead(), std::string("hello"));
ASSERT_EQ(b.MarkSet(), false);
ASSERT_EQ(h.Text(), std::string("hello world"));
// Kill "world" (region [6,11)).
ed.SetKillChain(false);
b.SetCursor(6, 0);
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
b.SetCursor(11, 0);
ASSERT_TRUE(h.Exec(CommandId::KillRegion));
ASSERT_EQ(ed.KillRingHead(), std::string("world"));
ASSERT_EQ(b.MarkSet(), false);
ASSERT_EQ(h.Text(), std::string("hello "));
}

View File

@@ -0,0 +1,12 @@
#include "Test.h"
#include "tests/TestHarness.h"
TEST(DailyDriverHarness_Smoke_CanCreateBufferAndInsertText)
{
ktet::TestHarness h;
ASSERT_TRUE(h.InsertText("hello"));
ASSERT_EQ(h.Line(0), std::string("hello"));
}

View File

@@ -0,0 +1,191 @@
/*
* test_daily_workflows.cc - Integration tests for real-world editing scenarios
*
* This file demonstrates end-to-end testing of kte functionality by simulating
* complete user workflows without requiring a UI. Tests execute commands directly
* through the command system, validating that the entire stack (Editor, Buffer,
* PieceTable, UndoSystem, SwapManager) works together correctly.
*
* Key workflows tested:
* - Open file → Edit → Save: Basic editing lifecycle
* - Multi-buffer management: Opening, switching, and closing multiple files
* - Crash recovery: Swap file recording and replay after simulated crash
*
* These tests are valuable examples for developers because they show:
* 1. How to test complex interactions without a frontend
* 2. How commands compose to implement user workflows
* 3. How to verify end-to-end behavior including file I/O and crash recovery
*
* When adding new features, consider adding integration tests here to validate
* that they work correctly in realistic scenarios.
*/
#include "Test.h"
#include "Command.h"
#include "Editor.h"
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
static void
write_file_bytes(const std::string &path, const std::string &bytes)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), (std::streamsize) bytes.size());
}
static std::string
read_file_bytes(const std::string &path)
{
std::ifstream in(path, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
static std::string
buffer_bytes_via_views(const Buffer &b)
{
const auto &rows = b.Rows();
std::string out;
for (std::size_t i = 0; i < rows.size(); i++) {
auto v = b.GetLineView(i);
out.append(v.data(), v.size());
}
return out;
}
TEST (DailyWorkflow_OpenEditSave_Transcript)
{
ktet::InstallDefaultCommandsOnce();
const std::string path = "./.kte_ut_daily_open_edit_save.txt";
std::remove(path.c_str());
write_file_bytes(path, "one\n");
const std::string npath = std::filesystem::canonical(path).string();
Editor ed;
ed.SetDimensions(24, 80);
// Seed an empty buffer so OpenFile can reuse it.
{
Buffer scratch;
ed.AddBuffer(std::move(scratch));
}
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(ed.CurrentBuffer()->Filename(), npath);
// Append two new lines via commands (no UI).
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "two"));
ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "three"));
ASSERT_TRUE(Execute(ed, CommandId::Save));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(read_file_bytes(npath), buffer_bytes_via_views(*ed.CurrentBuffer()));
std::remove(path.c_str());
std::remove(npath.c_str());
}
TEST (DailyWorkflow_MultiBufferSwitchClose_Transcript)
{
ktet::InstallDefaultCommandsOnce();
const std::string p1 = "./.kte_ut_daily_buf_1.txt";
const std::string p2 = "./.kte_ut_daily_buf_2.txt";
std::remove(p1.c_str());
std::remove(p2.c_str());
write_file_bytes(p1, "aaa\n");
write_file_bytes(p2, "bbb\n");
const std::string np1 = std::filesystem::canonical(p1).string();
const std::string np2 = std::filesystem::canonical(p2).string();
Editor ed;
ed.SetDimensions(24, 80);
{
Buffer scratch;
ed.AddBuffer(std::move(scratch));
}
std::string err;
ASSERT_TRUE(ed.OpenFile(p1, err));
ASSERT_TRUE(ed.OpenFile(p2, err));
ASSERT_EQ(ed.BufferCount(), (std::size_t) 2);
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
// Switch back and forth.
ASSERT_TRUE(Execute(ed, CommandId::BufferPrev));
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
ASSERT_TRUE(Execute(ed, CommandId::BufferNext));
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
// Close current buffer (p2); ensure we land on p1.
ASSERT_TRUE(Execute(ed, CommandId::BufferClose));
ASSERT_EQ(ed.BufferCount(), (std::size_t) 1);
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
std::remove(p1.c_str());
std::remove(p2.c_str());
std::remove(np1.c_str());
std::remove(np2.c_str());
}
TEST (DailyWorkflow_CrashRecovery_SwapReplay_Transcript)
{
ktet::InstallDefaultCommandsOnce();
const std::string path = "./.kte_ut_daily_swap_recover.txt";
std::remove(path.c_str());
write_file_bytes(path, "base\nline2\n");
Editor ed;
ed.SetDimensions(24, 80);
{
Buffer scratch;
ed.AddBuffer(std::move(scratch));
}
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
// Make unsaved edits through command execution.
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
ASSERT_TRUE(Execute(ed, CommandId::MoveDown));
ASSERT_TRUE(Execute(ed, CommandId::MoveHome));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "ZZ"));
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "TAIL"));
// Ensure journal is durable and capture expected bytes.
ed.Swap()->Flush(buf);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(*buf);
const std::string expected = buffer_bytes_via_views(*buf);
// "Crash": reopen from disk (original file content) into a fresh Buffer and replay.
Buffer recovered;
ASSERT_TRUE(recovered.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(recovered, swap_path, err));
ASSERT_EQ(buffer_bytes_via_views(recovered), expected);
// Cleanup.
ed.Swap()->Detach(buf);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}

84
tests/test_kkeymap.cc Normal file
View File

@@ -0,0 +1,84 @@
#include "Test.h"
#include "KKeymap.h"
#include <ncurses.h>
TEST(KKeymap_KPrefix_CanonicalChords)
{
CommandId id{};
// From docs/ke.md (K-commands)
ASSERT_TRUE(KLookupKCommand('s', false, id));
ASSERT_EQ(id, CommandId::Save);
ASSERT_TRUE(KLookupKCommand('s', true, id)); // C-k C-s
ASSERT_EQ(id, CommandId::Save);
ASSERT_TRUE(KLookupKCommand('d', false, id));
ASSERT_EQ(id, CommandId::KillToEOL);
ASSERT_TRUE(KLookupKCommand('d', true, id)); // C-k C-d
ASSERT_EQ(id, CommandId::KillLine);
ASSERT_TRUE(KLookupKCommand(' ', false, id)); // C-k SPACE
ASSERT_EQ(id, CommandId::ToggleMark);
ASSERT_TRUE(KLookupKCommand('j', false, id));
ASSERT_EQ(id, CommandId::JumpToMark);
ASSERT_TRUE(KLookupKCommand('f', false, id));
ASSERT_EQ(id, CommandId::FlushKillRing);
ASSERT_TRUE(KLookupKCommand('y', false, id));
ASSERT_EQ(id, CommandId::Yank);
// Unknown should not map
ASSERT_EQ(KLookupKCommand('Z', false, id), false);
}
TEST(KKeymap_CtrlChords_CanonicalChords)
{
CommandId id{};
// From docs/ke.md (other keybindings)
ASSERT_TRUE(KLookupCtrlCommand('n', id));
ASSERT_EQ(id, CommandId::MoveDown);
ASSERT_TRUE(KLookupCtrlCommand('p', id));
ASSERT_EQ(id, CommandId::MoveUp);
ASSERT_TRUE(KLookupCtrlCommand('f', id));
ASSERT_EQ(id, CommandId::MoveRight);
ASSERT_TRUE(KLookupCtrlCommand('b', id));
ASSERT_EQ(id, CommandId::MoveLeft);
ASSERT_TRUE(KLookupCtrlCommand('w', id));
ASSERT_EQ(id, CommandId::KillRegion);
ASSERT_TRUE(KLookupCtrlCommand('y', id));
ASSERT_EQ(id, CommandId::Yank);
ASSERT_EQ(KLookupCtrlCommand('z', id), false);
}
TEST(KKeymap_EscChords_CanonicalChords)
{
CommandId id{};
// From docs/ke.md (ESC bindings)
ASSERT_TRUE(KLookupEscCommand('b', id));
ASSERT_EQ(id, CommandId::WordPrev);
ASSERT_TRUE(KLookupEscCommand('f', id));
ASSERT_EQ(id, CommandId::WordNext);
ASSERT_TRUE(KLookupEscCommand('d', id));
ASSERT_EQ(id, CommandId::DeleteWordNext);
ASSERT_TRUE(KLookupEscCommand('q', id));
ASSERT_EQ(id, CommandId::ReflowParagraph);
ASSERT_TRUE(KLookupEscCommand('w', id));
ASSERT_EQ(id, CommandId::CopyRegion);
// ESC BACKSPACE
ASSERT_TRUE(KLookupEscCommand(KEY_BACKSPACE, id));
ASSERT_EQ(id, CommandId::DeleteWordPrev);
ASSERT_EQ(KLookupEscCommand('z', id), false);
}

View File

@@ -0,0 +1,448 @@
/*
* test_migration_coverage.cc - Edge case tests for Buffer::Line migration
*
* This file provides comprehensive test coverage for the migration from
* Buffer::Rows() to direct PieceTable operations using Nrows(), GetLineString(),
* and GetLineView().
*
* Tests cover:
* - Edge cases: empty buffers, single lines, very long lines
* - Boundary conditions: first line, last line, out-of-bounds
* - Consistency: GetLineString vs GetLineView vs Rows()
* - Performance: large files, many small operations
* - Correctness: special characters, newlines, unicode
*/
#include "Test.h"
#include "Buffer.h"
#include <string>
#include <vector>
// ============================================================================
// Edge Case Tests
// ============================================================================
TEST (Migration_EmptyBuffer_Nrows)
{
Buffer buf;
ASSERT_EQ(buf.Nrows(), (std::size_t) 1); // Empty buffer has 1 logical line
}
TEST (Migration_EmptyBuffer_GetLineString)
{
Buffer buf;
ASSERT_EQ(buf.GetLineString(0), std::string(""));
}
TEST (Migration_EmptyBuffer_GetLineView)
{
Buffer buf;
auto view = buf.GetLineView(0);
ASSERT_EQ(view.size(), (std::size_t) 0);
ASSERT_EQ(std::string(view), std::string(""));
}
TEST (Migration_SingleLine_NoNewline)
{
Buffer buf;
buf.insert_text(0, 0, std::string("hello"));
ASSERT_EQ(buf.Nrows(), (std::size_t) 1);
ASSERT_EQ(buf.GetLineString(0), std::string("hello"));
ASSERT_EQ(std::string(buf.GetLineView(0)), std::string("hello"));
}
TEST (Migration_SingleLine_WithNewline)
{
Buffer buf;
buf.insert_text(0, 0, std::string("hello\n"));
ASSERT_EQ(buf.Nrows(), (std::size_t) 2); // Line + empty line after newline
ASSERT_EQ(buf.GetLineString(0), std::string("hello"));
ASSERT_EQ(buf.GetLineString(1), std::string(""));
}
TEST (Migration_MultipleLines_TrailingNewline)
{
Buffer buf;
buf.insert_text(0, 0, std::string("line1\nline2\nline3\n"));
ASSERT_EQ(buf.Nrows(), (std::size_t) 4); // 3 lines + empty line
ASSERT_EQ(buf.GetLineString(0), std::string("line1"));
ASSERT_EQ(buf.GetLineString(1), std::string("line2"));
ASSERT_EQ(buf.GetLineString(2), std::string("line3"));
ASSERT_EQ(buf.GetLineString(3), std::string(""));
}
TEST (Migration_MultipleLines_NoTrailingNewline)
{
Buffer buf;
buf.insert_text(0, 0, std::string("line1\nline2\nline3"));
ASSERT_EQ(buf.Nrows(), (std::size_t) 3);
ASSERT_EQ(buf.GetLineString(0), std::string("line1"));
ASSERT_EQ(buf.GetLineString(1), std::string("line2"));
ASSERT_EQ(buf.GetLineString(2), std::string("line3"));
}
TEST (Migration_VeryLongLine)
{
Buffer buf;
std::string long_line(10000, 'x');
buf.insert_text(0, 0, long_line);
ASSERT_EQ(buf.Nrows(), (std::size_t) 1);
ASSERT_EQ(buf.GetLineString(0), long_line);
ASSERT_EQ(buf.GetLineString(0).size(), (std::size_t) 10000);
}
TEST (Migration_ManyEmptyLines)
{
Buffer buf;
std::string many_newlines(1000, '\n');
buf.insert_text(0, 0, many_newlines);
ASSERT_EQ(buf.Nrows(), (std::size_t) 1001); // 1000 newlines = 1001 lines
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
ASSERT_EQ(buf.GetLineString(i), std::string(""));
}
}
// ============================================================================
// Consistency Tests: GetLineString vs GetLineView vs Rows()
// ============================================================================
TEST (Migration_Consistency_AllMethods)
{
Buffer buf;
buf.insert_text(0, 0, std::string("abc\n123\nxyz"));
const auto &rows = buf.Rows();
ASSERT_EQ(buf.Nrows(), rows.size());
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
std::string via_string = buf.GetLineString(i);
std::string via_rows = std::string(rows[i]);
// GetLineString and Rows() both strip newlines
ASSERT_EQ(via_string, via_rows);
// GetLineView includes the raw range (with newlines if present)
// Just verify it's accessible
(void) buf.GetLineView(i);
}
}
TEST (Migration_Consistency_AfterEdits)
{
Buffer buf;
buf.insert_text(0, 0, std::string("line1\nline2\nline3\n"));
// Edit: insert in middle
buf.insert_text(1, 2, std::string("XX"));
const auto &rows = buf.Rows();
ASSERT_EQ(buf.Nrows(), rows.size());
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
// GetLineString and Rows() both strip newlines
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
}
// Edit: delete line
buf.delete_row(1);
const auto &rows2 = buf.Rows();
ASSERT_EQ(buf.Nrows(), rows2.size());
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
ASSERT_EQ(buf.GetLineString(i), std::string(rows2[i]));
}
}
// ============================================================================
// Boundary Tests
// ============================================================================
TEST (Migration_FirstLine_Access)
{
Buffer buf;
buf.insert_text(0, 0, std::string("first\nsecond\nthird"));
ASSERT_EQ(buf.GetLineString(0), std::string("first"));
// GetLineView includes newline: "first\n"
auto view0 = buf.GetLineView(0);
EXPECT_TRUE(view0.size() >= 5); // at least "first"
}
TEST (Migration_LastLine_Access)
{
Buffer buf;
buf.insert_text(0, 0, std::string("first\nsecond\nthird"));
std::size_t last = buf.Nrows() - 1;
ASSERT_EQ(buf.GetLineString(last), std::string("third"));
ASSERT_EQ(std::string(buf.GetLineView(last)), std::string("third"));
}
TEST (Migration_GetLineRange_Boundaries)
{
Buffer buf;
buf.insert_text(0, 0, std::string("abc\n123\nxyz"));
// First line
auto r0 = buf.GetLineRange(0);
ASSERT_EQ(r0.first, (std::size_t) 0);
ASSERT_EQ(r0.second, (std::size_t) 4); // "abc\n"
// Last line
std::size_t last = buf.Nrows() - 1;
(void) buf.GetLineRange(last); // Verify it doesn't crash
ASSERT_EQ(buf.GetLineString(last), std::string("xyz"));
}
// ============================================================================
// Special Characters and Unicode
// ============================================================================
TEST (Migration_SpecialChars_Tabs)
{
Buffer buf;
buf.insert_text(0, 0, std::string("line\twith\ttabs"));
ASSERT_EQ(buf.GetLineString(0), std::string("line\twith\ttabs"));
ASSERT_EQ(std::string(buf.GetLineView(0)), std::string("line\twith\ttabs"));
}
TEST (Migration_SpecialChars_CarriageReturn)
{
Buffer buf;
buf.insert_text(0, 0, std::string("line\rwith\rcr"));
ASSERT_EQ(buf.GetLineString(0), std::string("line\rwith\rcr"));
}
TEST (Migration_SpecialChars_NullBytes)
{
Buffer buf;
std::string with_null = "abc";
with_null.push_back('\0');
with_null += "def";
buf.insert_text(0, 0, with_null);
ASSERT_EQ(buf.GetLineString(0).size(), (std::size_t) 7);
ASSERT_EQ(buf.GetLineView(0).size(), (std::size_t) 7);
}
TEST (Migration_Unicode_BasicMultibyte)
{
Buffer buf;
std::string utf8 = "Hello 世界 🌍";
buf.insert_text(0, 0, utf8);
ASSERT_EQ(buf.GetLineString(0), utf8);
ASSERT_EQ(std::string(buf.GetLineView(0)), utf8);
}
// ============================================================================
// Large File Tests
// ============================================================================
TEST (Migration_LargeFile_10K_Lines)
{
Buffer buf;
std::string data;
for (int i = 0; i < 10000; ++i) {
data += "Line " + std::to_string(i) + "\n";
}
buf.insert_text(0, 0, data);
ASSERT_EQ(buf.Nrows(), (std::size_t) 10001); // +1 for final empty line
// Spot check some lines
ASSERT_EQ(buf.GetLineString(0), std::string("Line 0"));
ASSERT_EQ(buf.GetLineString(5000), std::string("Line 5000"));
ASSERT_EQ(buf.GetLineString(9999), std::string("Line 9999"));
ASSERT_EQ(buf.GetLineString(10000), std::string(""));
}
TEST (Migration_LargeFile_Iteration_Consistency)
{
Buffer buf;
std::string data;
for (int i = 0; i < 1000; ++i) {
data += "Line " + std::to_string(i) + "\n";
}
buf.insert_text(0, 0, data);
// Iterate with GetLineString (strips newlines, must add back)
std::string reconstructed1;
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
if (i > 0) {
reconstructed1 += '\n';
}
reconstructed1 += buf.GetLineString(i);
}
// Iterate with GetLineView (includes newlines)
std::string reconstructed2;
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
auto view = buf.GetLineView(i);
reconstructed2.append(view.data(), view.size());
}
// GetLineView should match original exactly
ASSERT_EQ(reconstructed2, data);
// GetLineString reconstruction should match (without final empty line)
EXPECT_TRUE(reconstructed1.size() > 0);
}
// ============================================================================
// Stress Tests: Many Small Operations
// ============================================================================
TEST (Migration_Stress_ManySmallInserts)
{
Buffer buf;
buf.insert_text(0, 0, std::string("start\n"));
for (int i = 0; i < 100; ++i) {
buf.insert_text(1, 0, std::string("x"));
}
ASSERT_EQ(buf.Nrows(), (std::size_t) 2);
ASSERT_EQ(buf.GetLineString(0), std::string("start"));
ASSERT_EQ(buf.GetLineString(1).size(), (std::size_t) 100);
// Verify consistency
const auto &rows = buf.Rows();
ASSERT_EQ(buf.GetLineString(1), std::string(rows[1]));
}
TEST (Migration_Stress_ManyLineInserts)
{
Buffer buf;
for (int i = 0; i < 500; ++i) {
buf.insert_row(buf.Nrows() - 1, std::string_view("line"));
}
ASSERT_EQ(buf.Nrows(), (std::size_t) 501); // 500 + initial empty line
for (std::size_t i = 0; i < 500; ++i) {
ASSERT_EQ(buf.GetLineString(i), std::string("line"));
}
}
TEST (Migration_Stress_AlternatingInsertDelete)
{
Buffer buf;
buf.insert_text(0, 0, std::string("a\nb\nc\nd\ne\n"));
for (int i = 0; i < 50; ++i) {
std::size_t nrows = buf.Nrows();
if (nrows > 2) {
buf.delete_row(1);
}
buf.insert_row(1, std::string_view("new"));
}
// Verify consistency after many operations
const auto &rows = buf.Rows();
ASSERT_EQ(buf.Nrows(), rows.size());
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
// GetLineString and Rows() both strip newlines
ASSERT_EQ(buf.GetLineString(i), std::string(rows[i]));
}
}
// ============================================================================
// Regression Tests: Specific Migration Scenarios
// ============================================================================
TEST (Migration_Shebang_Detection)
{
// Test the pattern used in Editor.cc for shebang detection
Buffer buf;
buf.insert_text(0, 0, std::string("#!/usr/bin/env python3\nprint('hello')"));
ASSERT_EQ(buf.Nrows(), (std::size_t) 2);
std::string first_line = "";
if (buf.Nrows() > 0) {
first_line = buf.GetLineString(0);
}
ASSERT_EQ(first_line, std::string("#!/usr/bin/env python3"));
}
TEST (Migration_EmptyBufferCheck_Pattern)
{
// Test the pattern used in Editor.cc for empty buffer detection
Buffer buf;
const std::size_t nrows = buf.Nrows();
const bool rows_empty = (nrows == 0);
const bool single_empty_line = (nrows == 1 && buf.GetLineView(0).size() == 0);
ASSERT_EQ(rows_empty, false);
ASSERT_EQ(single_empty_line, true);
}
TEST (Migration_SyntaxHighlighter_Pattern)
{
// Test the pattern used in syntax highlighters
Buffer buf;
buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}"));
for (std::size_t row = 0; row < buf.Nrows(); ++row) {
// This is the pattern used in all migrated highlighters
if (row >= buf.Nrows()) {
break; // Should never happen
}
std::string line = buf.GetLineString(row);
// Successfully accessed line - size() is always valid for std::string
}
}
TEST (Migration_SwapSnapshot_Pattern)
{
// Test the pattern used in Swap.cc for buffer snapshots
Buffer buf;
buf.insert_text(0, 0, std::string("line1\nline2\nline3\n"));
const std::size_t nrows = buf.Nrows();
std::string snapshot;
for (std::size_t i = 0; i < nrows; ++i) {
auto view = buf.GetLineView(i);
snapshot.append(view.data(), view.size());
}
EXPECT_TRUE(snapshot.size() > 0);
ASSERT_EQ(snapshot, std::string("line1\nline2\nline3\n"));
}

199
tests/test_piece_table.cc Normal file
View File

@@ -0,0 +1,199 @@
/*
* test_piece_table.cc - Tests for the PieceTable data structure
*
* This file validates the core text storage mechanism used by kte.
* PieceTable provides efficient insert/delete operations without copying
* the entire buffer, using a list of "pieces" that reference ranges in
* original and add buffers.
*
* Key functionality tested:
* - Insert/delete operations maintain correct content
* - Line counting and line-based queries work correctly
* - Position conversion (byte offset ↔ line/column) is accurate
* - Random edits against a reference model (string) produce identical results
*
* The random edit test is particularly important - it performs hundreds of
* random insertions and deletions, comparing PieceTable results against a
* simple std::string to ensure correctness under all conditions.
*/
#include "Test.h"
#include "PieceTable.h"
#include <algorithm>
#include <array>
#include <random>
#include <string>
#include <vector>
static std::vector<std::size_t>
LineStartsFor(const std::string &s)
{
std::vector<std::size_t> starts;
starts.push_back(0);
for (std::size_t i = 0; i < s.size(); i++) {
if (s[i] == '\n')
starts.push_back(i + 1);
}
return starts;
}
static std::string
LineContentFor(const std::string &s, std::size_t line_num)
{
auto starts = LineStartsFor(s);
if (starts.empty() || line_num >= starts.size())
return std::string();
std::size_t start = starts[line_num];
std::size_t end = (line_num + 1 < starts.size()) ? starts[line_num + 1] : s.size();
if (end > start && s[end - 1] == '\n')
end -= 1;
return s.substr(start, end - start);
}
TEST (PieceTable_Insert_Delete_LineCount)
{
PieceTable pt;
// start empty
ASSERT_EQ(pt.Size(), (std::size_t) 0);
ASSERT_EQ(pt.LineCount(), (std::size_t) 1); // empty buffer has 1 logical line
// Insert some text with newlines
const char *t = "abc\n123\nxyz"; // last line without trailing NL
pt.Insert(0, t, 11);
ASSERT_EQ(pt.Size(), (std::size_t) 11);
ASSERT_EQ(pt.LineCount(), (std::size_t) 3);
// Check get line
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("123"));
ASSERT_EQ(pt.GetLine(2), std::string("xyz"));
// Delete middle line entirely including its trailing NL
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
pt.Delete(r.first, r.second - r.first);
ASSERT_EQ(pt.LineCount(), (std::size_t) 2);
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
}
TEST (PieceTable_LineCol_Conversions)
{
PieceTable pt;
std::string s = "hello\nworld\n"; // two lines with trailing NL
pt.Insert(0, s.data(), s.size());
// Byte offsets of starts
auto off0 = pt.LineColToByteOffset(0, 0);
auto off1 = pt.LineColToByteOffset(1, 0);
auto off2 = pt.LineColToByteOffset(2, 0); // EOF
ASSERT_EQ(off0, (std::size_t) 0);
ASSERT_EQ(off1, (std::size_t) 6); // "hello\n"
ASSERT_EQ(off2, pt.Size());
auto lc0 = pt.ByteOffsetToLineCol(0);
auto lc1 = pt.ByteOffsetToLineCol(6);
ASSERT_EQ(lc0.first, (std::size_t) 0);
ASSERT_EQ(lc0.second, (std::size_t) 0);
ASSERT_EQ(lc1.first, (std::size_t) 1);
ASSERT_EQ(lc1.second, (std::size_t) 0);
}
TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic)
{
PieceTable pt;
std::string model;
std::mt19937 rng(0xC0FFEEu);
const std::vector<std::string> corpus = {
"a",
"b",
"c",
"xyz",
"123",
"\n",
"!\n",
"foo\nbar",
"end\n",
};
auto check_invariants = [&](const char *where) {
(void) where;
ASSERT_EQ(pt.Size(), model.size());
ASSERT_EQ(pt.GetRange(0, pt.Size()), model);
auto starts = LineStartsFor(model);
ASSERT_EQ(pt.LineCount(), starts.size());
// Spot-check a few line ranges and contents.
std::size_t last = starts.empty() ? (std::size_t) 0 : (starts.size() - 1);
std::size_t mid = (starts.size() > 2) ? (std::size_t) 1 : last;
const std::array<std::size_t, 3> probe_lines = {(std::size_t) 0, last, mid};
for (auto line: probe_lines) {
if (starts.empty())
break;
if (line >= starts.size())
continue;
std::size_t exp_start = starts[line];
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
auto r = pt.GetLineRange(line);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
}
// Round-trips for a few offsets.
const std::vector<std::size_t> probe_offsets = {
0,
model.size() / 2,
model.size(),
};
for (auto off: probe_offsets) {
auto lc = pt.ByteOffsetToLineCol(off);
auto back = pt.LineColToByteOffset(lc.first, lc.second);
ASSERT_EQ(back, off);
}
};
check_invariants("initial");
for (int step = 0; step < 250; step++) {
bool do_insert = model.empty() || ((rng() % 3u) != 0u); // bias toward insert
if (do_insert) {
const std::string &ins = corpus[rng() % corpus.size()];
std::size_t pos = model.empty() ? 0 : (rng() % (model.size() + 1));
pt.Insert(pos, ins.data(), ins.size());
model.insert(pos, ins);
} else {
std::size_t pos = rng() % model.size();
std::size_t max = std::min<std::size_t>(8, model.size() - pos);
std::size_t len = 1 + (rng() % max);
pt.Delete(pos, len);
model.erase(pos, len);
}
// Also validate GetRange on a small random window when non-empty.
if (!model.empty()) {
std::size_t off = rng() % model.size();
std::size_t max = std::min<std::size_t>(16, model.size() - off);
std::size_t len = 1 + (rng() % max);
ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len));
}
check_invariants("step");
}
// Full line-by-line range verification at the end.
auto starts = LineStartsFor(model);
for (std::size_t line = 0; line < starts.size(); line++) {
std::size_t exp_start = starts[line];
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
auto r = pt.GetLineRange(line);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
}
}

View File

@@ -0,0 +1,78 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include <iostream>
#include <string>
static std::string
to_string_rows(const Buffer &buf)
{
std::string out;
for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r);
out.push_back('\n');
}
return out;
}
TEST(ReflowParagraph_IndentedBullets_PreserveStructure)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
// Test the example from the issue: indented list items should not be merged
const std::string initial =
"+ something at the top\n"
" + something indented\n"
"+ the next line\n";
b.insert_text(0, 0, initial);
// Put cursor on first item
b.SetCursor(0, 0);
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
// Use a width that's larger than all lines (so no wrapping should occur)
const int width = 80;
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
const auto &rows = buf->Rows();
const std::string result = to_string_rows(*buf);
// We should have 3 lines (plus possibly a trailing empty line)
ASSERT_TRUE(rows.size() >= 3);
// Check that the structure is preserved
std::string line0 = static_cast<std::string>(rows[0]);
std::string line1 = static_cast<std::string>(rows[1]);
std::string line2 = static_cast<std::string>(rows[2]);
// First line should start with "+ "
EXPECT_TRUE(line0.rfind("+ ", 0) == 0);
EXPECT_TRUE(line0.find("something at the top") != std::string::npos);
// Second line should start with " + " (two spaces, then +)
EXPECT_TRUE(line1.rfind(" + ", 0) == 0);
EXPECT_TRUE(line1.find("something indented") != std::string::npos);
// Third line should start with "+ "
EXPECT_TRUE(line2.rfind("+ ", 0) == 0);
EXPECT_TRUE(line2.find("the next line") != std::string::npos);
// The indented line should NOT be merged with the first line
EXPECT_TRUE(line0.find("indented") == std::string::npos);
// Debug output if something goes wrong
if (line0.rfind("+ ", 0) != 0 || line1.rfind(" + ", 0) != 0 || line2.rfind("+ ", 0) != 0) {
std::cerr << "Reflow did not preserve indented bullet structure:\n" << result << "\n";
}
}

View File

@@ -0,0 +1,102 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include <iostream>
#include <string>
static std::string
to_string_rows(const Buffer &buf)
{
std::string out;
for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r);
out.push_back('\n');
}
return out;
}
TEST(ReflowParagraph_NumberedList_HangingIndent)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
// Two list items in one paragraph (no blank lines).
// Second line of each item already uses a hanging indent.
const std::string initial =
"1. one two three four five six seven eight nine ten eleven\n"
" twelve thirteen fourteen\n"
"10. alpha beta gamma delta epsilon zeta eta theta iota kappa lambda\n"
" mu nu xi omicron\n";
b.insert_text(0, 0, initial);
// Put cursor on first item
b.SetCursor(0, 0);
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
const int width = 25;
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
const auto &rows = buf->Rows();
ASSERT_TRUE(!rows.empty());
const std::string dump = to_string_rows(*buf);
// Find the start of the second item.
bool any_too_long = false;
std::size_t idx_10 = rows.size();
for (std::size_t i = 0; i < rows.size(); ++i) {
const std::string line = static_cast<std::string>(rows[i]);
if (static_cast<int>(line.size()) > width)
any_too_long = true;
if (line.rfind("10. ", 0) == 0) {
idx_10 = i;
break;
}
}
ASSERT_TRUE(idx_10 < rows.size());
if (any_too_long) {
std::cerr << "Reflow produced a line longer than width=" << width << "\n";
std::cerr << to_string_rows(*buf) << "\n";
}
EXPECT_TRUE(!any_too_long);
// Item 1: first line has "1. ", continuation lines have 3 spaces.
for (std::size_t i = 0; i < idx_10; ++i) {
const std::string line = static_cast<std::string>(rows[i]);
if (i == 0) {
ASSERT_TRUE(line.rfind("1. ", 0) == 0);
} else {
ASSERT_TRUE(line.rfind(" ", 0) == 0);
ASSERT_TRUE(line.rfind("1. ", 0) != 0);
}
}
// Item 10: first line has "10. ", continuation lines have 4 spaces.
ASSERT_TRUE(static_cast<std::string>(rows[idx_10]).rfind("10. ", 0) == 0);
bool bad_10 = false;
for (std::size_t i = idx_10 + 1; i < rows.size(); ++i) {
const std::string line = static_cast<std::string>(rows[i]);
if (line.empty())
break; // paragraph terminator / trailing empty line
if (line.rfind(" ", 0) != 0)
bad_10 = true;
if (line.rfind("10. ", 0) == 0)
bad_10 = true;
}
if (bad_10) {
std::cerr << "Unexpected prefix in reflow output:\n" << dump << "\n";
}
ASSERT_TRUE(!bad_10);
// Debug helper if something goes wrong (kept as a string for easy inspection).
EXPECT_TRUE(!to_string_rows(*buf).empty());
}

46
tests/test_search.cc Normal file
View File

@@ -0,0 +1,46 @@
#include "Test.h"
#include "OptimizedSearch.h"
#include <string>
#include <vector>
static std::vector<std::size_t>
ref_find_all(const std::string &text, const std::string &pat)
{
std::vector<std::size_t> res;
if (pat.empty())
return res;
std::size_t from = 0;
while (true) {
auto p = text.find(pat, from);
if (p == std::string::npos)
break;
res.push_back(p);
from = p + pat.size();
}
return res;
}
TEST(OptimizedSearch_basic_cases)
{
OptimizedSearch os;
struct Case {
std::string text;
std::string pat;
} cases[] = {
{"", ""},
{"", "a"},
{"a", ""},
{"a", "a"},
{"aaaaa", "aa"},
{"hello world", "world"},
{"abcabcabc", "abc"},
{"the quick brown fox", "fox"},
};
for (auto &c: cases) {
auto got = os.find_all(c.text, c.pat, 0);
auto ref = ref_find_all(c.text, c.pat);
ASSERT_EQ(got, ref);
}
}

View File

@@ -0,0 +1,129 @@
#include "Test.h"
#include "tests/TestHarness.h"
using ktet::TestHarness;
// These tests intentionally drive the prompt-based search/replace UI headlessly
// via `Execute(Editor&, CommandId, ...)` to lock down behavior without ncurses.
TEST(SearchFlow_FindStart_Success_LeavesCursorOnMatch_And_ClearsSearchState)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, "abc def abc");
b.SetCursor(0, 0);
b.SetOffsets(0, 0);
// Keep a mark set to ensure search doesn't clobber it.
b.SetMark(0, 0);
ASSERT_TRUE(b.MarkSet());
ASSERT_TRUE(h.Exec(CommandId::FindStart));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::Search);
ASSERT_TRUE(ed.SearchActive());
// Typing into the prompt uses InsertText and should jump to the first match.
ASSERT_TRUE(h.Exec(CommandId::InsertText, "def"));
ASSERT_EQ(b.Cury(), (std::size_t) 0);
ASSERT_EQ(b.Curx(), (std::size_t) 4);
// Enter (Newline) accepts the prompt and ends incremental search.
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_TRUE(!ed.SearchActive());
ASSERT_TRUE(b.MarkSet());
}
TEST(SearchFlow_FindStart_NotFound_RestoresOrigin_And_ClearsSearchState)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, "hello world\nsecond line\n");
b.SetCursor(3, 0);
b.SetOffsets(1, 2);
const std::size_t ox = b.Curx();
const std::size_t oy = b.Cury();
const std::size_t orow = b.Rowoffs();
const std::size_t ocol = b.Coloffs();
ASSERT_TRUE(h.Exec(CommandId::FindStart));
ASSERT_TRUE(ed.PromptActive());
ASSERT_TRUE(ed.SearchActive());
// Not-found should restore cursor/viewport to the saved origin while still in prompt.
ASSERT_TRUE(h.Exec(CommandId::InsertText, "zzzz"));
ASSERT_EQ(b.Curx(), ox);
ASSERT_EQ(b.Cury(), oy);
ASSERT_EQ(b.Rowoffs(), orow);
ASSERT_EQ(b.Coloffs(), ocol);
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_TRUE(!ed.SearchActive());
}
TEST(SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, "abc abc\n");
b.SetCursor(0, 0);
const std::string before = h.Text();
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind);
// Accept empty find -> proceed to ReplaceWith.
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
// Provide replacement and accept -> should cancel due to empty find.
ASSERT_TRUE(h.Exec(CommandId::InsertText, "X"));
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_TRUE(!ed.SearchActive());
ASSERT_EQ(h.Text(), before);
}
TEST(SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
b.insert_text(0, 0, "abc\ndef\n");
b.SetCursor(1, 0);
b.SetOffsets(0, 0);
const std::size_t ox = b.Curx();
const std::size_t oy = b.Cury();
ASSERT_TRUE(h.Exec(CommandId::RegexFindStart));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::RegexSearch);
// Invalid regex should not crash; cursor should remain at origin due to no matches.
ASSERT_TRUE(h.Exec(CommandId::InsertText, "("));
ASSERT_EQ(b.Curx(), ox);
ASSERT_EQ(b.Cury(), oy);
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_TRUE(!ed.SearchActive());
}

131
tests/test_swap_cleanup.cc Normal file
View File

@@ -0,0 +1,131 @@
#include "Test.h"
#include "Command.h"
#include "Editor.h"
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
namespace fs = std::filesystem;
static void
write_file_bytes(const std::string &path, const std::string &bytes)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), (std::streamsize) bytes.size());
}
TEST(SwapCleanup_ResetJournalOnSave)
{
ktet::InstallDefaultCommandsOnce();
const fs::path xdg_root = fs::temp_directory_path() /
(std::string("kte_ut_xdg_state_swap_cleanup_") + std::to_string((int) ::getpid()));
fs::remove_all(xdg_root);
fs::create_directories(xdg_root);
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
const std::string xdg_s = xdg_root.string();
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
const std::string path = (xdg_root / "work" / "file.txt").string();
fs::create_directories((xdg_root / "work"));
std::remove(path.c_str());
write_file_bytes(path, "base\n");
Editor ed;
ed.SetDimensions(24, 80);
// Seed scratch buffer so OpenFile can reuse it.
ed.AddBuffer(Buffer());
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
Buffer *b = ed.CurrentBuffer();
ASSERT_TRUE(b != nullptr);
// Edit to ensure swap is created.
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
ASSERT_TRUE(b->Dirty());
ed.Swap()->Flush(b);
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
ASSERT_TRUE(fs::exists(swp));
// Save should reset/delete the journal.
ASSERT_TRUE(Execute(ed, CommandId::Save));
ed.Swap()->Flush(b);
ASSERT_TRUE(!fs::exists(swp));
// Subsequent edits should recreate a fresh swap.
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Y"));
ed.Swap()->Flush(b);
ASSERT_TRUE(fs::exists(swp));
// Cleanup.
ed.Swap()->Detach(b);
std::remove(path.c_str());
std::remove(swp.c_str());
if (!old_xdg.empty())
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
else
unsetenv("XDG_STATE_HOME");
fs::remove_all(xdg_root);
}
TEST(SwapCleanup_PruneSwapDir_ByAge)
{
const fs::path xdg_root = fs::temp_directory_path() /
(std::string("kte_ut_xdg_state_swap_prune_") + std::to_string((int) ::getpid()));
fs::remove_all(xdg_root);
fs::create_directories(xdg_root);
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
const std::string xdg_s = xdg_root.string();
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
const fs::path swapdir = xdg_root / "kte" / "swap";
fs::create_directories(swapdir);
const fs::path oldp = swapdir / "old.swp";
const fs::path newp = swapdir / "new.swp";
const fs::path keep = swapdir / "note.txt";
write_file_bytes(oldp.string(), "x");
write_file_bytes(newp.string(), "y");
write_file_bytes(keep.string(), "z");
// Make old.swp look old (2 days ago) and new.swp recent.
std::error_code ec;
fs::last_write_time(oldp, fs::file_time_type::clock::now() - std::chrono::hours(48), ec);
fs::last_write_time(newp, fs::file_time_type::clock::now(), ec);
kte::SwapManager sm;
kte::SwapConfig cfg;
cfg.prune_on_startup = false;
cfg.prune_max_age_days = 1;
cfg.prune_max_files = 0; // disable count-based pruning for this test
sm.SetConfig(cfg);
sm.PruneSwapDir();
ASSERT_TRUE(!fs::exists(oldp));
ASSERT_TRUE(fs::exists(newp));
ASSERT_TRUE(fs::exists(keep));
// Cleanup.
std::remove(newp.string().c_str());
std::remove(keep.string().c_str());
if (!old_xdg.empty())
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
else
unsetenv("XDG_STATE_HOME");
fs::remove_all(xdg_root);
}

View File

@@ -0,0 +1,94 @@
#include "Test.h"
#include "Command.h"
#include "Editor.h"
#include "tests/TestHarness.h"
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
namespace fs = std::filesystem;
static void
write_file_bytes(const std::string &path, const std::string &bytes)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), (std::streamsize) bytes.size());
}
// Simulate git editor workflow: open file, edit, save, edit more, close.
// The swap file should be deleted on close, not left behind.
TEST(SwapCleanup_GitEditorWorkflow)
{
ktet::InstallDefaultCommandsOnce();
const fs::path xdg_root = fs::temp_directory_path() /
(std::string("kte_ut_xdg_state_git_editor_") + std::to_string((int) ::getpid()));
fs::remove_all(xdg_root);
fs::create_directories(xdg_root);
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
const std::string xdg_s = xdg_root.string();
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
// Simulate git's COMMIT_EDITMSG path
const std::string path = (xdg_root / ".git" / "COMMIT_EDITMSG").string();
fs::create_directories((xdg_root / ".git"));
std::remove(path.c_str());
write_file_bytes(path, "# Enter commit message\n");
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
Buffer *b = ed.CurrentBuffer();
ASSERT_TRUE(b != nullptr);
// User edits the file
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
ASSERT_TRUE(b->Dirty());
// User saves (git will read this)
ASSERT_TRUE(Execute(ed, CommandId::Save));
ASSERT_TRUE(!b->Dirty());
ed.Swap()->Flush(b);
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
// After save, swap should be deleted
ASSERT_TRUE(!fs::exists(swp));
// User makes more edits (common in git editor workflow - refining message)
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Y"));
ASSERT_TRUE(b->Dirty());
ed.Swap()->Flush(b);
// Now there's a new swap file for the unsaved edits
ASSERT_TRUE(fs::exists(swp));
// User closes the buffer (or kte exits)
// This simulates what happens when git is done and kte closes
const std::size_t idx = ed.CurrentBufferIndex();
ed.CloseBuffer(idx);
// The swap file should be deleted on close, even though buffer was dirty
// This prevents stale swap files when used as git editor
ASSERT_TRUE(!fs::exists(swp));
// Cleanup
std::remove(path.c_str());
if (!old_xdg.empty())
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
else
unsetenv("XDG_STATE_HOME");
fs::remove_all(xdg_root);
}

Some files were not shown because too many files have changed in this diff Show More