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 ()

1062
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)
@@ -162,4 +164,4 @@ void InstallDefaultCommands();
// Returns true if the command executed successfully.
bool Execute(Editor &ed, CommandId id, const std::string &arg = std::string(), int count = 0);
bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0);
bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0);

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

View File

@@ -127,4 +127,4 @@ GUIConfig::LoadFromFile(const std::string &path)
}
return true;
}
}

View File

@@ -30,4 +30,4 @@ public:
// Load from explicit path. Returns true if file existed and was parsed.
bool LoadFromFile(const std::string &path);
};
};

View File

@@ -942,4 +942,4 @@ SyntaxInk(const TokenKind k)
}
} // namespace kte
#endif // KTE_USE_QT
#endif // KTE_USE_QT

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

@@ -10,4 +10,4 @@ public:
// Project maintainers can customize the returned string below
// (in HelpText.cc) without touching the help command logic.
static std::string Text();
};
};

View File

@@ -34,4 +34,4 @@ struct LineHighlight {
std::vector<HighlightSpan> spans;
std::uint64_t version{0}; // buffer version used for this line
};
} // namespace kte
} // namespace kte

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;
@@ -33,4 +33,4 @@ private:
SDL_GLContext gl_ctx_ = nullptr;
int width_ = 1280;
int height_ = 800;
};
};

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 _");
@@ -598,4 +606,4 @@ ImGuiInputHandler::Poll(MappedInput &out)
out = q_.front();
q_.pop();
return true;
}
}

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);
}
}
@@ -858,4 +927,4 @@ ImGuiRenderer::Draw(Editor &ed)
ed.SetFilePickerVisible(false);
}
}
}
}

View File

@@ -11,4 +11,4 @@ public:
~ImGuiRenderer() override = default;
void Draw(Editor &ed) override;
};
};

View File

@@ -28,4 +28,4 @@ public:
// Poll for input and translate it to a command. Non-blocking.
// Returns true if a command is available in 'out'. Returns false if no input.
virtual bool Poll(MappedInput &out) = 0;
};
};

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;
@@ -218,4 +230,4 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
break;
}
return false;
}
}

View File

@@ -27,4 +27,4 @@ KLowerAscii(const int key)
if (key >= 'A' && key <= 'Z')
return key + ('a' - 'A');
return key;
}
}

View File

@@ -20,4 +20,4 @@ private:
std::string last_pat_;
void build_bad_char(const std::string &pattern);
};
};

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;
@@ -33,4 +33,4 @@ private:
QWidget *window_ = nullptr; // owned
int width_ = 1280;
int height_ = 800;
};
};

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_
->
UArg() != 0
)
{
if (ed_ && ed_
->
UArg() != 0
) {
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
int d = e.key() - Qt::Key_0;
@@ -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) {
@@ -535,4 +533,4 @@ QtInputHandler::Poll(MappedInput &out)
out = q_.front();
q_.pop();
return true;
}
}

View File

@@ -37,4 +37,4 @@ private:
bool esc_meta_ = false; // ESC-prefix for next key
bool suppress_text_input_once_ = false; // reserved (Qt sends text in keyPressEvent)
Editor *ed_ = nullptr;
};
};

View File

@@ -73,4 +73,4 @@ QtRenderer::Draw(Editor &ed)
}
// Request a repaint
widget_->update();
}
}

View File

@@ -24,4 +24,4 @@ public:
private:
QWidget *widget_ = nullptr; // not owned
};
};

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

View File

@@ -10,4 +10,4 @@ public:
virtual ~Renderer() = default;
virtual void Draw(Editor &ed) = 0;
};
};

1119
Swap.cc

File diff suppressed because it is too large Load Diff

147
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,10 +213,14 @@ 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_;
};
} // namespace kte
} // namespace kte

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)
@@ -298,4 +329,4 @@ TerminalInputHandler::Poll(MappedInput &out)
{
out = {};
return decode_(out) && out.hasCommand;
}
}

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";
}
}
@@ -515,4 +615,4 @@ TerminalRenderer::Draw(Editor &ed)
}
refresh();
}
}

View File

@@ -12,4 +12,4 @@ public:
~TerminalRenderer() override;
void Draw(Editor &ed) override;
};
};

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;
@@ -34,4 +34,4 @@ public:
private:
TestInputHandler input_{};
TestRenderer renderer_{};
};
};

View File

@@ -27,4 +27,4 @@ public:
private:
std::queue<MappedInput> queue_;
};
};

View File

@@ -29,4 +29,4 @@ public:
private:
std::size_t draw_count_ = 0;
};
};

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);
}
@@ -58,4 +60,4 @@ private:
std::size_t block_size_;
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
std::stack<UndoNode *> available_;
};
};

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;
}
@@ -247,4 +452,4 @@ UndoSystem::debug_log(const char *op) const
#else
(void) op;
#endif
}
}

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

@@ -7,4 +7,4 @@ struct UndoTree {
UndoNode *current = nullptr; // current state of buffer
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
UndoNode *pending = nullptr; // in-progress batch (detached)
};
};

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; };
});
};
}

View File

@@ -11884,4 +11884,4 @@ static const unsigned int DefaultFontRegularCompressedData[72616 / 4] =
0x0070a96e,
};
}
}
}

5438
fonts/BerkeleyMono.h Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5586,4 +5586,4 @@ static const unsigned int DefaultFontBoldCompressedData[32380 / 4] =
0x0e01060e, 0xff01b82a, 0x8d04b085,
0x440002b1, 0x066405b3, 0x00444400, 0x01000000, 0x00000000, 0xfacafa05, 0x00004aa6,
};
}
}

View File

@@ -5918,4 +5918,4 @@ static const unsigned int DefaultFontRegularCompressedData[34064 / 4] =
0x20080082, 0x01060eb3, 0x01b82a0e,
0x04b085ff, 0x0002b18d, 0x6405b344, 0x44440006, 0x00000000, 0x00000001, 0xccfa0500, 0x0030ee4f,
};
}
}

View File

@@ -2851,4 +2851,4 @@ static const unsigned int DefaultFontRegularCompressedData[68288 / 4] =
0x820a2003, 0x421e20c3, 0x18821057,
0x21830284, 0xdeda0024, 0x0d83c5d7, 0xa48ad12b, 0x0000001e, 0xa48ad100, 0x62fa051e, 0x00a176d4,
};
}
}

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
} // 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:
@@ -31,4 +31,4 @@ private:
const unsigned int *data_{nullptr};
unsigned int size_{0};
};
}
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include "B612Mono.h"
#include "BerkeleyMono.h"
#include "BrassMono.h"
#include "BrassMonoCode.h"
#include "FiraCode.h"
@@ -14,4 +15,4 @@
#include "SpaceMono.h"
#include "Syne.h"
#include "Triplicate.h"
#include "Unispace.h"
#include "Unispace.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
));
@@ -91,4 +111,4 @@ InstallDefaultFonts()
Unispace::DefaultFontRegularCompressedSize
));
}
}
}

View File

@@ -119,4 +119,4 @@ private:
void InstallDefaultFonts();
}
}

25743
fonts/Go.h

File diff suppressed because it is too large Load Diff

View File

@@ -13671,4 +13671,4 @@ static const unsigned int DefaultFontItalicCompressedData[84884 / 4] =
0x4f1aea4f, 0x0d2022dc, 0x211fdc4f,
0x164f2804, 0x8b952711, 0x236f6f19, 0xfa050081, 0x0bda00e5,
};
}
}

View File

@@ -6231,4 +6231,4 @@ static const unsigned int DefaultFontRegularCompressedData[149388 / 4] =
0x86382043, 0x870f8383, 0x022e2463,
0x05480066, 0x599623fa, 0x00000043,
};
}
}

View File

@@ -5632,4 +5632,4 @@ static const unsigned int DefaultFontRegularCompressedData[66932 / 4] =
0xc6221786, 0x1788a203, 0x86008524,
0x2f88e200, 0x1788a520, 0xf6ffb424, 0xfa05fa00, 0xc234b8e9,
};
}
}

View File

@@ -5737,4 +5737,4 @@ static const unsigned int DefaultFontBoldCompressedData[68920 / 4] =
0x86910006, 0x03c32217, 0x241788b7, 0x00ce0085, 0x202f88e4, 0x261788b5, 0x00ebffb4, 0x050000fe, 0x877646fa,
0x000000de,
};
}
}

File diff suppressed because it is too large Load Diff

48
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);
@@ -315,4 +301,4 @@ main(int argc, const char *argv[])
fe->Shutdown();
return 0;
}
}

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

@@ -32,4 +32,4 @@ private:
static bool is_ident_char(char c);
};
} // namespace kte
} // namespace kte

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

@@ -14,4 +14,4 @@ public:
private:
std::unordered_set<std::string> kws_;
};
} // namespace kte
} // namespace kte

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

@@ -14,4 +14,4 @@ public:
private:
std::unordered_set<std::string> kws_;
};
} // namespace kte
} // namespace kte

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

@@ -15,4 +15,4 @@ private:
std::unordered_set<std::string> kws_;
std::unordered_set<std::string> types_;
};
} // namespace kte
} // namespace kte

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;
}

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