Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 895e4ccb1e | |||
| 15b350bfaa | |||
| cc8df36bdf | |||
| 1c0f04f076 | |||
| ac0eadc345 | |||
| f3bdced3d4 | |||
| 2551388420 | |||
| d2d155f211 | |||
| 8634eb78f0 | |||
| 6eb240a0c4 | |||
| 4c402f5ef3 | |||
| a8abda4b87 | |||
| 7347556aa2 | |||
| 289e155c98 | |||
| 147a52f3d4 | |||
| dda7541e2f | |||
| 2408f5494c | |||
| 2542690eca | |||
| cc0c187481 | |||
| a8dcfbec58 | |||
| 65705e3354 | |||
| e1f9a9eb6a | |||
| c9f34003f2 | |||
| f450ef825c | |||
| f6f0c11be4 | |||
| 657c9bbc19 | |||
| 3493695165 | |||
| 5f57cf23dc | |||
| 9312550be4 | |||
| f734f98891 | |||
| 1191e14ce9 | |||
| 12cc04d7e0 | |||
| 3f4c60d311 | |||
| 71c1c9e50b | |||
| afb6888c31 | |||
| 222f73252b | |||
| 51ea473a91 | |||
| fd517b5d57 |
1
.idea/codeStyles/codeStyleConfig.xml
generated
1
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -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
3
.idea/editor.xml
generated
@@ -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
2
.idea/kte.iml
generated
@@ -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="" />
|
||||
|
||||
@@ -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 ke’s UX unless explicitly changing
|
||||
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
||||
changing
|
||||
behavior.
|
||||
|
||||
## References
|
||||
|
||||
- Previous editor manual: `ke.md` (canonical keybinding/spec reference for now).
|
||||
- Previous editor manual: `ke.md` (canonical keybinding/spec reference
|
||||
for now).
|
||||
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
315
Buffer.cc
315
Buffer.cc
@@ -2,8 +2,13 @@
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include <cstdlib>
|
||||
#include <limits>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <string_view>
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "SwapRecorder.h"
|
||||
#include "UndoSystem.h"
|
||||
#include "UndoTree.h"
|
||||
// For reconstructing highlighter state on copies
|
||||
@@ -36,6 +41,8 @@ Buffer::Buffer(const Buffer &other)
|
||||
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_;
|
||||
@@ -84,6 +91,8 @@ Buffer::operator=(const Buffer &other)
|
||||
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_;
|
||||
@@ -141,6 +150,8 @@ Buffer::Buffer(Buffer &&other) noexcept
|
||||
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);
|
||||
@@ -177,7 +188,8 @@ Buffer::operator=(Buffer &&other) noexcept
|
||||
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 +241,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,47 +254,20 @@ 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;
|
||||
{
|
||||
// Read entire file into PieceTable as-is
|
||||
std::string data;
|
||||
in.seekg(0, std::ios::end);
|
||||
std::streamoff sz = in.tellg();
|
||||
auto 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();
|
||||
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();
|
||||
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;
|
||||
@@ -310,20 +299,18 @@ Buffer::Save(std::string &err) const
|
||||
}
|
||||
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
err = "Failed to open for write: " + filename_;
|
||||
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
||||
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');
|
||||
}
|
||||
// Stream the content directly from the piece table to avoid relying on
|
||||
// full materialization, which may yield an empty pointer when size > 0.
|
||||
if (content_.Size() > 0) {
|
||||
content_.WriteToStream(out);
|
||||
}
|
||||
// Ensure data hits the OS buffers
|
||||
out.flush();
|
||||
if (!out.good()) {
|
||||
err = "Write error";
|
||||
err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
||||
return false;
|
||||
}
|
||||
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
||||
@@ -357,20 +344,17 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
||||
// 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;
|
||||
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
||||
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');
|
||||
}
|
||||
// Stream content without forcing full materialization
|
||||
if (content_.Size() > 0) {
|
||||
content_.WriteToStream(out);
|
||||
}
|
||||
// Ensure data hits the OS buffers
|
||||
out.flush();
|
||||
if (!out.good()) {
|
||||
err = "Write error";
|
||||
err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -389,7 +373,7 @@ Buffer::AsString() const
|
||||
if (this->Dirty()) {
|
||||
ss << "*";
|
||||
}
|
||||
ss << ">: " << rows_.size() << " lines";
|
||||
ss << ">: " << content_.LineCount() << " lines";
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
@@ -400,111 +384,151 @@ 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;
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
// ===== 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));
|
||||
}
|
||||
// Do not set dirty here; UndoSystem will manage state/dirty externally
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
||||
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 +537,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 +557,21 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
188
Buffer.h
188
Buffer.h
@@ -9,12 +9,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 {
|
||||
@@ -63,7 +63,7 @@ public:
|
||||
|
||||
[[nodiscard]] std::size_t Nrows() const
|
||||
{
|
||||
return nrows_;
|
||||
return content_LineCount_();
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,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 +109,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 +218,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 +370,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 +468,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();
|
||||
}
|
||||
@@ -450,7 +509,7 @@ public:
|
||||
void delete_row(int row);
|
||||
|
||||
// Undo system accessors (created per-buffer)
|
||||
UndoSystem *Undo();
|
||||
[[nodiscard]] UndoSystem *Undo();
|
||||
|
||||
[[nodiscard]] const UndoSystem *Undo() const;
|
||||
|
||||
@@ -460,13 +519,26 @@ private:
|
||||
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 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 +551,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_;
|
||||
};
|
||||
@@ -4,14 +4,13 @@ project(kte)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(KTE_VERSION "1.4.1")
|
||||
set(KTE_VERSION "1.6.1")
|
||||
|
||||
# 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,7 +63,7 @@ endif ()
|
||||
|
||||
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
||||
|
||||
if (${BUILD_GUI})
|
||||
if (BUILD_GUI)
|
||||
include(cmake/imgui.cmake)
|
||||
endif ()
|
||||
|
||||
@@ -128,7 +127,6 @@ if (BUILD_GUI)
|
||||
endif ()
|
||||
|
||||
set(COMMON_SOURCES
|
||||
GapBuffer.cc
|
||||
PieceTable.cc
|
||||
Buffer.cc
|
||||
Editor.cc
|
||||
@@ -210,14 +208,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 +256,7 @@ if (BUILD_GUI)
|
||||
ImGuiFrontend.h
|
||||
ImGuiInputHandler.h
|
||||
ImGuiRenderer.h
|
||||
fonts/BerkeleyMono.h
|
||||
)
|
||||
endif ()
|
||||
endif ()
|
||||
@@ -270,9 +268,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 +294,61 @@ 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_piece_table.cc
|
||||
tests/test_search.cc
|
||||
tests/test_search_replace_flow.cc
|
||||
tests/test_reflow_paragraph.cc
|
||||
tests/test_undo.cc
|
||||
tests/test_visual_line_mode.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 +403,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 +438,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 ()
|
||||
|
||||
817
Command.cc
817
Command.cc
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
25
Editor.cc
25
Editor.cc
@@ -128,8 +128,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 +143,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;
|
||||
@@ -171,8 +171,8 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
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)
|
||||
@@ -197,6 +197,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,12 +207,8 @@ 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 = "";
|
||||
@@ -237,7 +235,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -280,6 +283,10 @@ Editor::CloseBuffer(std::size_t index)
|
||||
if (index >= buffers_.size()) {
|
||||
return false;
|
||||
}
|
||||
if (swap_) {
|
||||
swap_->Detach(&buffers_[index]);
|
||||
buffers_[index].SetSwapRecorder(nullptr);
|
||||
}
|
||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
if (buffers_.empty()) {
|
||||
curbuf_ = 0;
|
||||
|
||||
@@ -12,7 +12,7 @@ public:
|
||||
virtual ~Frontend() = default;
|
||||
|
||||
// Initialize the frontend (create window/terminal, etc.)
|
||||
virtual bool Init(Editor &ed) = 0;
|
||||
virtual bool Init(int &argc, char **argv, Editor &ed) = 0;
|
||||
|
||||
// Execute one iteration (poll input, dispatch, draw). Set running=false to exit.
|
||||
virtual void Step(Editor &ed, bool &running) = 0;
|
||||
|
||||
204
GapBuffer.cc
204
GapBuffer.cc
@@ -1,204 +0,0 @@
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
|
||||
|
||||
GapBuffer::GapBuffer() = default;
|
||||
|
||||
|
||||
GapBuffer::GapBuffer(std::size_t initialCapacity)
|
||||
: buffer_(nullptr), size_(0), capacity_(0)
|
||||
{
|
||||
if (initialCapacity > 0) {
|
||||
Reserve(initialCapacity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
GapBuffer::GapBuffer(const GapBuffer &other)
|
||||
: buffer_(nullptr), size_(0), capacity_(0)
|
||||
{
|
||||
if (other.capacity_ > 0) {
|
||||
Reserve(other.capacity_);
|
||||
if (other.size_ > 0) {
|
||||
std::memcpy(buffer_, other.buffer_, other.size_);
|
||||
size_ = other.size_;
|
||||
}
|
||||
setTerminator();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
GapBuffer &
|
||||
GapBuffer::operator=(const GapBuffer &other)
|
||||
{
|
||||
if (this == &other)
|
||||
return *this;
|
||||
if (other.capacity_ > capacity_) {
|
||||
Reserve(other.capacity_);
|
||||
}
|
||||
if (other.size_ > 0) {
|
||||
std::memcpy(buffer_, other.buffer_, other.size_);
|
||||
}
|
||||
size_ = other.size_;
|
||||
setTerminator();
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
GapBuffer::GapBuffer(GapBuffer &&other) noexcept
|
||||
: buffer_(other.buffer_), size_(other.size_), capacity_(other.capacity_)
|
||||
{
|
||||
other.buffer_ = nullptr;
|
||||
other.size_ = 0;
|
||||
other.capacity_ = 0;
|
||||
}
|
||||
|
||||
|
||||
GapBuffer &
|
||||
GapBuffer::operator=(GapBuffer &&other) noexcept
|
||||
{
|
||||
if (this == &other)
|
||||
return *this;
|
||||
delete[] buffer_;
|
||||
buffer_ = other.buffer_;
|
||||
size_ = other.size_;
|
||||
capacity_ = other.capacity_;
|
||||
other.buffer_ = nullptr;
|
||||
other.size_ = 0;
|
||||
other.capacity_ = 0;
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
GapBuffer::~GapBuffer()
|
||||
{
|
||||
delete[] buffer_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Reserve(const std::size_t newCapacity)
|
||||
{
|
||||
if (newCapacity <= capacity_) [[likely]]
|
||||
return;
|
||||
// Allocate space for terminator as well
|
||||
char *nb = new char[newCapacity + 1];
|
||||
if (size_ > 0 && buffer_) {
|
||||
std::memcpy(nb, buffer_, size_);
|
||||
}
|
||||
delete[] buffer_;
|
||||
buffer_ = nb;
|
||||
capacity_ = newCapacity;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::AppendChar(const char c)
|
||||
{
|
||||
ensureCapacityFor(1);
|
||||
buffer_[size_++] = c;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Append(const char *s, const std::size_t len)
|
||||
{
|
||||
if (!s || len == 0) [[unlikely]]
|
||||
return;
|
||||
ensureCapacityFor(len);
|
||||
std::memcpy(buffer_ + size_, s, len);
|
||||
size_ += len;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Append(const GapBuffer &other)
|
||||
{
|
||||
if (other.size_ == 0)
|
||||
return;
|
||||
Append(other.buffer_, other.size_);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::PrependChar(char c)
|
||||
{
|
||||
ensureCapacityFor(1);
|
||||
// shift right by 1
|
||||
if (size_ > 0) [[likely]] {
|
||||
std::memmove(buffer_ + 1, buffer_, size_);
|
||||
}
|
||||
buffer_[0] = c;
|
||||
++size_;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Prepend(const char *s, std::size_t len)
|
||||
{
|
||||
if (!s || len == 0) [[unlikely]]
|
||||
return;
|
||||
ensureCapacityFor(len);
|
||||
if (size_ > 0) [[likely]] {
|
||||
std::memmove(buffer_ + len, buffer_, size_);
|
||||
}
|
||||
std::memcpy(buffer_, s, len);
|
||||
size_ += len;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Prepend(const GapBuffer &other)
|
||||
{
|
||||
if (other.size_ == 0)
|
||||
return;
|
||||
Prepend(other.buffer_, other.size_);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Clear()
|
||||
{
|
||||
size_ = 0;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::ensureCapacityFor(std::size_t delta)
|
||||
{
|
||||
if (capacity_ - size_ >= delta) [[likely]]
|
||||
return;
|
||||
auto required = size_ + delta;
|
||||
Reserve(growCapacity(capacity_, required));
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
GapBuffer::growCapacity(std::size_t current, std::size_t required)
|
||||
{
|
||||
// geometric growth, at least required
|
||||
std::size_t newCap = current ? current : 8;
|
||||
while (newCap < required)
|
||||
newCap = newCap + (newCap >> 1); // 1.5x growth
|
||||
return newCap;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::setTerminator() const
|
||||
{
|
||||
if (!buffer_) {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer_[size_] = '\0';
|
||||
}
|
||||
76
GapBuffer.h
76
GapBuffer.h
@@ -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
|
||||
};
|
||||
@@ -31,6 +31,7 @@ HelpText::Text()
|
||||
" C-k c Close current buffer\n"
|
||||
" C-k d Kill to end of line\n"
|
||||
" C-k e Open file (prompt)\n"
|
||||
" C-k i New empty buffer\n"
|
||||
" C-k f Flush kill ring\n"
|
||||
" C-k g Jump to line\n"
|
||||
" C-k h Show this help\n"
|
||||
|
||||
@@ -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
|
||||
@@ -262,10 +265,10 @@ 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 row_h = ImGui::GetTextLineHeightWithSpacing();
|
||||
float ch_w = ImGui::CalcTextSize("M").x;
|
||||
if (line_h <= 0.0f)
|
||||
line_h = 16.0f;
|
||||
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
|
||||
@@ -357,14 +360,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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,26 +295,35 @@ 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;
|
||||
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 < repeat; ++i) {
|
||||
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 false;
|
||||
}
|
||||
case SDL_KEYDOWN: {
|
||||
@@ -463,8 +473,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 _");
|
||||
|
||||
@@ -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
|
||||
};
|
||||
365
ImGuiRenderer.cc
365
ImGuiRenderer.cc
@@ -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
|
||||
// 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;
|
||||
|
||||
// Iterate through source columns, computing rendered position, to find closest match
|
||||
std::size_t rx = 0; // rendered column position
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1;
|
||||
}
|
||||
}
|
||||
return {by, best_col};
|
||||
};
|
||||
|
||||
// Dispatch absolute buffer coordinates (row: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, best_col);
|
||||
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();
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
KKeymap.cc
12
KKeymap.cc
@@ -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;
|
||||
|
||||
575
PieceTable.cc
575
PieceTable.cc
@@ -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_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +221,9 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
|
||||
last.len += len;
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -179,6 +232,10 @@ 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;
|
||||
InvalidateLineIndex();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -197,18 +254,26 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
|
||||
first.len += len;
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
}
|
||||
pieces_.insert(pieces_.begin(), Piece{src, start, len});
|
||||
total_size_ += len;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
93
PieceTable.h
93
PieceTable.h
@@ -3,8 +3,12 @@
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <ostream>
|
||||
#include <vector>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
|
||||
|
||||
class PieceTable {
|
||||
@@ -13,6 +17,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 +78,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,6 +125,24 @@ 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_;
|
||||
@@ -90,5 +150,38 @@ private:
|
||||
|
||||
mutable std::string materialized_;
|
||||
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_;
|
||||
};
|
||||
@@ -144,7 +144,9 @@ 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]);
|
||||
// Materialize the Buffer::Line into a std::string for
|
||||
// regex/iterator usage and general string ops.
|
||||
const std::string line = static_cast<std::string>(lines[i]);
|
||||
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
|
||||
const int baseline = y + fm.ascent();
|
||||
|
||||
@@ -656,10 +658,8 @@ 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);
|
||||
|
||||
window_ = new MainWindow(input_);
|
||||
|
||||
@@ -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
README.md
33
README.md
@@ -32,7 +32,8 @@ Project Goals
|
||||
|
||||
Keybindings
|
||||
-----------
|
||||
kte maintains ke’s command model while internals evolve. Highlights (subject to refinement):
|
||||
kte maintains ke’s command model while internals evolve. Highlights (
|
||||
subject to refinement):
|
||||
|
||||
- K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or
|
||||
`C-g`.
|
||||
@@ -52,7 +53,8 @@ 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 +64,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 +123,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
2502
REWRITE.md
Normal file
File diff suppressed because it is too large
Load Diff
607
Swap.cc
607
Swap.cc
@@ -5,6 +5,9 @@
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
@@ -18,8 +21,51 @@ namespace {
|
||||
constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
|
||||
constexpr std::uint32_t VERSION = 1;
|
||||
|
||||
|
||||
static fs::path
|
||||
xdg_state_home()
|
||||
{
|
||||
if (const char *p = std::getenv("XDG_STATE_HOME")) {
|
||||
if (*p)
|
||||
return fs::path(p);
|
||||
}
|
||||
if (const char *home = std::getenv("HOME")) {
|
||||
if (*home)
|
||||
return fs::path(home) / ".local" / "state";
|
||||
}
|
||||
// Last resort: still provide a stable per-user-ish location.
|
||||
return fs::temp_directory_path() / "kte" / "state";
|
||||
}
|
||||
|
||||
|
||||
static std::uint64_t
|
||||
fnv1a64(std::string_view s)
|
||||
{
|
||||
std::uint64_t h = 14695981039346656037ULL;
|
||||
for (unsigned char ch: s) {
|
||||
h ^= (std::uint64_t) ch;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
hex_u64(std::uint64_t v)
|
||||
{
|
||||
static const char *kHex = "0123456789abcdef";
|
||||
char out[16];
|
||||
for (int i = 15; i >= 0; --i) {
|
||||
out[i] = kHex[v & 0xFULL];
|
||||
v >>= 4;
|
||||
}
|
||||
return std::string(out, sizeof(out));
|
||||
}
|
||||
|
||||
|
||||
// Write all bytes in buf to fd, handling EINTR and partial writes.
|
||||
static bool write_full(int fd, const void *buf, size_t len)
|
||||
static bool
|
||||
write_full(int fd, const void *buf, size_t len)
|
||||
{
|
||||
const std::uint8_t *p = static_cast<const std::uint8_t *>(buf);
|
||||
while (len > 0) {
|
||||
@@ -50,6 +96,8 @@ SwapManager::SwapManager()
|
||||
|
||||
SwapManager::~SwapManager()
|
||||
{
|
||||
// Best-effort: drain queued records before stopping the writer.
|
||||
Flush();
|
||||
running_.store(false);
|
||||
cv_.notify_all();
|
||||
if (worker_.joinable())
|
||||
@@ -62,30 +110,108 @@ SwapManager::~SwapManager()
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Attach(Buffer * /*buf*/)
|
||||
SwapManager::Flush(Buffer *buf)
|
||||
{
|
||||
// Stage 1: lazy-open on first record; nothing to do here.
|
||||
(void) buf; // stage 1: flushes all buffers
|
||||
std::unique_lock<std::mutex> lk(mtx_);
|
||||
const std::uint64_t target = next_seq_;
|
||||
// Wake the writer in case it's waiting on the interval.
|
||||
cv_.notify_one();
|
||||
cv_.wait(lk, [&] {
|
||||
return queue_.empty() && inflight_ == 0 && last_processed_ >= target;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Detach(Buffer * /*buf*/)
|
||||
SwapManager::BufferRecorder::OnInsert(int row, int col, std::string_view bytes)
|
||||
{
|
||||
// Stage 1: keep files open until manager destruction; future work can close per-buffer.
|
||||
m_.RecordInsert(buf_, row, col, bytes);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::BufferRecorder::OnDelete(int row, int col, std::size_t len)
|
||||
{
|
||||
m_.RecordDelete(buf_, row, col, len);
|
||||
}
|
||||
|
||||
|
||||
SwapRecorder *
|
||||
SwapManager::RecorderFor(Buffer *buf)
|
||||
{
|
||||
if (!buf)
|
||||
return nullptr;
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = recorders_.find(buf);
|
||||
if (it != recorders_.end())
|
||||
return it->second.get();
|
||||
// Create on-demand. Recording calls will no-op until Attach() has been called.
|
||||
auto rec = std::make_unique<BufferRecorder>(*this, *buf);
|
||||
SwapRecorder *ptr = rec.get();
|
||||
recorders_[buf] = std::move(rec);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Attach(Buffer *buf)
|
||||
{
|
||||
if (!buf)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
JournalCtx &ctx = journals_[buf];
|
||||
if (ctx.path.empty())
|
||||
ctx.path = ComputeSidecarPath(*buf);
|
||||
// Ensure a recorder exists as well.
|
||||
if (recorders_.find(buf) == recorders_.end()) {
|
||||
recorders_[buf] = std::make_unique<BufferRecorder>(*this, *buf);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Detach(Buffer *buf)
|
||||
{
|
||||
if (!buf)
|
||||
return;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(buf);
|
||||
if (it != journals_.end()) {
|
||||
it->second.suspended = true;
|
||||
}
|
||||
}
|
||||
Flush(buf);
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(buf);
|
||||
if (it != journals_.end()) {
|
||||
close_ctx(it->second);
|
||||
journals_.erase(it);
|
||||
}
|
||||
recorders_.erase(buf);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::NotifyFilenameChanged(Buffer &buf)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
it->second.suspended = true;
|
||||
}
|
||||
Flush(&buf);
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
JournalCtx &ctx = it->second;
|
||||
// Close existing file handle, update path; lazily reopen on next write
|
||||
close_ctx(ctx);
|
||||
ctx.path = ComputeSidecarPath(buf);
|
||||
ctx.suspended = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,47 +219,100 @@ void
|
||||
SwapManager::SetSuspended(Buffer &buf, bool on)
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto path = ComputeSidecarPath(buf);
|
||||
// Create/update context for this buffer
|
||||
JournalCtx &ctx = journals_[&buf];
|
||||
ctx.path = path;
|
||||
ctx.suspended = on;
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
it->second.suspended = on;
|
||||
}
|
||||
|
||||
|
||||
SwapManager::SuspendGuard::SuspendGuard(SwapManager &m, Buffer *b)
|
||||
: m_(m), buf_(b), prev_(false)
|
||||
{
|
||||
// Suspend recording while guard is alive
|
||||
if (buf_)
|
||||
m_.SetSuspended(*buf_, true);
|
||||
if (!buf_)
|
||||
return;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(m_.mtx_);
|
||||
auto it = m_.journals_.find(buf_);
|
||||
if (it != m_.journals_.end()) {
|
||||
prev_ = it->second.suspended;
|
||||
it->second.suspended = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SwapManager::SuspendGuard::~SuspendGuard()
|
||||
{
|
||||
if (buf_)
|
||||
m_.SetSuspended(*buf_, false);
|
||||
if (!buf_)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lg(m_.mtx_);
|
||||
auto it = m_.journals_.find(buf_);
|
||||
if (it != m_.journals_.end()) {
|
||||
it->second.suspended = prev_;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
SwapManager::ComputeSidecarPath(const Buffer &buf)
|
||||
{
|
||||
if (buf.IsFileBacked() || !buf.Filename().empty()) {
|
||||
fs::path p(buf.Filename());
|
||||
fs::path dir = p.parent_path();
|
||||
std::string base = p.filename().string();
|
||||
std::string side = "." + base + ".kte.swp";
|
||||
return (dir / side).string();
|
||||
// Always place swap under an XDG home-appropriate state directory.
|
||||
// This avoids cluttering working directories and prevents stomping on
|
||||
// swap files when multiple different paths share the same basename.
|
||||
fs::path root = xdg_state_home() / "kte" / "swap";
|
||||
|
||||
auto encode_path = [](std::string s) -> std::string {
|
||||
// Turn an absolute path like "/home/kyle/tmp/test.txt" into
|
||||
// "home!kyle!tmp!test.txt" so swap files are human-identifiable.
|
||||
//
|
||||
// Notes:
|
||||
// - We strip a single leading path separator so absolute paths don't start with '!'.
|
||||
// - We replace both '/' and '\\' with '!'.
|
||||
// - We leave other characters as-is (spaces are OK on POSIX).
|
||||
if (!s.empty() && (s[0] == '/' || s[0] == '\\'))
|
||||
s.erase(0, 1);
|
||||
for (char &ch: s) {
|
||||
if (ch == '/' || ch == '\\')
|
||||
ch = '!';
|
||||
}
|
||||
// unnamed: $TMPDIR/kte/unnamed-<ptr>.kte.swp (best-effort)
|
||||
const char *tmp = std::getenv("TMPDIR");
|
||||
fs::path t = tmp ? fs::path(tmp) : fs::temp_directory_path();
|
||||
fs::path d = t / "kte";
|
||||
char bufptr[32];
|
||||
std::snprintf(bufptr, sizeof(bufptr), "%p", (const void *) &buf);
|
||||
return (d / (std::string("unnamed-") + bufptr + ".kte.swp")).string();
|
||||
return s;
|
||||
};
|
||||
|
||||
if (!buf.Filename().empty()) {
|
||||
fs::path p(buf.Filename());
|
||||
std::string key;
|
||||
try {
|
||||
key = fs::weakly_canonical(p).string();
|
||||
} catch (...) {
|
||||
try {
|
||||
key = fs::absolute(p).string();
|
||||
} catch (...) {
|
||||
key = buf.Filename();
|
||||
}
|
||||
}
|
||||
std::string encoded = encode_path(key);
|
||||
if (!encoded.empty()) {
|
||||
std::string name = encoded + ".swp";
|
||||
// Avoid filesystem/path length issues; fall back to hashed naming.
|
||||
// NAME_MAX is often 255 on POSIX, but keep extra headroom.
|
||||
if (name.size() <= 200) {
|
||||
return (root / name).string();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: stable, shorter name based on basename + hash.
|
||||
std::string base = p.filename().string();
|
||||
const std::string name = base + "." + hex_u64(fnv1a64(key)) + ".swp";
|
||||
return (root / name).string();
|
||||
}
|
||||
|
||||
// Unnamed buffers: unique within the process.
|
||||
static std::atomic<std::uint64_t> ctr{0};
|
||||
const std::uint64_t n = ++ctr;
|
||||
const int pid = (int) ::getpid();
|
||||
const std::string name = "unnamed-" + std::to_string(pid) + "-" + std::to_string(n) + ".swp";
|
||||
return (root / name).string();
|
||||
}
|
||||
|
||||
|
||||
@@ -163,53 +342,69 @@ SwapManager::ensure_parent_dir(const std::string &path)
|
||||
|
||||
|
||||
bool
|
||||
SwapManager::write_header(JournalCtx &ctx)
|
||||
SwapManager::write_header(int fd)
|
||||
{
|
||||
if (ctx.fd < 0)
|
||||
if (fd < 0)
|
||||
return false;
|
||||
// Write a simple 64-byte header
|
||||
// Fixed 64-byte header (v1)
|
||||
// [magic 8][version u32][flags u32][created_time u64][reserved/padding]
|
||||
std::uint8_t hdr[64];
|
||||
std::memset(hdr, 0, sizeof(hdr));
|
||||
std::memcpy(hdr, MAGIC, 8);
|
||||
std::uint32_t ver = VERSION;
|
||||
std::memcpy(hdr + 8, &ver, sizeof(ver));
|
||||
// version (little-endian)
|
||||
hdr[8] = static_cast<std::uint8_t>(VERSION & 0xFFu);
|
||||
hdr[9] = static_cast<std::uint8_t>((VERSION >> 8) & 0xFFu);
|
||||
hdr[10] = static_cast<std::uint8_t>((VERSION >> 16) & 0xFFu);
|
||||
hdr[11] = static_cast<std::uint8_t>((VERSION >> 24) & 0xFFu);
|
||||
// flags = 0
|
||||
// created_time (unix seconds; little-endian)
|
||||
std::uint64_t ts = static_cast<std::uint64_t>(std::time(nullptr));
|
||||
std::memcpy(hdr + 16, &ts, sizeof(ts));
|
||||
ssize_t w = ::write(ctx.fd, hdr, sizeof(hdr));
|
||||
return (w == (ssize_t) sizeof(hdr));
|
||||
put_le64(hdr + 16, ts);
|
||||
return write_full(fd, hdr, sizeof(hdr));
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
SwapManager::open_ctx(JournalCtx &ctx)
|
||||
SwapManager::open_ctx(JournalCtx &ctx, const std::string &path)
|
||||
{
|
||||
if (ctx.fd >= 0)
|
||||
return true;
|
||||
if (!ensure_parent_dir(ctx.path))
|
||||
if (!ensure_parent_dir(path))
|
||||
return false;
|
||||
// Create or open with 0600 perms
|
||||
int fd = ::open(ctx.path.c_str(), O_CREAT | O_RDWR, 0600);
|
||||
int flags = O_CREAT | O_WRONLY | O_APPEND;
|
||||
#ifdef O_CLOEXEC
|
||||
flags |= O_CLOEXEC;
|
||||
#endif
|
||||
int fd = ::open(path.c_str(), flags, 0600);
|
||||
if (fd < 0)
|
||||
return false;
|
||||
// Detect if file is new/empty to write header
|
||||
// Ensure permissions even if file already existed.
|
||||
(void) ::fchmod(fd, 0600);
|
||||
struct stat st{};
|
||||
if (fstat(fd, &st) != 0) {
|
||||
::close(fd);
|
||||
return false;
|
||||
}
|
||||
ctx.fd = fd;
|
||||
ctx.file = fdopen(fd, "ab");
|
||||
if (!ctx.file) {
|
||||
// If an existing file is too small to contain the fixed header, truncate
|
||||
// and restart.
|
||||
if (st.st_size > 0 && st.st_size < 64) {
|
||||
::close(fd);
|
||||
ctx.fd = -1;
|
||||
int tflags = O_CREAT | O_WRONLY | O_TRUNC | O_APPEND;
|
||||
#ifdef O_CLOEXEC
|
||||
tflags |= O_CLOEXEC;
|
||||
#endif
|
||||
fd = ::open(path.c_str(), tflags, 0600);
|
||||
if (fd < 0)
|
||||
return false;
|
||||
(void) ::fchmod(fd, 0600);
|
||||
st.st_size = 0;
|
||||
}
|
||||
ctx.fd = fd;
|
||||
ctx.path = path;
|
||||
if (st.st_size == 0) {
|
||||
ctx.header_ok = write_header(ctx);
|
||||
ctx.header_ok = write_header(fd);
|
||||
} else {
|
||||
ctx.header_ok = true; // trust existing file for stage 1
|
||||
// Seek to end to append
|
||||
::lseek(ctx.fd, 0, SEEK_END);
|
||||
ctx.header_ok = true; // stage 1: trust existing header
|
||||
}
|
||||
return ctx.header_ok;
|
||||
}
|
||||
@@ -218,16 +413,12 @@ SwapManager::open_ctx(JournalCtx &ctx)
|
||||
void
|
||||
SwapManager::close_ctx(JournalCtx &ctx)
|
||||
{
|
||||
if (ctx.file) {
|
||||
std::fflush((FILE *) ctx.file);
|
||||
::fsync(ctx.fd);
|
||||
std::fclose((FILE *) ctx.file);
|
||||
ctx.file = nullptr;
|
||||
}
|
||||
if (ctx.fd >= 0) {
|
||||
(void) ::fsync(ctx.fd);
|
||||
::close(ctx.fd);
|
||||
ctx.fd = -1;
|
||||
}
|
||||
ctx.header_ok = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -253,22 +444,35 @@ SwapManager::crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed
|
||||
|
||||
|
||||
void
|
||||
SwapManager::put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v)
|
||||
SwapManager::put_le32(std::vector<std::uint8_t> &out, std::uint32_t v)
|
||||
{
|
||||
while (v >= 0x80) {
|
||||
out.push_back(static_cast<std::uint8_t>(v) | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
out.push_back(static_cast<std::uint8_t>(v));
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFFu));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFFu));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFFu));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFFu));
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::put_u24(std::uint8_t dst[3], std::uint32_t v)
|
||||
SwapManager::put_le64(std::uint8_t *dst, std::uint64_t v)
|
||||
{
|
||||
dst[0] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||
dst[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||
dst[2] = static_cast<std::uint8_t>(v & 0xFF);
|
||||
dst[0] = static_cast<std::uint8_t>(v & 0xFFu);
|
||||
dst[1] = static_cast<std::uint8_t>((v >> 8) & 0xFFu);
|
||||
dst[2] = static_cast<std::uint8_t>((v >> 16) & 0xFFu);
|
||||
dst[3] = static_cast<std::uint8_t>((v >> 24) & 0xFFu);
|
||||
dst[4] = static_cast<std::uint8_t>((v >> 32) & 0xFFu);
|
||||
dst[5] = static_cast<std::uint8_t>((v >> 40) & 0xFFu);
|
||||
dst[6] = static_cast<std::uint8_t>((v >> 48) & 0xFFu);
|
||||
dst[7] = static_cast<std::uint8_t>((v >> 56) & 0xFFu);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::put_u24_le(std::uint8_t dst[3], std::uint32_t v)
|
||||
{
|
||||
dst[0] = static_cast<std::uint8_t>(v & 0xFFu);
|
||||
dst[1] = static_cast<std::uint8_t>((v >> 8) & 0xFFu);
|
||||
dst[2] = static_cast<std::uint8_t>((v >> 16) & 0xFFu);
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +481,7 @@ SwapManager::enqueue(Pending &&p)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
p.seq = ++next_seq_;
|
||||
queue_.emplace_back(std::move(p));
|
||||
}
|
||||
cv_.notify_one();
|
||||
@@ -288,16 +493,20 @@ SwapManager::RecordInsert(Buffer &buf, int row, int col, std::string_view text)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (journals_[&buf].suspended)
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end() || it->second.suspended)
|
||||
return;
|
||||
}
|
||||
Pending p;
|
||||
p.buf = &buf;
|
||||
p.type = SwapRecType::INS;
|
||||
// payload: varint row, varint col, varint len, bytes
|
||||
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
|
||||
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col)));
|
||||
put_varu64(p.payload, static_cast<std::uint64_t>(text.size()));
|
||||
// payload v1: [encver u8=1][row u32][col u32][nbytes u32][bytes]
|
||||
if (text.size() > 0xFFFFFFFFu)
|
||||
return;
|
||||
p.payload.push_back(1);
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, row)));
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, col)));
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(text.size()));
|
||||
p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(text.data()),
|
||||
reinterpret_cast<const std::uint8_t *>(text.data()) + text.size());
|
||||
enqueue(std::move(p));
|
||||
@@ -309,15 +518,20 @@ SwapManager::RecordDelete(Buffer &buf, int row, int col, std::size_t len)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (journals_[&buf].suspended)
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end() || it->second.suspended)
|
||||
return;
|
||||
}
|
||||
if (len > 0xFFFFFFFFu)
|
||||
return;
|
||||
Pending p;
|
||||
p.buf = &buf;
|
||||
p.type = SwapRecType::DEL;
|
||||
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
|
||||
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col)));
|
||||
put_varu64(p.payload, static_cast<std::uint64_t>(len));
|
||||
// payload v1: [encver u8=1][row u32][col u32][len u32]
|
||||
p.payload.push_back(1);
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, row)));
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, col)));
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(len));
|
||||
enqueue(std::move(p));
|
||||
}
|
||||
|
||||
@@ -327,14 +541,17 @@ SwapManager::RecordSplit(Buffer &buf, int row, int col)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (journals_[&buf].suspended)
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end() || it->second.suspended)
|
||||
return;
|
||||
}
|
||||
Pending p;
|
||||
p.buf = &buf;
|
||||
p.type = SwapRecType::SPLIT;
|
||||
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
|
||||
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col)));
|
||||
// payload v1: [encver u8=1][row u32][col u32]
|
||||
p.payload.push_back(1);
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, row)));
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, col)));
|
||||
enqueue(std::move(p));
|
||||
}
|
||||
|
||||
@@ -344,13 +561,16 @@ SwapManager::RecordJoin(Buffer &buf, int row)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (journals_[&buf].suspended)
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end() || it->second.suspended)
|
||||
return;
|
||||
}
|
||||
Pending p;
|
||||
p.buf = &buf;
|
||||
p.type = SwapRecType::JOIN;
|
||||
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
|
||||
// payload v1: [encver u8=1][row u32]
|
||||
p.payload.push_back(1);
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, row)));
|
||||
enqueue(std::move(p));
|
||||
}
|
||||
|
||||
@@ -358,59 +578,91 @@ SwapManager::RecordJoin(Buffer &buf, int row)
|
||||
void
|
||||
SwapManager::writer_loop()
|
||||
{
|
||||
while (running_.load()) {
|
||||
for (;;) {
|
||||
std::vector<Pending> batch;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mtx_);
|
||||
if (queue_.empty()) {
|
||||
if (!running_.load())
|
||||
break;
|
||||
cv_.wait_for(lk, std::chrono::milliseconds(cfg_.flush_interval_ms));
|
||||
}
|
||||
if (!queue_.empty()) {
|
||||
batch.swap(queue_);
|
||||
inflight_ += batch.size();
|
||||
}
|
||||
}
|
||||
if (batch.empty())
|
||||
continue;
|
||||
|
||||
// Group by buffer path to minimize fsyncs
|
||||
for (const Pending &p: batch) {
|
||||
process_one(p);
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (p.seq > last_processed_)
|
||||
last_processed_ = p.seq;
|
||||
if (inflight_ > 0)
|
||||
--inflight_;
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
// Throttled fsync: best-effort
|
||||
// Iterate unique contexts and fsync if needed
|
||||
// For stage 1, fsync all once per interval
|
||||
// Throttled fsync: best-effort (grouped)
|
||||
std::vector<int> to_sync;
|
||||
std::uint64_t now = now_ns();
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
for (auto &kv: journals_) {
|
||||
JournalCtx &ctx = kv.second;
|
||||
if (ctx.fd >= 0) {
|
||||
if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >= cfg_.
|
||||
fsync_interval_ms) {
|
||||
::fsync(ctx.fd);
|
||||
if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >=
|
||||
cfg_.fsync_interval_ms) {
|
||||
ctx.last_fsync_ns = now;
|
||||
to_sync.push_back(ctx.fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int fd: to_sync) {
|
||||
(void) ::fsync(fd);
|
||||
}
|
||||
}
|
||||
// Wake any waiters.
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::process_one(const Pending &p)
|
||||
{
|
||||
if (!p.buf)
|
||||
return;
|
||||
Buffer &buf = *p.buf;
|
||||
// Resolve context by path derived from buffer
|
||||
std::string path = ComputeSidecarPath(buf);
|
||||
// Get or create context keyed by this buffer pointer (stage 1 simplification)
|
||||
JournalCtx &ctx = journals_[p.buf];
|
||||
if (ctx.path.empty())
|
||||
ctx.path = path;
|
||||
if (!open_ctx(ctx))
|
||||
|
||||
JournalCtx *ctxp = nullptr;
|
||||
std::string path;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(p.buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
if (it->second.suspended)
|
||||
return;
|
||||
if (it->second.path.empty())
|
||||
it->second.path = ComputeSidecarPath(buf);
|
||||
path = it->second.path;
|
||||
ctxp = &it->second;
|
||||
}
|
||||
if (!ctxp)
|
||||
return;
|
||||
if (!open_ctx(*ctxp, path))
|
||||
return;
|
||||
if (p.payload.size() > 0xFFFFFFu)
|
||||
return;
|
||||
|
||||
// Build record: [type u8][len u24][payload][crc32 u32]
|
||||
std::uint8_t len3[3];
|
||||
put_u24(len3, static_cast<std::uint32_t>(p.payload.size()));
|
||||
put_u24_le(len3, static_cast<std::uint32_t>(p.payload.size()));
|
||||
|
||||
std::uint8_t head[4];
|
||||
head[0] = static_cast<std::uint8_t>(p.type);
|
||||
@@ -422,13 +674,170 @@ SwapManager::process_one(const Pending &p)
|
||||
c = crc32(head, sizeof(head), c);
|
||||
if (!p.payload.empty())
|
||||
c = crc32(p.payload.data(), p.payload.size(), c);
|
||||
std::uint8_t crcbytes[4];
|
||||
crcbytes[0] = static_cast<std::uint8_t>(c & 0xFFu);
|
||||
crcbytes[1] = static_cast<std::uint8_t>((c >> 8) & 0xFFu);
|
||||
crcbytes[2] = static_cast<std::uint8_t>((c >> 16) & 0xFFu);
|
||||
crcbytes[3] = static_cast<std::uint8_t>((c >> 24) & 0xFFu);
|
||||
|
||||
// Write (handle partial writes and check results)
|
||||
bool ok = write_full(ctx.fd, head, sizeof(head));
|
||||
bool ok = write_full(ctxp->fd, head, sizeof(head));
|
||||
if (ok && !p.payload.empty())
|
||||
ok = write_full(ctx.fd, p.payload.data(), p.payload.size());
|
||||
ok = write_full(ctxp->fd, p.payload.data(), p.payload.size());
|
||||
if (ok)
|
||||
ok = write_full(ctx.fd, &c, sizeof(c));
|
||||
ok = write_full(ctxp->fd, crcbytes, sizeof(crcbytes));
|
||||
(void) ok; // stage 1: best-effort; future work could mark ctx error state
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
read_exact(std::ifstream &in, void *dst, std::size_t n)
|
||||
{
|
||||
in.read(static_cast<char *>(dst), static_cast<std::streamsize>(n));
|
||||
return in.good() && static_cast<std::size_t>(in.gcount()) == n;
|
||||
}
|
||||
|
||||
|
||||
static std::uint32_t
|
||||
read_le32(const std::uint8_t b[4])
|
||||
{
|
||||
return (std::uint32_t) b[0] | ((std::uint32_t) b[1] << 8) | ((std::uint32_t) b[2] << 16) | (
|
||||
(std::uint32_t) b[3] << 24);
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
parse_u32_le(const std::vector<std::uint8_t> &p, std::size_t &off, std::uint32_t &out)
|
||||
{
|
||||
if (off + 4 > p.size())
|
||||
return false;
|
||||
out = (std::uint32_t) p[off] | ((std::uint32_t) p[off + 1] << 8) | ((std::uint32_t) p[off + 2] << 16) |
|
||||
((std::uint32_t) p[off + 3] << 24);
|
||||
off += 4;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)
|
||||
{
|
||||
err.clear();
|
||||
std::ifstream in(swap_path, std::ios::binary);
|
||||
if (!in) {
|
||||
err = "Failed to open swap file for replay: " + swap_path;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::uint8_t hdr[64];
|
||||
if (!read_exact(in, hdr, sizeof(hdr))) {
|
||||
err = "Swap file truncated (header): " + swap_path;
|
||||
return false;
|
||||
}
|
||||
if (std::memcmp(hdr, MAGIC, 8) != 0) {
|
||||
err = "Swap file has bad magic: " + swap_path;
|
||||
return false;
|
||||
}
|
||||
const std::uint32_t ver = read_le32(hdr + 8);
|
||||
if (ver != VERSION) {
|
||||
err = "Unsupported swap version: " + std::to_string(ver);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
std::uint8_t head[4];
|
||||
in.read(reinterpret_cast<char *>(head), sizeof(head));
|
||||
const std::size_t got_head = static_cast<std::size_t>(in.gcount());
|
||||
if (got_head == 0 && in.eof()) {
|
||||
return true; // clean EOF
|
||||
}
|
||||
if (got_head != sizeof(head)) {
|
||||
err = "Swap file truncated (record header): " + swap_path;
|
||||
return false;
|
||||
}
|
||||
|
||||
const SwapRecType type = static_cast<SwapRecType>(head[0]);
|
||||
const std::size_t len = (std::size_t) head[1] | ((std::size_t) head[2] << 8) | (
|
||||
(std::size_t) head[3] << 16);
|
||||
std::vector<std::uint8_t> payload;
|
||||
payload.resize(len);
|
||||
if (len > 0 && !read_exact(in, payload.data(), len)) {
|
||||
err = "Swap file truncated (payload): " + swap_path;
|
||||
return false;
|
||||
}
|
||||
std::uint8_t crcbytes[4];
|
||||
if (!read_exact(in, crcbytes, sizeof(crcbytes))) {
|
||||
err = "Swap file truncated (crc): " + swap_path;
|
||||
return false;
|
||||
}
|
||||
const std::uint32_t want_crc = read_le32(crcbytes);
|
||||
std::uint32_t got_crc = 0;
|
||||
got_crc = crc32(head, sizeof(head), got_crc);
|
||||
if (!payload.empty())
|
||||
got_crc = crc32(payload.data(), payload.size(), got_crc);
|
||||
if (got_crc != want_crc) {
|
||||
err = "Swap file CRC mismatch: " + swap_path;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply record
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
if (encver != 1) {
|
||||
err = "Unsupported swap payload encoding";
|
||||
return false;
|
||||
}
|
||||
switch (type) {
|
||||
case SwapRecType::INS: {
|
||||
std::uint32_t row = 0, col = 0, nbytes = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
|
||||
payload, off, nbytes)) {
|
||||
err = "Malformed INS payload";
|
||||
return false;
|
||||
}
|
||||
if (off + nbytes > payload.size()) {
|
||||
err = "Truncated INS payload bytes";
|
||||
return false;
|
||||
}
|
||||
buf.insert_text((int) row, (int) col,
|
||||
std::string_view(reinterpret_cast<const char *>(payload.data() + off), nbytes));
|
||||
break;
|
||||
}
|
||||
case SwapRecType::DEL: {
|
||||
std::uint32_t row = 0, col = 0, dlen = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
|
||||
payload, off, dlen)) {
|
||||
err = "Malformed DEL payload";
|
||||
return false;
|
||||
}
|
||||
buf.delete_text((int) row, (int) col, (std::size_t) dlen);
|
||||
break;
|
||||
}
|
||||
case SwapRecType::SPLIT: {
|
||||
std::uint32_t row = 0, col = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col)) {
|
||||
err = "Malformed SPLIT payload";
|
||||
return false;
|
||||
}
|
||||
buf.split_line((int) row, (int) col);
|
||||
break;
|
||||
}
|
||||
case SwapRecType::JOIN: {
|
||||
std::uint32_t row = 0;
|
||||
if (!parse_u32_le(payload, off, row)) {
|
||||
err = "Malformed JOIN payload";
|
||||
return false;
|
||||
}
|
||||
buf.join_lines((int) row);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Ignore unknown types for forward-compat in stage 1
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace kte
|
||||
95
Swap.h
95
Swap.h
@@ -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 {
|
||||
@@ -31,30 +34,12 @@ struct SwapConfig {
|
||||
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;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 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);
|
||||
@@ -62,17 +47,34 @@ public:
|
||||
// Detach and close journal.
|
||||
void Detach(Buffer *buf);
|
||||
|
||||
// SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs)
|
||||
void NotifyFilenameChanged(Buffer &buf) 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);
|
||||
|
||||
// SwapRecorder
|
||||
void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override;
|
||||
// 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);
|
||||
|
||||
void RecordDelete(Buffer &buf, int row, int col, std::size_t len) override;
|
||||
// Notify that the buffer's filename changed (e.g., SaveAs)
|
||||
void NotifyFilenameChanged(Buffer &buf);
|
||||
|
||||
void RecordSplit(Buffer &buf, int row, int col) override;
|
||||
// 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);
|
||||
|
||||
void RecordJoin(Buffer &buf, int row) override;
|
||||
// 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,12 +90,32 @@ 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);
|
||||
|
||||
struct JournalCtx {
|
||||
std::string path;
|
||||
void *file{nullptr}; // FILE*
|
||||
int fd{-1};
|
||||
bool header_ok{false};
|
||||
bool suspended{false};
|
||||
@@ -106,6 +128,7 @@ private:
|
||||
SwapRecType type{SwapRecType::INS};
|
||||
std::vector<std::uint8_t> payload; // framed payload only
|
||||
bool urgent_flush{false};
|
||||
std::uint64_t seq{0};
|
||||
};
|
||||
|
||||
// Helpers
|
||||
@@ -115,17 +138,19 @@ private:
|
||||
|
||||
static bool ensure_parent_dir(const std::string &path);
|
||||
|
||||
static bool write_header(JournalCtx &ctx);
|
||||
static bool write_header(int fd);
|
||||
|
||||
static bool open_ctx(JournalCtx &ctx);
|
||||
static bool open_ctx(JournalCtx &ctx, const std::string &path);
|
||||
|
||||
static void close_ctx(JournalCtx &ctx);
|
||||
|
||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||
|
||||
static void put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v);
|
||||
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
|
||||
|
||||
static void put_u24(std::uint8_t dst[3], std::uint32_t v);
|
||||
static void put_le64(std::uint8_t dst[8], std::uint64_t v);
|
||||
|
||||
static void put_u24_le(std::uint8_t dst[3], std::uint32_t v);
|
||||
|
||||
void enqueue(Pending &&p);
|
||||
|
||||
@@ -136,9 +161,13 @@ private:
|
||||
// State
|
||||
SwapConfig cfg_{};
|
||||
std::unordered_map<Buffer *, JournalCtx> journals_;
|
||||
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
||||
std::mutex mtx_;
|
||||
std::condition_variable cv_;
|
||||
std::vector<Pending> queue_;
|
||||
std::uint64_t next_seq_{0};
|
||||
std::uint64_t last_processed_{0};
|
||||
std::uint64_t inflight_{0};
|
||||
std::atomic<bool> running_{false};
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
19
SwapRecorder.h
Normal file
19
SwapRecorder.h
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -80,9 +99,6 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
||||
if (mi.hasCommand) {
|
||||
Execute(ed, mi.id, mi.arg, mi.count);
|
||||
}
|
||||
} else {
|
||||
// Avoid busy loop
|
||||
usleep(1000);
|
||||
}
|
||||
|
||||
if (ed.QuitRequested()) {
|
||||
@@ -101,5 +117,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();
|
||||
}
|
||||
@@ -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_{};
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "TerminalInputHandler.h"
|
||||
#include "KKeymap.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
namespace {
|
||||
@@ -23,12 +24,19 @@ 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_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;
|
||||
@@ -48,13 +56,34 @@ map_key_to_command(const int ch,
|
||||
}
|
||||
#endif
|
||||
// React to left button click/press
|
||||
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
|
||||
if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED |
|
||||
REPORT_MOUSE_POSITION))) {
|
||||
char buf[64];
|
||||
// Use screen coordinates; command handler will translate via offsets
|
||||
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
|
||||
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
|
||||
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;
|
||||
}
|
||||
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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
#include <clocale>
|
||||
#define _XOPEN_SOURCE_EXTENDED 1
|
||||
#include <cwchar>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
@@ -104,11 +107,80 @@ 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;
|
||||
|
||||
// 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]);
|
||||
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)
|
||||
@@ -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;
|
||||
return A_BOLD;
|
||||
case kte::TokenKind::Comment:
|
||||
attron(A_DIM);
|
||||
break;
|
||||
return A_DIM;
|
||||
case kte::TokenKind::String:
|
||||
case kte::TokenKind::Char:
|
||||
case kte::TokenKind::Number:
|
||||
// standout a bit using A_UNDERLINE if available
|
||||
attron(A_UNDERLINE);
|
||||
break;
|
||||
return A_UNDERLINE;
|
||||
default:
|
||||
break;
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
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
|
||||
@@ -198,98 +281,103 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
}
|
||||
// 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;
|
||||
&&
|
||||
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)
|
||||
attr |= A_STANDOUT;
|
||||
a |= 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));
|
||||
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();
|
||||
}
|
||||
@@ -306,14 +394,26 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
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::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 {
|
||||
if (wch == L'\t') {
|
||||
std::size_t next_tab = tabw - (render_col_cur % tabw);
|
||||
render_col_cur += next_tab;
|
||||
++src_i_cur;
|
||||
} else {
|
||||
++render_col_cur;
|
||||
++src_i_cur;
|
||||
int dw = wcwidth(wch);
|
||||
render_col_cur += (dw < 0) ? 1 : dw;
|
||||
}
|
||||
src_i_cur += res;
|
||||
}
|
||||
}
|
||||
rx_recomputed = render_col_cur;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 *parent = nullptr; // previous state; null means pre-first-edit
|
||||
UndoNode *child = nullptr; // next in current timeline
|
||||
UndoNode *next = nullptr; // redo branch
|
||||
};
|
||||
@@ -20,6 +20,7 @@ public:
|
||||
available_.pop();
|
||||
// Node comes zeroed; ensure links are reset
|
||||
node->text.clear();
|
||||
node->parent = nullptr;
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
node->row = node->col = 0;
|
||||
@@ -34,6 +35,7 @@ public:
|
||||
return;
|
||||
// Clear heavy fields to free memory held by strings
|
||||
node->text.clear();
|
||||
node->parent = nullptr;
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
node->row = node->col = 0;
|
||||
|
||||
231
UndoSystem.cc
231
UndoSystem.cc
@@ -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;
|
||||
}
|
||||
|
||||
29
UndoSystem.h
29
UndoSystem.h
@@ -12,6 +12,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 +28,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 +41,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 +70,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_;
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
/*
|
||||
* BufferBench.cc - microbenchmarks for GapBuffer and PieceTable
|
||||
*
|
||||
* This benchmark exercises the public APIs shared by both structures as used
|
||||
* in Buffer::Line: Reserve, AppendChar, Append, PrependChar, Prepend, Clear.
|
||||
*
|
||||
* Run examples:
|
||||
* ./kte_bench_buffer # defaults
|
||||
* ./kte_bench_buffer 200000 8 4096 # N=200k, rounds=8, chunk=4096
|
||||
*/
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <typeinfo>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
#include "PieceTable.h"
|
||||
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
using us = std::chrono::microseconds;
|
||||
|
||||
struct Result {
|
||||
std::string name;
|
||||
std::string scenario;
|
||||
double micros = 0.0;
|
||||
std::size_t bytes = 0;
|
||||
};
|
||||
|
||||
|
||||
static void
|
||||
print_header()
|
||||
{
|
||||
std::cout << std::left << std::setw(14) << "Structure"
|
||||
<< std::left << std::setw(18) << "Scenario"
|
||||
<< std::right << std::setw(12) << "time(us)"
|
||||
<< std::right << std::setw(14) << "bytes"
|
||||
<< std::right << std::setw(14) << "MB/s"
|
||||
<< "\n";
|
||||
std::cout << std::string(72, '-') << "\n";
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
print_row(const Result &r)
|
||||
{
|
||||
double mb = r.bytes / (1024.0 * 1024.0);
|
||||
double mbps = (r.micros > 0.0) ? (mb / (r.micros / 1'000'000.0)) : 0.0;
|
||||
std::cout << std::left << std::setw(14) << r.name
|
||||
<< std::left << std::setw(18) << r.scenario
|
||||
<< std::right << std::setw(12) << std::fixed << std::setprecision(2) << r.micros
|
||||
<< std::right << std::setw(14) << r.bytes
|
||||
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << mbps
|
||||
<< "\n";
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_sequential_append(std::size_t N, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "seq_append";
|
||||
const char c = 'x';
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
b.AppendChar(c);
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_sequential_prepend(std::size_t N, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "seq_prepend";
|
||||
const char c = 'x';
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
b.PrependChar(c);
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_chunk_append(std::size_t N, std::size_t chunk, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "chunk_append";
|
||||
std::string payload(chunk, 'y');
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
b.Append(payload.data(), now);
|
||||
written += now;
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_mixed(std::size_t N, std::size_t chunk, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "mixed";
|
||||
std::string payload(chunk, 'z');
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
// alternate append/prepend with small chunks
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
if ((written / chunk) % 2 == 0) {
|
||||
b.Append(payload.data(), now);
|
||||
} else {
|
||||
b.Prepend(payload.data(), now);
|
||||
}
|
||||
written += now;
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
main(int argc, char **argv)
|
||||
{
|
||||
// Parameters
|
||||
std::size_t N = 100'000; // bytes per round
|
||||
int rounds = 5; // iterations
|
||||
std::size_t chunk = 1024; // chunk size for chunked scenarios
|
||||
if (argc >= 2)
|
||||
N = static_cast<std::size_t>(std::stoull(argv[1]));
|
||||
if (argc >= 3)
|
||||
rounds = std::stoi(argv[2]);
|
||||
if (argc >= 4)
|
||||
chunk = static_cast<std::size_t>(std::stoull(argv[3]));
|
||||
|
||||
std::cout << "KTE Buffer Microbenchmarks" << "\n";
|
||||
std::cout << "N=" << N << ", rounds=" << rounds << ", chunk=" << chunk << "\n\n";
|
||||
|
||||
print_header();
|
||||
|
||||
// Run for GapBuffer
|
||||
print_row(bench_sequential_append<GapBuffer>(N, rounds));
|
||||
print_row(bench_sequential_prepend<GapBuffer>(N, rounds));
|
||||
print_row(bench_chunk_append<GapBuffer>(N, chunk, rounds));
|
||||
print_row(bench_mixed<GapBuffer>(N, chunk, rounds));
|
||||
|
||||
// Run for PieceTable
|
||||
print_row(bench_sequential_append<PieceTable>(N, rounds));
|
||||
print_row(bench_sequential_prepend<PieceTable>(N, rounds));
|
||||
print_row(bench_chunk_append<PieceTable>(N, chunk, rounds));
|
||||
print_row(bench_mixed<PieceTable>(N, chunk, rounds));
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
/*
|
||||
* PerformanceSuite.cc - broader performance and verification benchmarks
|
||||
*/
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <typeinfo>
|
||||
#include <vector>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
#include "PieceTable.h"
|
||||
#include "OptimizedSearch.h"
|
||||
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
using us = std::chrono::microseconds;
|
||||
|
||||
namespace {
|
||||
struct Stat {
|
||||
double micros{0.0};
|
||||
std::size_t bytes{0};
|
||||
std::size_t ops{0};
|
||||
};
|
||||
|
||||
|
||||
static void
|
||||
print_header(const std::string &title)
|
||||
{
|
||||
std::cout << "\n" << title << "\n";
|
||||
std::cout << std::left << std::setw(18) << "Case"
|
||||
<< std::left << std::setw(18) << "Type"
|
||||
<< std::right << std::setw(12) << "time(us)"
|
||||
<< std::right << std::setw(14) << "bytes"
|
||||
<< std::right << std::setw(14) << "ops/s"
|
||||
<< std::right << std::setw(14) << "MB/s"
|
||||
<< "\n";
|
||||
std::cout << std::string(90, '-') << "\n";
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
print_row(const std::string &caseName, const std::string &typeName, const Stat &s)
|
||||
{
|
||||
double mb = s.bytes / (1024.0 * 1024.0);
|
||||
double sec = s.micros / 1'000'000.0;
|
||||
double mbps = sec > 0 ? (mb / sec) : 0.0;
|
||||
double opss = sec > 0 ? (static_cast<double>(s.ops) / sec) : 0.0;
|
||||
std::cout << std::left << std::setw(18) << caseName
|
||||
<< std::left << std::setw(18) << typeName
|
||||
<< std::right << std::setw(12) << std::fixed << std::setprecision(2) << s.micros
|
||||
<< std::right << std::setw(14) << s.bytes
|
||||
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << opss
|
||||
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << mbps
|
||||
<< "\n";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
class PerformanceSuite {
|
||||
public:
|
||||
void benchmarkBufferOperations(std::size_t N, int rounds, std::size_t chunk)
|
||||
{
|
||||
print_header("Buffer Operations");
|
||||
run_buffer_case<GapBuffer>("append_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.AppendChar('a');
|
||||
});
|
||||
run_buffer_case<GapBuffer>("prepend_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.PrependChar('a');
|
||||
});
|
||||
run_buffer_case<GapBuffer>("chunk_mix", N, rounds, chunk, [&](auto &b, std::size_t) {
|
||||
std::string payload(chunk, 'x');
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
if (((written / chunk) & 1) == 0)
|
||||
b.Append(payload.data(), now);
|
||||
else
|
||||
b.Prepend(payload.data(), now);
|
||||
written += now;
|
||||
}
|
||||
});
|
||||
run_buffer_case<PieceTable>("append_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.AppendChar('a');
|
||||
});
|
||||
run_buffer_case<PieceTable>("prepend_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.PrependChar('a');
|
||||
});
|
||||
run_buffer_case<PieceTable>("chunk_mix", N, rounds, chunk, [&](auto &b, std::size_t) {
|
||||
std::string payload(chunk, 'x');
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
if (((written / chunk) & 1) == 0)
|
||||
b.Append(payload.data(), now);
|
||||
else
|
||||
b.Prepend(payload.data(), now);
|
||||
written += now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void benchmarkSearchOperations(std::size_t textLen, std::size_t patLen, int rounds)
|
||||
{
|
||||
print_header("Search Operations");
|
||||
std::mt19937_64 rng(0xC0FFEE);
|
||||
std::uniform_int_distribution<int> dist('a', 'z');
|
||||
std::string text(textLen, '\0');
|
||||
for (auto &ch: text)
|
||||
ch = static_cast<char>(dist(rng));
|
||||
std::string pattern(patLen, '\0');
|
||||
for (auto &ch: pattern)
|
||||
ch = static_cast<char>(dist(rng));
|
||||
|
||||
// Ensure at least one hit
|
||||
if (textLen >= patLen && patLen > 0) {
|
||||
std::size_t pos = textLen / 2;
|
||||
std::memcpy(&text[pos], pattern.data(), patLen);
|
||||
}
|
||||
|
||||
// OptimizedSearch find_all vs std::string reference
|
||||
OptimizedSearch os;
|
||||
Stat s{};
|
||||
auto start = clock_t::now();
|
||||
std::size_t matches = 0;
|
||||
std::size_t bytesScanned = 0;
|
||||
for (int r = 0; r < rounds; ++r) {
|
||||
auto hits = os.find_all(text, pattern, 0);
|
||||
matches += hits.size();
|
||||
bytesScanned += text.size();
|
||||
// Verify with reference
|
||||
std::vector<std::size_t> ref;
|
||||
std::size_t from = 0;
|
||||
while (true) {
|
||||
auto p = text.find(pattern, from);
|
||||
if (p == std::string::npos)
|
||||
break;
|
||||
ref.push_back(p);
|
||||
from = p + (patLen ? patLen : 1);
|
||||
}
|
||||
assert(ref == hits);
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
s.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
s.bytes = bytesScanned;
|
||||
s.ops = matches;
|
||||
print_row("find_all", "OptimizedSearch", s);
|
||||
}
|
||||
|
||||
|
||||
void benchmarkMemoryAllocation(std::size_t N, int rounds)
|
||||
{
|
||||
print_header("Memory Allocation (allocations during editing)");
|
||||
// Measure number of allocations by simulating editing patterns.
|
||||
auto run_session = [&](auto &&buffer) {
|
||||
// alternate small appends and prepends
|
||||
const std::size_t chunk = 32;
|
||||
std::string payload(chunk, 'q');
|
||||
for (int r = 0; r < rounds; ++r) {
|
||||
buffer.Clear();
|
||||
for (std::size_t i = 0; i < N; i += chunk)
|
||||
buffer.Append(payload.data(), std::min(chunk, N - i));
|
||||
for (std::size_t i = 0; i < N / 2; i += chunk)
|
||||
buffer.Prepend(payload.data(), std::min(chunk, N / 2 - i));
|
||||
}
|
||||
};
|
||||
|
||||
// Local allocation counters for this TU via overriding operators
|
||||
reset_alloc_counters();
|
||||
GapBuffer gb;
|
||||
run_session(gb);
|
||||
auto gap_allocs = current_allocs();
|
||||
print_row("edit_session", "GapBuffer", Stat{
|
||||
0.0, static_cast<std::size_t>(gap_allocs.bytes),
|
||||
static_cast<std::size_t>(gap_allocs.count)
|
||||
});
|
||||
|
||||
reset_alloc_counters();
|
||||
PieceTable pt;
|
||||
run_session(pt);
|
||||
auto pt_allocs = current_allocs();
|
||||
print_row("edit_session", "PieceTable", Stat{
|
||||
0.0, static_cast<std::size_t>(pt_allocs.bytes),
|
||||
static_cast<std::size_t>(pt_allocs.count)
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
template<typename Buf, typename Fn>
|
||||
void run_buffer_case(const std::string &caseName, std::size_t N, int rounds, std::size_t chunk, Fn fn)
|
||||
{
|
||||
Stat s{};
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
std::size_t ops = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
fn(b, N);
|
||||
// compare to reference string where possible (only for append_char/prepend_char)
|
||||
bytes += N;
|
||||
ops += N / (chunk ? chunk : 1);
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
s.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
s.bytes = bytes;
|
||||
s.ops = ops;
|
||||
print_row(caseName, typeid(Buf).name(), s);
|
||||
}
|
||||
|
||||
|
||||
// Simple global allocation tracking for this TU
|
||||
struct AllocStats {
|
||||
std::uint64_t count{0};
|
||||
std::uint64_t bytes{0};
|
||||
};
|
||||
|
||||
|
||||
static AllocStats &alloc_stats()
|
||||
{
|
||||
static AllocStats s;
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
static void reset_alloc_counters()
|
||||
{
|
||||
alloc_stats() = {};
|
||||
}
|
||||
|
||||
|
||||
static AllocStats current_allocs()
|
||||
{
|
||||
return alloc_stats();
|
||||
}
|
||||
|
||||
|
||||
// Friend global new/delete defined below
|
||||
friend void *operator new(std::size_t sz) noexcept(false);
|
||||
|
||||
friend void operator delete(void *p) noexcept;
|
||||
|
||||
friend void *operator new[](std::size_t sz) noexcept(false);
|
||||
|
||||
friend void operator delete[](void *p) noexcept;
|
||||
};
|
||||
|
||||
// Override new/delete only in this translation unit to track allocations made here
|
||||
void *
|
||||
operator new(std::size_t sz) noexcept(false)
|
||||
{
|
||||
auto &s = PerformanceSuite::alloc_stats();
|
||||
s.count++;
|
||||
s.bytes += sz;
|
||||
if (void *p = std::malloc(sz))
|
||||
return p;
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
operator delete(void *p) noexcept
|
||||
{
|
||||
std::free(p);
|
||||
}
|
||||
|
||||
|
||||
void *
|
||||
operator new[](std::size_t sz) noexcept(false)
|
||||
{
|
||||
auto &s = PerformanceSuite::alloc_stats();
|
||||
s.count++;
|
||||
s.bytes += sz;
|
||||
if (void *p = std::malloc(sz))
|
||||
return p;
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
operator delete[](void *p) noexcept
|
||||
{
|
||||
std::free(p);
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
main(int argc, char **argv)
|
||||
{
|
||||
std::size_t N = 200'000; // bytes per round for buffer cases
|
||||
int rounds = 3;
|
||||
std::size_t chunk = 1024;
|
||||
if (argc >= 2)
|
||||
N = static_cast<std::size_t>(std::stoull(argv[1]));
|
||||
if (argc >= 3)
|
||||
rounds = std::stoi(argv[2]);
|
||||
if (argc >= 4)
|
||||
chunk = static_cast<std::size_t>(std::stoull(argv[3]));
|
||||
|
||||
std::cout << "KTE Performance Suite" << "\n";
|
||||
std::cout << "N=" << N << ", rounds=" << rounds << ", chunk=" << chunk << "\n";
|
||||
|
||||
PerformanceSuite suite;
|
||||
suite.benchmarkBufferOperations(N, rounds, chunk);
|
||||
suite.benchmarkSearchOperations(1'000'000, 16, rounds);
|
||||
suite.benchmarkMemoryAllocation(N, rounds);
|
||||
return 0;
|
||||
}
|
||||
78
cmake/fix_bundle.cmake
Normal file
78
cmake/fix_bundle.cmake
Normal 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}")
|
||||
35
default.nix
35
default.nix
@@ -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/
|
||||
${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
|
||||
cp ../kge.png $out/share/icons/
|
||||
''
|
||||
+ ''
|
||||
|
||||
mkdir -p $out/share/icons/hicolor/256x256/apps
|
||||
cp ../kge.png $out/share/icons/hicolor/256x256/apps/kge.png
|
||||
''}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
|
||||
601
docs/plans/piece-table-migration.md
Normal file
601
docs/plans/piece-table-migration.md
Normal 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`
|
||||
@@ -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 per‑session 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: append‑only 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
|
||||
---------------------------------
|
||||
|
||||
- Append‑only with per‑record 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.,
|
||||
64–128 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
163
docs/plans/test-plan.md
Normal 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 A–H described later. “Implemented” reflects current coverage in `kte_tests`.
|
||||
|
||||
| Suite | ID | Name | Description (1‑line) | Headless | Implemented |
|
||||
|:-----:|:---:|:------------------------------------------|:-------------------------------------------------------------------------------------|:--------:|:-----------:|
|
||||
| A | 1 | SaveAs then Save (append) | New buffer → write two lines → `SaveAs` → append → `Save`; verify exact bytes. | Yes | ✓ |
|
||||
| A | 2 | Open existing then Save | Open seeded file, append, `Save`; verify overwrite bytes. | Yes | ✓ |
|
||||
| A | 3 | Open non-existent then SaveAs | Start from non-existent path, insert `hello, world\n`, `SaveAs`; verify bytes. | Yes | ✓ |
|
||||
| A | 4 | Trailing newline preservation | Verify saving preserves presence/absence of final `\n`. | Yes | Planned |
|
||||
| A | 5 | Empty buffer saves | Empty → `SaveAs` → 0 bytes; then insert `\n` → `Save` → 1 byte. | Yes | Planned |
|
||||
| A | 6 | Large file streaming | 1–4 MiB with periodic newlines; size and content integrity. | Yes | Planned |
|
||||
| A | 7 | Tilde expansion | `SaveAs` with `~/...`; re-open to confirm path/content. | Yes | Planned |
|
||||
| A | 8 | Error propagation | Save to unwritable path → expect failure and error message. | Yes | Planned |
|
||||
| B | 1 | Insert/Delete LineCount | Basic inserts/deletes and line counting sanity. | Yes | ✓ |
|
||||
| B | 2 | Line/Col conversions | `LineColToByteOffset` and reverse around boundaries. | Yes | ✓ |
|
||||
| B | 3 | Delete spanning newlines | Delete ranges that cross line breaks; verify bytes/lines. | Yes | Planned |
|
||||
| B | 4 | Split/Join equivalence | `split_line` followed by `join_lines` yields original bytes. | Yes | Planned |
|
||||
| B | 5 | Stream vs Data equivalence | `WriteToStream` matches `GetRange`/`Data()` after edits. | Yes | Planned |
|
||||
| B | 6 | UTF‑8 bytes stability | Multibyte sequences behave correctly (byte-based ops). | Yes | Planned |
|
||||
| C | 1 | insert_text/delete_text | Edits at start/middle/end; `Rows()` mirrors PieceTable. | Yes | Planned |
|
||||
| C | 2 | split_line/join_lines | Effects and snapshots across multiple positions. | Yes | Planned |
|
||||
| C | 3 | insert_row/delete_row | Replace paragraph by row ops; verify bytes/linecount. | Yes | Planned |
|
||||
| C | 4 | Cache invalidation | After each mutation, `Rows()` matches `LineCount()`. | Yes | Planned |
|
||||
| D | 1 | Grouped insert undo | Contiguous typing undone/redone as a group. | Yes | Planned |
|
||||
| D | 2 | Delete/Newline undo/redo | Backspace/Delete and Newline transitions across undo/redo. | Yes | Planned |
|
||||
| D | 3 | Mark saved & dirty | Dirty/save markers interact correctly with undo/redo. | Yes | Planned |
|
||||
| E | 1 | Search parity basic | `OptimizedSearch::find_all` vs `std::string` reference. | Yes | ✓ |
|
||||
| E | 2 | Large text search | ~1 MiB random text/patterns parity. | Yes | Planned |
|
||||
| F | 1 | Editor open & reload | Open via `Editor`, modify, reload, verify on-disk bytes. | Yes | Planned |
|
||||
| F | 2 | Read-only toggle | Toggle and verify enforcement/behavior of saves. | Yes | Planned |
|
||||
| F | 3 | Prompt lifecycle | Start/Accept/Cancel prompt doesn’t corrupt state. | Yes | Planned |
|
||||
| G | 1 | Saved only newline regression | Insert text + newline; `Save` includes both bytes. | Yes | Planned |
|
||||
| G | 2 | Backspace crash regression | PieceTable-backed delete/join path remains stable. | Yes | Planned |
|
||||
| G | 3 | Overwrite-confirm path | Saving over existing path succeeds and is correct. | Yes | Planned |
|
||||
| H | 1 | Many small edits | 10k small edits; final bytes correct within time bounds. | Yes | Planned |
|
||||
| H | 2 | Consolidation equivalence | After many edits, stream vs data produce identical bytes. | Yes | Planned |
|
||||
|
||||
Legend: Implemented = ✓, Planned = to be added per Coverage roadmap.
|
||||
|
||||
### Test suites and cases
|
||||
|
||||
#### A) Filesystem I/O via Buffer
|
||||
1) SaveAs then Save (append)
|
||||
- New buffer → `insert_text` two lines (explicit `\n`) → `SaveAs(tmp)` → insert a third line → `Save()`.
|
||||
- Assert file bytes equal exact expected string.
|
||||
2) Open existing then Save
|
||||
- Seed a file on disk; `OpenFromFile(path)` → append line → `Save()`.
|
||||
- Assert file bytes updated exactly.
|
||||
3) Open non-existent then SaveAs
|
||||
- `OpenFromFile(nonexistent)` → assert `IsFileBacked()==false` → insert `"hello, world\n"` → `SaveAs(path)`.
|
||||
- Read back exact bytes.
|
||||
4) Trailing newline preservation
|
||||
- Case (a) last line without `\n`; (b) last line with `\n` → save and verify bytes unchanged.
|
||||
5) Empty buffer saves
|
||||
- `SaveAs(tmp)` on empty buffer → 0-byte file. Then insert `"\n"` and `Save()` → 1-byte file.
|
||||
6) Large file streaming
|
||||
- Insert ~1–4 MiB of data with periodic newlines. `SaveAs` then `Save`; verify size matches `content_.Size()` and bytes integrity.
|
||||
7) Path normalization and tilde expansion
|
||||
- `SaveAs("~/.../file.txt")` → verify path expands to `$HOME` and file content round-trips with `OpenFromFile`.
|
||||
8) Error propagation (guarded)
|
||||
- Attempt save into a non-writable path; expect `Save/SaveAs` returns false with non-empty error. Mark as skipped in environments lacking such path.
|
||||
|
||||
#### B) PieceTable semantics
|
||||
1) Line counting and deletion across lines
|
||||
- Insert `"abc\n123\nxyz"` → 3 lines; delete middle line range → 2 lines; validate `GetLine` contents.
|
||||
2) Position conversions
|
||||
- Validate `LineColToByteOffset` and `ByteOffsetToLineCol` at start/end of lines and EOF, especially around `\n`.
|
||||
3) Delete spanning newlines
|
||||
- Remove a range that crosses line boundaries; verify resulting bytes, `LineCount` and line contents.
|
||||
4) Split/join equivalence
|
||||
- Split at various columns; then join adjacent lines; verify bytes equal original.
|
||||
5) WriteToStream vs materialized `Data()`
|
||||
- After multiple inserts/deletes (without forcing `Data()`), stream to `std::ostringstream`; compare with `GetRange(0, Size())`, then call `Data()` and re-compare.
|
||||
6) UTF-8 bytes stability
|
||||
- Insert multibyte sequences (e.g., `"héllo"`, `"中文"`, emoji) as raw bytes; ensure line counting and conversions behave (byte-based API; no crashes/corruption).
|
||||
|
||||
#### C) Buffer editing helpers and rows cache correctness
|
||||
1) `insert_text`/`delete_text`
|
||||
- Apply at start/middle/end of lines; immediately call `Rows()` and validate contents/lengths mirror PieceTable.
|
||||
2) `split_line` and `join_lines`
|
||||
- Verify content effects and `Rows()` snapshots for multiple positions and consecutive operations.
|
||||
3) `insert_row`/`delete_row`
|
||||
- Replace a paragraph by deleting N rows then inserting N′ rows; verify bytes and `LineCount`.
|
||||
4) Cache invalidation
|
||||
- After each mutation, fetch `Rows()`; assert `Nrows() == content.LineCount()` and no stale data remains.
|
||||
|
||||
#### D) UndoSystem semantics
|
||||
1) Grouped contiguous insert undo
|
||||
- Emulate typing at a single location via repeated `insert_text`; one `undo()` should remove the whole run; `redo()` restores it.
|
||||
2) Delete/newline undo/redo
|
||||
- Simulate backspace/delete (`delete_text` and `join_lines`) and newline (`split_line`); verify content transitions across `undo()`/`redo()`.
|
||||
3) Mark saved and dirty flag
|
||||
- After successful save, call `UndoSystem::mark_saved()` (via existing pathways) and ensure dirty state pairing behaves as intended (at least: `SetDirty(false)` plus save does not break undo/redo).
|
||||
|
||||
#### E) Search algorithms
|
||||
1) Parity with `std::string::find`
|
||||
- Use `OptimizedSearch::find_all` across edge cases (empty needle/text, overlaps like `"aaaaa"` vs `"aa"`, Unicode byte sequences). Compare to reference implementation.
|
||||
2) Large text
|
||||
- Random ASCII text ~1 MiB; random patterns; results match reference.
|
||||
|
||||
#### F) Editor non-interactive flows (no frontend)
|
||||
1) Open and reload
|
||||
- Through `Editor`, open file; modify the underlying `Buffer` directly; invoke reload (`Buffer::OpenFromFile` or `cmd_reload_buffer` if you bring `Command.cc` into the test target). Verify bytes match the on-disk file after reload.
|
||||
2) Read-only toggle
|
||||
- Toggle `Buffer::ToggleReadOnly()`; confirm flag value changes and that subsequent saves still execute when not read-only (or, if enforcement exists, that mutations are appropriately restricted).
|
||||
3) Prompt lifecycle (headless)
|
||||
- Exercise `StartPrompt` → `AcceptPrompt` → `CancelPrompt`; ensure state resets and does not corrupt buffer/editor state.
|
||||
|
||||
#### G) Regression tests for reported bugs
|
||||
1) “Saved only newline”
|
||||
- Build buffer content via `insert_text` followed by `split_line` for newline; `Save` then validate bytes include both the text and newline.
|
||||
2) Backspace crash path
|
||||
- Mimic backspace behavior using PieceTable-backed helpers (`delete_text`/`join_lines`); ensure no dependency on legacy `rows_` mutation and no memory issues.
|
||||
3) Overwrite-confirm path behavior
|
||||
- Start with non-file-backed buffer named to collide with an existing file; perform `SaveAs(existing_path)` and assert success and correctness on disk (unit test bypasses interactive confirm, validating underlying write path).
|
||||
|
||||
#### H) Performance/stress sanity
|
||||
1) Many small edits
|
||||
- 10k single-char inserts and interleaved deletes; assert final bytes; keep within conservative runtime bounds.
|
||||
2) Consolidation heuristics
|
||||
- After many edits, call both `WriteToStream` and `Data()` and verify identical bytes.
|
||||
|
||||
---
|
||||
|
||||
### Coverage roadmap
|
||||
- Phase 1 (already implemented and passing):
|
||||
- Buffer I/O basics (A.1–A.3), PieceTable basics (B.1–B.2), Search parity (E.1).
|
||||
- Phase 2 (add next):
|
||||
- Buffer I/O edge cases (A.4–A.7), deeper PieceTable ops (B.3–B.6), Buffer helpers and cache (C.1–C.4), Undo semantics (D.1–D.2), Regression set (G.1–G.3).
|
||||
- Phase 3:
|
||||
- Editor flows (F.1–F.3), performance/stress (H.1–H.2), and optional integration of `Command.cc` into the test target to exercise non-interactive command execution paths directly.
|
||||
|
||||
### Notes
|
||||
- Use per-test temp files under the repo root or a unique temp directory; ensure cleanup after assertions.
|
||||
- For HOME-dependent tests (tilde expansion), set `HOME` in the test process if not present or skip with a clear message.
|
||||
- On macOS Debug, a benign allocator warning may appear; rely on process exit code for pass/fail.
|
||||
@@ -13,9 +13,9 @@
|
||||
packages = eachSystem (system: rec {
|
||||
default = kte;
|
||||
full = kge;
|
||||
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; };
|
||||
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; };
|
||||
qt = (pkgsFor system).callPackage ./default.nix { graphical-qt = true; }
|
||||
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; graphical-qt = false; };
|
||||
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; };
|
||||
qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; };
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
5438
fonts/BerkeleyMono.h
Normal file
5438
fonts/BerkeleyMono.h
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "B612Mono.h"
|
||||
#include "BerkeleyMono.h"
|
||||
#include "BrassMono.h"
|
||||
#include "BrassMonoCode.h"
|
||||
#include "FiraCode.h"
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
13
main.cc
13
main.cc
@@ -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,8 +195,6 @@ 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
|
||||
#if defined(KTE_DEFAULT_GUI)
|
||||
use_gui = true;
|
||||
@@ -302,11 +303,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);
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// Simple buffer correctness tests comparing GapBuffer and PieceTable to std::string
|
||||
#include <cassert>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
#include "PieceTable.h"
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
static void
|
||||
check_equals(const Buf &b, const std::string &ref)
|
||||
{
|
||||
assert(b.Size() == ref.size());
|
||||
if (b.Size() == 0)
|
||||
return;
|
||||
const char *p = b.Data();
|
||||
assert(p != nullptr);
|
||||
assert(std::memcmp(p, ref.data(), ref.size()) == 0);
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
static void
|
||||
run_basic_cases()
|
||||
{
|
||||
// empty
|
||||
{
|
||||
Buf b;
|
||||
std::string ref;
|
||||
check_equals(b, ref);
|
||||
}
|
||||
|
||||
// append chars
|
||||
{
|
||||
Buf b;
|
||||
std::string ref;
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
b.AppendChar('a');
|
||||
ref.push_back('a');
|
||||
}
|
||||
check_equals(b, ref);
|
||||
}
|
||||
|
||||
// prepend chars
|
||||
{
|
||||
Buf b;
|
||||
std::string ref;
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
b.PrependChar('b');
|
||||
ref.insert(ref.begin(), 'b');
|
||||
}
|
||||
check_equals(b, ref);
|
||||
}
|
||||
|
||||
// append/prepend strings
|
||||
{
|
||||
Buf b;
|
||||
std::string ref;
|
||||
const char *hello = "hello";
|
||||
b.Append(hello, 5);
|
||||
ref.append("hello");
|
||||
b.Prepend(hello, 5);
|
||||
ref.insert(0, "hello");
|
||||
check_equals(b, ref);
|
||||
}
|
||||
|
||||
// larger random blocks
|
||||
{
|
||||
std::mt19937 rng(42);
|
||||
std::uniform_int_distribution<int> len_dist(0, 128);
|
||||
std::uniform_int_distribution<int> coin(0, 1);
|
||||
Buf b;
|
||||
std::string ref;
|
||||
for (int step = 0; step < 2000; ++step) {
|
||||
int L = len_dist(rng);
|
||||
std::string payload(L, '\0');
|
||||
for (int i = 0; i < L; ++i)
|
||||
payload[i] = static_cast<char>('a' + (i % 26));
|
||||
if (coin(rng)) {
|
||||
b.Append(payload.data(), payload.size());
|
||||
ref.append(payload);
|
||||
} else {
|
||||
b.Prepend(payload.data(), payload.size());
|
||||
ref.insert(0, payload);
|
||||
}
|
||||
}
|
||||
check_equals(b, ref);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
main()
|
||||
{
|
||||
run_basic_cases<GapBuffer>();
|
||||
run_basic_cases<PieceTable>();
|
||||
return 0;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Verify OptimizedSearch against std::string reference across patterns and sizes
|
||||
#include <cassert>
|
||||
#include <cstddef>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "OptimizedSearch.h"
|
||||
|
||||
|
||||
static std::vector<std::size_t>
|
||||
ref_find_all(const std::string &text, const std::string &pat)
|
||||
{
|
||||
std::vector<std::size_t> res;
|
||||
if (pat.empty())
|
||||
return res;
|
||||
std::size_t from = 0;
|
||||
while (true) {
|
||||
auto p = text.find(pat, from);
|
||||
if (p == std::string::npos)
|
||||
break;
|
||||
res.push_back(p);
|
||||
from = p + pat.size(); // non-overlapping
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
run_case(std::size_t textLen, std::size_t patLen, unsigned seed)
|
||||
{
|
||||
std::mt19937 rng(seed);
|
||||
std::uniform_int_distribution<int> dist('a', 'z');
|
||||
std::string text(textLen, '\0');
|
||||
for (auto &ch: text)
|
||||
ch = static_cast<char>(dist(rng));
|
||||
std::string pat(patLen, '\0');
|
||||
for (auto &ch: pat)
|
||||
ch = static_cast<char>(dist(rng));
|
||||
|
||||
// Guarantee at least one match when possible
|
||||
if (textLen >= patLen && patLen > 0) {
|
||||
std::size_t pos = textLen / 3;
|
||||
if (pos + patLen <= text.size())
|
||||
std::copy(pat.begin(), pat.end(), text.begin() + static_cast<long>(pos));
|
||||
}
|
||||
|
||||
OptimizedSearch os;
|
||||
auto got = os.find_all(text, pat, 0);
|
||||
auto ref = ref_find_all(text, pat);
|
||||
assert(got == ref);
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
main()
|
||||
{
|
||||
// Edge cases
|
||||
run_case(0, 0, 1);
|
||||
run_case(0, 1, 2);
|
||||
run_case(1, 0, 3);
|
||||
run_case(1, 1, 4);
|
||||
|
||||
// Various sizes
|
||||
for (std::size_t t = 128; t <= 4096; t *= 2) {
|
||||
for (std::size_t p = 1; p <= 64; p *= 2) {
|
||||
run_case(t, p, static_cast<unsigned>(t + p));
|
||||
}
|
||||
}
|
||||
// Larger random
|
||||
run_case(100000, 16, 12345);
|
||||
run_case(250000, 32, 67890);
|
||||
return 0;
|
||||
}
|
||||
338
test_undo.cc
338
test_undo.cc
@@ -1,338 +0,0 @@
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
#include "TestFrontend.h"
|
||||
|
||||
|
||||
int
|
||||
main()
|
||||
{
|
||||
// Install default commands
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor editor;
|
||||
TestFrontend frontend;
|
||||
|
||||
// Initialize frontend
|
||||
if (!frontend.Init(editor)) {
|
||||
std::cerr << "Failed to initialize frontend\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Create a temporary test file
|
||||
std::string err;
|
||||
const char *tmpfile = "/tmp/kte_test_undo.txt";
|
||||
{
|
||||
std::ofstream f(tmpfile);
|
||||
if (!f) {
|
||||
std::cerr << "Failed to create temp file\n";
|
||||
return 1;
|
||||
}
|
||||
f << "\n"; // Write one newline so file isn't empty
|
||||
f.close();
|
||||
}
|
||||
|
||||
if (!editor.OpenFile(tmpfile, err)) {
|
||||
std::cerr << "Failed to open test file: " << err << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
Buffer *buf = editor.CurrentBuffer();
|
||||
assert(buf != nullptr);
|
||||
|
||||
// Initialize cursor to (0,0) explicitly
|
||||
buf->SetCursor(0, 0);
|
||||
|
||||
std::cout << "test_undo: Testing undo/redo system\n";
|
||||
std::cout << "====================================\n\n";
|
||||
|
||||
bool running = true;
|
||||
|
||||
// Test 1: Insert text and verify buffer contains expected text
|
||||
std::cout << "Test 1: Insert text 'Hello'\n";
|
||||
frontend.Input().QueueText("Hello");
|
||||
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
|
||||
assert(buf->Rows().size() >= 1);
|
||||
std::string line_after_insert = std::string(buf->Rows()[0]);
|
||||
assert(line_after_insert == "Hello");
|
||||
std::cout << " Buffer content: '" << line_after_insert << "'\n";
|
||||
std::cout << " ✓ Text insertion verified\n\n";
|
||||
|
||||
// Test 2: Undo insertion - text should be removed
|
||||
std::cout << "Test 2: Undo insertion\n";
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
|
||||
assert(buf->Rows().size() >= 1);
|
||||
std::string line_after_undo = std::string(buf->Rows()[0]);
|
||||
assert(line_after_undo == "");
|
||||
std::cout << " Buffer content: '" << line_after_undo << "'\n";
|
||||
std::cout << " ✓ Undo successful - text removed\n\n";
|
||||
|
||||
// Test 3: Redo insertion - text should be restored
|
||||
std::cout << "Test 3: Redo insertion\n";
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
|
||||
assert(buf->Rows().size() >= 1);
|
||||
std::string line_after_redo = std::string(buf->Rows()[0]);
|
||||
assert(line_after_redo == "Hello");
|
||||
std::cout << " Buffer content: '" << line_after_redo << "'\n";
|
||||
std::cout << " ✓ Redo successful - text restored\n\n";
|
||||
|
||||
// Test 4: Branching behavior – redo is discarded after new edits
|
||||
std::cout << "Test 4: Branching behavior (redo discarded after new edits)\n";
|
||||
// Reset to empty by undoing the last redo and the original insert, then reinsert 'abc'
|
||||
// Ensure buffer is empty before starting this scenario
|
||||
frontend.Input().QueueCommand(CommandId::Undo); // undo Hello
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
|
||||
// Type a contiguous word 'abc' (single batch)
|
||||
frontend.Input().QueueText("abc");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abc");
|
||||
|
||||
// Undo once – should remove the whole batch and leave empty
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
|
||||
// Now type new text 'X' – this should create a new branch and discard old redo chain
|
||||
frontend.Input().QueueText("X");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "X");
|
||||
|
||||
// Attempt Redo – should be a no-op (redo branch was discarded by new edit)
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "X");
|
||||
// Undo and Redo along the new branch should still work
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "X");
|
||||
std::cout << " ✓ Redo discarded after new edit; new branch undo/redo works\n\n";
|
||||
|
||||
// Clear buffer state for next tests: undo to empty if needed
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
|
||||
// Test 5: UTF-8 insertion and undo/redo round-trip
|
||||
std::cout << "Test 5: UTF-8 insertion 'é漢' and undo/redo\n";
|
||||
const std::string utf8_text = "é漢"; // multi-byte UTF-8 (2 bytes + 3 bytes)
|
||||
frontend.Input().QueueText(utf8_text);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == utf8_text);
|
||||
// Undo should remove the entire contiguous insertion batch
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
// Redo restores it
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == utf8_text);
|
||||
std::cout << " ✓ UTF-8 insert round-trips with undo/redo\n\n";
|
||||
|
||||
// Clear for next test
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "");
|
||||
|
||||
// Test 6: Multi-line operations (newline split and join via backspace at BOL)
|
||||
std::cout << "Test 6: Newline split and join via backspace at BOL\n";
|
||||
// Insert "ab" then newline then "cd" → expect two lines
|
||||
frontend.Input().QueueText("ab");
|
||||
frontend.Input().QueueCommand(CommandId::Newline);
|
||||
frontend.Input().QueueText("cd");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 2);
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
assert(std::string(buf->Rows()[1]) == "cd");
|
||||
std::cout << " ✓ Split into two lines\n";
|
||||
|
||||
// Undo once – should remove "cd" insertion leaving two lines ["ab", ""] or join depending on commit
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
// Current design batches typing on the second line; after undo, the second line should exist but be empty
|
||||
assert(buf->Rows().size() >= 2);
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
assert(std::string(buf->Rows()[1]) == "");
|
||||
|
||||
// Undo the newline – should rejoin to a single line "ab"
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 1);
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
|
||||
// Redo twice to get back to ["ab","cd"]
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "ab");
|
||||
assert(std::string(buf->Rows()[1]) == "cd");
|
||||
std::cout << " ✓ Newline undo/redo round-trip\n";
|
||||
|
||||
// Now join via Backspace at beginning of second line
|
||||
frontend.Input().QueueCommand(CommandId::MoveDown); // ensure we're on the second line
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome); // go to BOL on second line
|
||||
frontend.Input().QueueCommand(CommandId::Backspace); // join with previous line
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 1);
|
||||
assert(std::string(buf->Rows()[0]) == "abcd");
|
||||
std::cout << " ✓ Backspace at BOL joins lines\n";
|
||||
|
||||
// Undo/Redo the join
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(buf->Rows().size() >= 1);
|
||||
assert(std::string(buf->Rows()[0]) == "abcd");
|
||||
std::cout << " ✓ Join undo/redo round-trip\n\n";
|
||||
|
||||
// Test 7: Typing batching – a contiguous word undone in one step
|
||||
std::cout << "Test 7: Typing batching (single undo removes whole word)\n";
|
||||
// Clear current line first
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
||||
frontend.Input().QueueCommand(CommandId::KillToEOL);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]).empty());
|
||||
// Type a word and verify one undo clears it
|
||||
frontend.Input().QueueText("hello");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "hello");
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]).empty());
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "hello");
|
||||
std::cout << " ✓ Contiguous typing batched into single undo step\n\n";
|
||||
|
||||
// Test 8: Forward delete batching at a fixed anchor column
|
||||
std::cout << "Test 8: Forward delete batching at fixed anchor (DeleteChar)\n";
|
||||
// Prepare line content
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
||||
frontend.Input().QueueCommand(CommandId::KillToEOL);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
frontend.Input().QueueText("abcdef");
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
// Ensure cursor at anchor column 0
|
||||
frontend.Input().QueueCommand(CommandId::MoveHome);
|
||||
// Delete three chars at cursor; should batch into one Delete node
|
||||
frontend.Input().QueueCommand(CommandId::DeleteChar, "", 3);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "def");
|
||||
// Single undo should restore the entire deleted run
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
||||
// Redo should remove the same run again
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "def");
|
||||
std::cout << " ✓ Forward delete batched and undo/redo round-trips\n\n";
|
||||
|
||||
// Test 9: Backspace batching with prepend rule (cursor moves left)
|
||||
std::cout << "Test 9: Backspace batching with prepend rule\n";
|
||||
// Restore to full string then backspace a run
|
||||
frontend.Input().QueueCommand(CommandId::Undo); // bring back to "abcdef"
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
||||
// Move to end and backspace three characters; should batch into one Delete node
|
||||
frontend.Input().QueueCommand(CommandId::MoveEnd);
|
||||
frontend.Input().QueueCommand(CommandId::Backspace, "", 3);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abc");
|
||||
// Single undo restores the deleted run
|
||||
frontend.Input().QueueCommand(CommandId::Undo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abcdef");
|
||||
// Redo removes it again
|
||||
frontend.Input().QueueCommand(CommandId::Redo);
|
||||
while (!frontend.Input().IsEmpty() && running) {
|
||||
frontend.Step(editor, running);
|
||||
}
|
||||
assert(std::string(buf->Rows()[0]) == "abc");
|
||||
std::cout << " ✓ Backspace run batched and undo/redo round-trips\n\n";
|
||||
|
||||
frontend.Shutdown();
|
||||
|
||||
std::cout << "====================================\n";
|
||||
std::cout << "All tests passed!\n";
|
||||
|
||||
return 0;
|
||||
}
|
||||
63
tests/Test.h
Normal file
63
tests/Test.h
Normal file
@@ -0,0 +1,63 @@
|
||||
// Minimal header-only unit test framework for kte
|
||||
#pragma once
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <chrono>
|
||||
#include <sstream>
|
||||
|
||||
namespace ktet {
|
||||
|
||||
struct TestCase {
|
||||
std::string name;
|
||||
std::function<void()> fn;
|
||||
};
|
||||
|
||||
inline std::vector<TestCase>& registry() {
|
||||
static std::vector<TestCase> r;
|
||||
return r;
|
||||
}
|
||||
|
||||
struct Registrar {
|
||||
Registrar(const char* name, std::function<void()> fn) {
|
||||
registry().push_back(TestCase{std::string(name), std::move(fn)});
|
||||
}
|
||||
};
|
||||
|
||||
// Assertions
|
||||
struct AssertionFailure {
|
||||
std::string msg;
|
||||
};
|
||||
|
||||
inline void expect(bool cond, const char* expr, const char* file, int line) {
|
||||
if (!cond) {
|
||||
std::cerr << file << ":" << line << ": EXPECT failed: " << expr << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
inline void assert_true(bool cond, const char* expr, const char* file, int line) {
|
||||
if (!cond) {
|
||||
throw AssertionFailure{std::string(file) + ":" + std::to_string(line) + ": ASSERT failed: " + expr};
|
||||
}
|
||||
}
|
||||
|
||||
template<typename A, typename B>
|
||||
inline void assert_eq_impl(const A& a, const B& b, const char* ea, const char* eb, const char* file, int line) {
|
||||
if (!(a == b)) {
|
||||
std::ostringstream oss;
|
||||
oss << file << ":" << line << ": ASSERT_EQ failed: " << ea << " == " << eb;
|
||||
throw AssertionFailure{oss.str()};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ktet
|
||||
|
||||
#define TEST(name) \
|
||||
static void name(); \
|
||||
static ::ktet::Registrar _reg_##name(#name, &name); \
|
||||
static void name()
|
||||
|
||||
#define EXPECT_TRUE(x) ::ktet::expect((x), #x, __FILE__, __LINE__)
|
||||
#define ASSERT_TRUE(x) ::ktet::assert_true((x), #x, __FILE__, __LINE__)
|
||||
#define ASSERT_EQ(a,b) ::ktet::assert_eq_impl((a),(b), #a, #b, __FILE__, __LINE__)
|
||||
138
tests/TestHarness.h
Normal file
138
tests/TestHarness.h
Normal file
@@ -0,0 +1,138 @@
|
||||
// TestHarness.h - small helper layer for driving kte headlessly in tests
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
namespace ktet {
|
||||
inline void
|
||||
InstallDefaultCommandsOnce()
|
||||
{
|
||||
static bool installed = false;
|
||||
if (!installed) {
|
||||
InstallDefaultCommands();
|
||||
installed = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestHarness {
|
||||
public:
|
||||
TestHarness()
|
||||
{
|
||||
InstallDefaultCommandsOnce();
|
||||
editor_.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
b.SetVirtualName("+TEST+");
|
||||
editor_.AddBuffer(std::move(b));
|
||||
}
|
||||
|
||||
|
||||
Editor &
|
||||
EditorRef()
|
||||
{
|
||||
return editor_;
|
||||
}
|
||||
|
||||
|
||||
Buffer &
|
||||
Buf()
|
||||
{
|
||||
return *editor_.CurrentBuffer();
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] const Buffer &
|
||||
Buf() const
|
||||
{
|
||||
return *editor_.CurrentBuffer();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Exec(CommandId id, const std::string &arg = std::string(), int ucount = 0)
|
||||
{
|
||||
if (ucount > 0) {
|
||||
editor_.SetUniversalArg(1, ucount);
|
||||
} else {
|
||||
editor_.UArgClear();
|
||||
}
|
||||
return Execute(editor_, id, arg);
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
InsertText(std::string_view text)
|
||||
{
|
||||
if (text.find('\n') != std::string_view::npos || text.find('\r') != std::string_view::npos)
|
||||
return false;
|
||||
return Exec(CommandId::InsertText, std::string(text));
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
TypeText(std::string_view text)
|
||||
{
|
||||
for (char ch: text) {
|
||||
if (ch == '\n') {
|
||||
Exec(CommandId::Newline);
|
||||
} else if (ch == '\r') {
|
||||
// ignore
|
||||
} else {
|
||||
Exec(CommandId::InsertText, std::string(1, ch));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::string
|
||||
Text() const
|
||||
{
|
||||
const auto &rows = Buf().Rows();
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||
out += static_cast<std::string>(rows[i]);
|
||||
if (i + 1 < rows.size())
|
||||
out.push_back('\n');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::string
|
||||
Line(std::size_t y) const
|
||||
{
|
||||
return Buf().GetLineString(y);
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
SaveAs(const std::string &path, std::string &err)
|
||||
{
|
||||
return Buf().SaveAs(path, err);
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Undo(int ucount = 0)
|
||||
{
|
||||
return Exec(CommandId::Undo, std::string(), ucount);
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Redo(int ucount = 0)
|
||||
{
|
||||
return Exec(CommandId::Redo, std::string(), ucount);
|
||||
}
|
||||
|
||||
private:
|
||||
Editor editor_;
|
||||
};
|
||||
} // namespace ktet
|
||||
33
tests/TestRunner.cc
Normal file
33
tests/TestRunner.cc
Normal file
@@ -0,0 +1,33 @@
|
||||
#include "Test.h"
|
||||
#include <iostream>
|
||||
#include <chrono>
|
||||
|
||||
int main() {
|
||||
using namespace std::chrono;
|
||||
auto ® = ktet::registry();
|
||||
std::cout << "kte unit tests: " << reg.size() << " test(s)\n";
|
||||
int failed = 0;
|
||||
auto t0 = steady_clock::now();
|
||||
for (const auto &tc : reg) {
|
||||
auto ts = steady_clock::now();
|
||||
try {
|
||||
tc.fn();
|
||||
auto te = steady_clock::now();
|
||||
auto ms = duration_cast<milliseconds>(te - ts).count();
|
||||
std::cout << "[ OK ] " << tc.name << " (" << ms << " ms)\n";
|
||||
} catch (const ktet::AssertionFailure &e) {
|
||||
++failed;
|
||||
std::cerr << "[FAIL] " << tc.name << " -> " << e.msg << "\n";
|
||||
} catch (const std::exception &e) {
|
||||
++failed;
|
||||
std::cerr << "[EXCP] " << tc.name << " -> " << e.what() << "\n";
|
||||
} catch (...) {
|
||||
++failed;
|
||||
std::cerr << "[EXCP] " << tc.name << " -> unknown exception\n";
|
||||
}
|
||||
}
|
||||
auto t1 = steady_clock::now();
|
||||
auto total_ms = duration_cast<milliseconds>(t1 - t0).count();
|
||||
std::cout << "Done in " << total_ms << " ms. Failures: " << failed << "\n";
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
79
tests/test_buffer_io.cc
Normal file
79
tests/test_buffer_io.cc
Normal file
@@ -0,0 +1,79 @@
|
||||
#include "Test.h"
|
||||
#include <fstream>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include "Buffer.h"
|
||||
|
||||
static std::string read_all(const std::string &path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
TEST(Buffer_SaveAs_and_Save_new_file) {
|
||||
const std::string path = "./.kte_ut_buffer_io_1.tmp";
|
||||
std::remove(path.c_str());
|
||||
|
||||
Buffer b;
|
||||
// insert two lines
|
||||
b.insert_text(0, 0, std::string("Hello, world!\n"));
|
||||
b.insert_text(1, 0, std::string("Second line\n"));
|
||||
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.SaveAs(path, err));
|
||||
ASSERT_EQ(err.empty(), true);
|
||||
|
||||
// append another line then Save()
|
||||
b.insert_text(2, 0, std::string("Third\n"));
|
||||
b.SetDirty(true);
|
||||
ASSERT_TRUE(b.Save(err));
|
||||
ASSERT_EQ(err.empty(), true);
|
||||
|
||||
std::string got = read_all(path);
|
||||
ASSERT_EQ(got, std::string("Hello, world!\nSecond line\nThird\n"));
|
||||
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST(Buffer_Save_after_Open_existing) {
|
||||
const std::string path = "./.kte_ut_buffer_io_2.tmp";
|
||||
std::remove(path.c_str());
|
||||
{
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
out << "abc\n123\n";
|
||||
}
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(err.empty(), true);
|
||||
|
||||
b.insert_text(2, 0, std::string("tail\n"));
|
||||
b.SetDirty(true);
|
||||
ASSERT_TRUE(b.Save(err));
|
||||
ASSERT_EQ(err.empty(), true);
|
||||
|
||||
std::string got = read_all(path);
|
||||
ASSERT_EQ(got, std::string("abc\n123\ntail\n"));
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST(Buffer_Open_nonexistent_then_SaveAs) {
|
||||
const std::string path = "./.kte_ut_buffer_io_3.tmp";
|
||||
std::remove(path.c_str());
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_EQ(err.empty(), true);
|
||||
ASSERT_EQ(b.IsFileBacked(), false);
|
||||
|
||||
b.insert_text(0, 0, std::string("hello, world"));
|
||||
b.insert_text(0, 12, std::string("\n"));
|
||||
b.SetDirty(true);
|
||||
ASSERT_TRUE(b.SaveAs(path, err));
|
||||
ASSERT_EQ(err.empty(), true);
|
||||
|
||||
std::string got = read_all(path);
|
||||
ASSERT_EQ(got, std::string("hello, world\n"));
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
142
tests/test_buffer_rows.cc
Normal file
142
tests/test_buffer_rows.cc
Normal file
@@ -0,0 +1,142 @@
|
||||
#include "Test.h"
|
||||
#include "Buffer.h"
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
|
||||
static std::vector<std::string>
|
||||
split_lines_preserve_trailing_empty(const std::string &s)
|
||||
{
|
||||
std::vector<std::string> out;
|
||||
std::size_t start = 0;
|
||||
for (std::size_t i = 0; i <= s.size(); i++) {
|
||||
if (i == s.size() || s[i] == '\n') {
|
||||
out.push_back(s.substr(start, i - start));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (out.empty())
|
||||
out.push_back(std::string());
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
static std::vector<std::size_t>
|
||||
line_starts_for(const std::string &s)
|
||||
{
|
||||
std::vector<std::size_t> starts;
|
||||
starts.push_back(0);
|
||||
for (std::size_t i = 0; i < s.size(); i++) {
|
||||
if (s[i] == '\n')
|
||||
starts.push_back(i + 1);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
|
||||
static std::size_t
|
||||
ref_linecol_to_offset(const std::string &s, std::size_t row, std::size_t col)
|
||||
{
|
||||
auto starts = line_starts_for(s);
|
||||
if (starts.empty())
|
||||
return 0;
|
||||
if (row >= starts.size())
|
||||
return s.size();
|
||||
std::size_t start = starts[row];
|
||||
std::size_t end = (row + 1 < starts.size()) ? starts[row + 1] : s.size();
|
||||
if (end > start && s[end - 1] == '\n')
|
||||
end -= 1; // clamp before trailing newline
|
||||
return start + std::min(col, end - start);
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
check_buffer_matches_model(const Buffer &b, const std::string &model)
|
||||
{
|
||||
auto expected_lines = split_lines_preserve_trailing_empty(model);
|
||||
const auto &rows = b.Rows();
|
||||
ASSERT_EQ(rows.size(), expected_lines.size());
|
||||
ASSERT_EQ(b.Nrows(), rows.size());
|
||||
|
||||
auto starts = line_starts_for(model);
|
||||
ASSERT_EQ(starts.size(), expected_lines.size());
|
||||
|
||||
std::string via_views;
|
||||
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||
ASSERT_EQ(std::string(rows[i]), expected_lines[i]);
|
||||
ASSERT_EQ(b.GetLineString(i), expected_lines[i]);
|
||||
|
||||
std::size_t exp_start = starts[i];
|
||||
std::size_t exp_end = (i + 1 < starts.size()) ? starts[i + 1] : model.size();
|
||||
auto r = b.GetLineRange(i);
|
||||
ASSERT_EQ(r.first, exp_start);
|
||||
ASSERT_EQ(r.second, exp_end);
|
||||
|
||||
auto v = b.GetLineView(i);
|
||||
ASSERT_EQ(std::string(v), model.substr(exp_start, exp_end - exp_start));
|
||||
via_views.append(v.data(), v.size());
|
||||
}
|
||||
ASSERT_EQ(via_views, model);
|
||||
}
|
||||
|
||||
|
||||
TEST (Buffer_RowsCache_MultiLineEdits_StayConsistent)
|
||||
{
|
||||
Buffer b;
|
||||
std::string model;
|
||||
|
||||
check_buffer_matches_model(b, model);
|
||||
|
||||
// Insert text and newlines in a few different ways.
|
||||
b.insert_text(0, 0, std::string("abc"));
|
||||
model.insert(0, "abc");
|
||||
check_buffer_matches_model(b, model);
|
||||
|
||||
b.split_line(0, 1); // a\nbc
|
||||
model.insert(ref_linecol_to_offset(model, 0, 1), "\n");
|
||||
check_buffer_matches_model(b, model);
|
||||
|
||||
b.insert_text(1, 2, std::string("X")); // a\nbcX
|
||||
model.insert(ref_linecol_to_offset(model, 1, 2), "X");
|
||||
check_buffer_matches_model(b, model);
|
||||
|
||||
b.join_lines(0); // abcX
|
||||
{
|
||||
std::size_t off = ref_linecol_to_offset(model, 0, std::numeric_limits<std::size_t>::max());
|
||||
if (off < model.size() && model[off] == '\n')
|
||||
model.erase(off, 1);
|
||||
}
|
||||
check_buffer_matches_model(b, model);
|
||||
|
||||
// Insert a multi-line segment in one shot.
|
||||
b.insert_text(0, 2, std::string("\n123\nxyz"));
|
||||
model.insert(ref_linecol_to_offset(model, 0, 2), "\n123\nxyz");
|
||||
check_buffer_matches_model(b, model);
|
||||
|
||||
// Delete spanning across a newline.
|
||||
b.delete_text(0, 1, 5);
|
||||
{
|
||||
std::size_t start = ref_linecol_to_offset(model, 0, 1);
|
||||
std::size_t actual = std::min<std::size_t>(5, model.size() - start);
|
||||
model.erase(start, actual);
|
||||
}
|
||||
check_buffer_matches_model(b, model);
|
||||
|
||||
// Insert/delete whole rows.
|
||||
b.insert_row(1, std::string_view("ROW"));
|
||||
model.insert(ref_linecol_to_offset(model, 1, 0), "ROW\n");
|
||||
check_buffer_matches_model(b, model);
|
||||
|
||||
b.delete_row(1);
|
||||
{
|
||||
auto starts = line_starts_for(model);
|
||||
if (1 < (int) starts.size()) {
|
||||
std::size_t start = starts[1];
|
||||
std::size_t end = (2 < starts.size()) ? starts[2] : model.size();
|
||||
model.erase(start, end - start);
|
||||
}
|
||||
}
|
||||
check_buffer_matches_model(b, model);
|
||||
}
|
||||
91
tests/test_command_semantics.cc
Normal file
91
tests/test_command_semantics.cc
Normal file
@@ -0,0 +1,91 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "TestHarness.h"
|
||||
|
||||
using ktet::TestHarness;
|
||||
|
||||
|
||||
TEST (CommandSemantics_KillToEOL_KillChain_And_Yank)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
b.insert_text(0, 0, std::string("abc\ndef"));
|
||||
b.SetCursor(1, 0); // a|bc
|
||||
|
||||
ed.KillRingClear();
|
||||
ed.SetKillChain(false);
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
|
||||
ASSERT_EQ(h.Text(), std::string("a\ndef"));
|
||||
ASSERT_EQ(ed.KillRingHead(), std::string("bc"));
|
||||
|
||||
// At EOL, KillToEOL kills the newline (join).
|
||||
ASSERT_TRUE(h.Exec(CommandId::KillToEOL));
|
||||
ASSERT_EQ(h.Text(), std::string("adef"));
|
||||
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
|
||||
|
||||
// Yank pastes the kill ring head and breaks the kill chain.
|
||||
ASSERT_TRUE(h.Exec(CommandId::Yank));
|
||||
ASSERT_EQ(h.Text(), std::string("abc\ndef"));
|
||||
ASSERT_EQ(ed.KillRingHead(), std::string("bc\n"));
|
||||
ASSERT_EQ(ed.KillChain(), false);
|
||||
}
|
||||
|
||||
|
||||
TEST (CommandSemantics_ToggleMark_JumpToMark)
|
||||
{
|
||||
TestHarness h;
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
b.insert_text(0, 0, std::string("hello"));
|
||||
b.SetCursor(2, 0);
|
||||
ASSERT_EQ(b.MarkSet(), false);
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||
ASSERT_EQ(b.MarkSet(), true);
|
||||
ASSERT_EQ(b.MarkCurx(), (std::size_t) 2);
|
||||
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
|
||||
|
||||
b.SetCursor(4, 0);
|
||||
ASSERT_TRUE(h.Exec(CommandId::JumpToMark));
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 2);
|
||||
ASSERT_EQ(b.Cury(), (std::size_t) 0);
|
||||
// Jump-to-mark swaps: mark becomes previous cursor.
|
||||
ASSERT_EQ(b.MarkSet(), true);
|
||||
ASSERT_EQ(b.MarkCurx(), (std::size_t) 4);
|
||||
ASSERT_EQ(b.MarkCury(), (std::size_t) 0);
|
||||
}
|
||||
|
||||
|
||||
TEST (CommandSemantics_CopyRegion_And_KillRegion)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
b.insert_text(0, 0, std::string("hello world"));
|
||||
b.SetCursor(0, 0);
|
||||
|
||||
ed.KillRingClear();
|
||||
ed.SetKillChain(false);
|
||||
|
||||
// Copy "hello" (region [0,5)).
|
||||
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||
b.SetCursor(5, 0);
|
||||
ASSERT_TRUE(h.Exec(CommandId::CopyRegion));
|
||||
ASSERT_EQ(ed.KillRingHead(), std::string("hello"));
|
||||
ASSERT_EQ(b.MarkSet(), false);
|
||||
ASSERT_EQ(h.Text(), std::string("hello world"));
|
||||
|
||||
// Kill "world" (region [6,11)).
|
||||
ed.SetKillChain(false);
|
||||
b.SetCursor(6, 0);
|
||||
ASSERT_TRUE(h.Exec(CommandId::ToggleMark));
|
||||
b.SetCursor(11, 0);
|
||||
ASSERT_TRUE(h.Exec(CommandId::KillRegion));
|
||||
ASSERT_EQ(ed.KillRingHead(), std::string("world"));
|
||||
ASSERT_EQ(b.MarkSet(), false);
|
||||
ASSERT_EQ(h.Text(), std::string("hello "));
|
||||
}
|
||||
12
tests/test_daily_driver_harness.cc
Normal file
12
tests/test_daily_driver_harness.cc
Normal file
@@ -0,0 +1,12 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "tests/TestHarness.h"
|
||||
|
||||
|
||||
TEST (DailyDriverHarness_Smoke_CanCreateBufferAndInsertText)
|
||||
{
|
||||
ktet::TestHarness h;
|
||||
|
||||
ASSERT_TRUE(h.InsertText("hello"));
|
||||
ASSERT_EQ(h.Line(0), std::string("hello"));
|
||||
}
|
||||
170
tests/test_daily_workflows.cc
Normal file
170
tests/test_daily_workflows.cc
Normal file
@@ -0,0 +1,170 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
|
||||
static void
|
||||
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||
{
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
read_file_bytes(const std::string &path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
buffer_bytes_via_views(const Buffer &b)
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||
auto v = b.GetLineView(i);
|
||||
out.append(v.data(), v.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
TEST (DailyWorkflow_OpenEditSave_Transcript)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
|
||||
const std::string path = "./.kte_ut_daily_open_edit_save.txt";
|
||||
std::remove(path.c_str());
|
||||
write_file_bytes(path, "one\n");
|
||||
const std::string npath = std::filesystem::canonical(path).string();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
// Seed an empty buffer so OpenFile can reuse it.
|
||||
{
|
||||
Buffer scratch;
|
||||
ed.AddBuffer(std::move(scratch));
|
||||
}
|
||||
|
||||
std::string err;
|
||||
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
ASSERT_EQ(ed.CurrentBuffer()->Filename(), npath);
|
||||
|
||||
// Append two new lines via commands (no UI).
|
||||
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "two"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Newline));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "three"));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Save));
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
ASSERT_EQ(read_file_bytes(npath), buffer_bytes_via_views(*ed.CurrentBuffer()));
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(npath.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (DailyWorkflow_MultiBufferSwitchClose_Transcript)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
|
||||
const std::string p1 = "./.kte_ut_daily_buf_1.txt";
|
||||
const std::string p2 = "./.kte_ut_daily_buf_2.txt";
|
||||
std::remove(p1.c_str());
|
||||
std::remove(p2.c_str());
|
||||
write_file_bytes(p1, "aaa\n");
|
||||
write_file_bytes(p2, "bbb\n");
|
||||
const std::string np1 = std::filesystem::canonical(p1).string();
|
||||
const std::string np2 = std::filesystem::canonical(p2).string();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
{
|
||||
Buffer scratch;
|
||||
ed.AddBuffer(std::move(scratch));
|
||||
}
|
||||
|
||||
std::string err;
|
||||
ASSERT_TRUE(ed.OpenFile(p1, err));
|
||||
ASSERT_TRUE(ed.OpenFile(p2, err));
|
||||
ASSERT_EQ(ed.BufferCount(), (std::size_t) 2);
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
|
||||
|
||||
// Switch back and forth.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::BufferPrev));
|
||||
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
|
||||
ASSERT_TRUE(Execute(ed, CommandId::BufferNext));
|
||||
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np2);
|
||||
|
||||
// Close current buffer (p2); ensure we land on p1.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::BufferClose));
|
||||
ASSERT_EQ(ed.BufferCount(), (std::size_t) 1);
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
ASSERT_EQ(ed.CurrentBuffer()->Filename(), np1);
|
||||
|
||||
std::remove(p1.c_str());
|
||||
std::remove(p2.c_str());
|
||||
std::remove(np1.c_str());
|
||||
std::remove(np2.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (DailyWorkflow_CrashRecovery_SwapReplay_Transcript)
|
||||
{
|
||||
ktet::InstallDefaultCommandsOnce();
|
||||
|
||||
const std::string path = "./.kte_ut_daily_swap_recover.txt";
|
||||
std::remove(path.c_str());
|
||||
write_file_bytes(path, "base\nline2\n");
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
{
|
||||
Buffer scratch;
|
||||
ed.AddBuffer(std::move(scratch));
|
||||
}
|
||||
|
||||
std::string err;
|
||||
ASSERT_TRUE(ed.OpenFile(path, err));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
// Make unsaved edits through command execution.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::MoveDown));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::MoveHome));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "ZZ"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::MoveFileEnd));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "TAIL"));
|
||||
|
||||
// Ensure journal is durable and capture expected bytes.
|
||||
ed.Swap()->Flush(buf);
|
||||
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(*buf);
|
||||
const std::string expected = buffer_bytes_via_views(*buf);
|
||||
|
||||
// "Crash": reopen from disk (original file content) into a fresh Buffer and replay.
|
||||
Buffer recovered;
|
||||
ASSERT_TRUE(recovered.OpenFromFile(path, err));
|
||||
ASSERT_TRUE(kte::SwapManager::ReplayFile(recovered, swap_path, err));
|
||||
ASSERT_EQ(buffer_bytes_via_views(recovered), expected);
|
||||
|
||||
// Cleanup.
|
||||
ed.Swap()->Detach(buf);
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
84
tests/test_kkeymap.cc
Normal file
84
tests/test_kkeymap.cc
Normal file
@@ -0,0 +1,84 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "KKeymap.h"
|
||||
|
||||
#include <ncurses.h>
|
||||
|
||||
|
||||
TEST (KKeymap_KPrefix_CanonicalChords)
|
||||
{
|
||||
CommandId id{};
|
||||
|
||||
// From docs/ke.md (K-commands)
|
||||
ASSERT_TRUE(KLookupKCommand('s', false, id));
|
||||
ASSERT_EQ(id, CommandId::Save);
|
||||
ASSERT_TRUE(KLookupKCommand('s', true, id)); // C-k C-s
|
||||
ASSERT_EQ(id, CommandId::Save);
|
||||
|
||||
ASSERT_TRUE(KLookupKCommand('d', false, id));
|
||||
ASSERT_EQ(id, CommandId::KillToEOL);
|
||||
ASSERT_TRUE(KLookupKCommand('d', true, id)); // C-k C-d
|
||||
ASSERT_EQ(id, CommandId::KillLine);
|
||||
|
||||
ASSERT_TRUE(KLookupKCommand(' ', false, id)); // C-k SPACE
|
||||
ASSERT_EQ(id, CommandId::ToggleMark);
|
||||
|
||||
ASSERT_TRUE(KLookupKCommand('j', false, id));
|
||||
ASSERT_EQ(id, CommandId::JumpToMark);
|
||||
|
||||
ASSERT_TRUE(KLookupKCommand('f', false, id));
|
||||
ASSERT_EQ(id, CommandId::FlushKillRing);
|
||||
|
||||
ASSERT_TRUE(KLookupKCommand('y', false, id));
|
||||
ASSERT_EQ(id, CommandId::Yank);
|
||||
|
||||
// Unknown should not map
|
||||
ASSERT_EQ(KLookupKCommand('Z', false, id), false);
|
||||
}
|
||||
|
||||
|
||||
TEST (KKeymap_CtrlChords_CanonicalChords)
|
||||
{
|
||||
CommandId id{};
|
||||
|
||||
// From docs/ke.md (other keybindings)
|
||||
ASSERT_TRUE(KLookupCtrlCommand('n', id));
|
||||
ASSERT_EQ(id, CommandId::MoveDown);
|
||||
ASSERT_TRUE(KLookupCtrlCommand('p', id));
|
||||
ASSERT_EQ(id, CommandId::MoveUp);
|
||||
ASSERT_TRUE(KLookupCtrlCommand('f', id));
|
||||
ASSERT_EQ(id, CommandId::MoveRight);
|
||||
ASSERT_TRUE(KLookupCtrlCommand('b', id));
|
||||
ASSERT_EQ(id, CommandId::MoveLeft);
|
||||
|
||||
ASSERT_TRUE(KLookupCtrlCommand('w', id));
|
||||
ASSERT_EQ(id, CommandId::KillRegion);
|
||||
ASSERT_TRUE(KLookupCtrlCommand('y', id));
|
||||
ASSERT_EQ(id, CommandId::Yank);
|
||||
|
||||
ASSERT_EQ(KLookupCtrlCommand('z', id), false);
|
||||
}
|
||||
|
||||
|
||||
TEST (KKeymap_EscChords_CanonicalChords)
|
||||
{
|
||||
CommandId id{};
|
||||
|
||||
// From docs/ke.md (ESC bindings)
|
||||
ASSERT_TRUE(KLookupEscCommand('b', id));
|
||||
ASSERT_EQ(id, CommandId::WordPrev);
|
||||
ASSERT_TRUE(KLookupEscCommand('f', id));
|
||||
ASSERT_EQ(id, CommandId::WordNext);
|
||||
ASSERT_TRUE(KLookupEscCommand('d', id));
|
||||
ASSERT_EQ(id, CommandId::DeleteWordNext);
|
||||
ASSERT_TRUE(KLookupEscCommand('q', id));
|
||||
ASSERT_EQ(id, CommandId::ReflowParagraph);
|
||||
ASSERT_TRUE(KLookupEscCommand('w', id));
|
||||
ASSERT_EQ(id, CommandId::CopyRegion);
|
||||
|
||||
// ESC BACKSPACE
|
||||
ASSERT_TRUE(KLookupEscCommand(KEY_BACKSPACE, id));
|
||||
ASSERT_EQ(id, CommandId::DeleteWordPrev);
|
||||
|
||||
ASSERT_EQ(KLookupEscCommand('z', id), false);
|
||||
}
|
||||
181
tests/test_piece_table.cc
Normal file
181
tests/test_piece_table.cc
Normal file
@@ -0,0 +1,181 @@
|
||||
#include "Test.h"
|
||||
#include "PieceTable.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
|
||||
static std::vector<std::size_t>
|
||||
LineStartsFor(const std::string &s)
|
||||
{
|
||||
std::vector<std::size_t> starts;
|
||||
starts.push_back(0);
|
||||
for (std::size_t i = 0; i < s.size(); i++) {
|
||||
if (s[i] == '\n')
|
||||
starts.push_back(i + 1);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
LineContentFor(const std::string &s, std::size_t line_num)
|
||||
{
|
||||
auto starts = LineStartsFor(s);
|
||||
if (starts.empty() || line_num >= starts.size())
|
||||
return std::string();
|
||||
std::size_t start = starts[line_num];
|
||||
std::size_t end = (line_num + 1 < starts.size()) ? starts[line_num + 1] : s.size();
|
||||
if (end > start && s[end - 1] == '\n')
|
||||
end -= 1;
|
||||
return s.substr(start, end - start);
|
||||
}
|
||||
|
||||
|
||||
TEST (PieceTable_Insert_Delete_LineCount)
|
||||
{
|
||||
PieceTable pt;
|
||||
// start empty
|
||||
ASSERT_EQ(pt.Size(), (std::size_t) 0);
|
||||
ASSERT_EQ(pt.LineCount(), (std::size_t) 1); // empty buffer has 1 logical line
|
||||
|
||||
// Insert some text with newlines
|
||||
const char *t = "abc\n123\nxyz"; // last line without trailing NL
|
||||
pt.Insert(0, t, 11);
|
||||
ASSERT_EQ(pt.Size(), (std::size_t) 11);
|
||||
ASSERT_EQ(pt.LineCount(), (std::size_t) 3);
|
||||
|
||||
// Check get line
|
||||
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
|
||||
ASSERT_EQ(pt.GetLine(1), std::string("123"));
|
||||
ASSERT_EQ(pt.GetLine(2), std::string("xyz"));
|
||||
|
||||
// Delete middle line entirely including its trailing NL
|
||||
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
|
||||
pt.Delete(r.first, r.second - r.first);
|
||||
ASSERT_EQ(pt.LineCount(), (std::size_t) 2);
|
||||
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
|
||||
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
|
||||
}
|
||||
|
||||
|
||||
TEST (PieceTable_LineCol_Conversions)
|
||||
{
|
||||
PieceTable pt;
|
||||
std::string s = "hello\nworld\n"; // two lines with trailing NL
|
||||
pt.Insert(0, s.data(), s.size());
|
||||
|
||||
// Byte offsets of starts
|
||||
auto off0 = pt.LineColToByteOffset(0, 0);
|
||||
auto off1 = pt.LineColToByteOffset(1, 0);
|
||||
auto off2 = pt.LineColToByteOffset(2, 0); // EOF
|
||||
ASSERT_EQ(off0, (std::size_t) 0);
|
||||
ASSERT_EQ(off1, (std::size_t) 6); // "hello\n"
|
||||
ASSERT_EQ(off2, pt.Size());
|
||||
|
||||
auto lc0 = pt.ByteOffsetToLineCol(0);
|
||||
auto lc1 = pt.ByteOffsetToLineCol(6);
|
||||
ASSERT_EQ(lc0.first, (std::size_t) 0);
|
||||
ASSERT_EQ(lc0.second, (std::size_t) 0);
|
||||
ASSERT_EQ(lc1.first, (std::size_t) 1);
|
||||
ASSERT_EQ(lc1.second, (std::size_t) 0);
|
||||
}
|
||||
|
||||
|
||||
TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic)
|
||||
{
|
||||
PieceTable pt;
|
||||
std::string model;
|
||||
|
||||
std::mt19937 rng(0xC0FFEEu);
|
||||
const std::vector<std::string> corpus = {
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"xyz",
|
||||
"123",
|
||||
"\n",
|
||||
"!\n",
|
||||
"foo\nbar",
|
||||
"end\n",
|
||||
};
|
||||
|
||||
auto check_invariants = [&](const char *where) {
|
||||
(void) where;
|
||||
ASSERT_EQ(pt.Size(), model.size());
|
||||
ASSERT_EQ(pt.GetRange(0, pt.Size()), model);
|
||||
|
||||
auto starts = LineStartsFor(model);
|
||||
ASSERT_EQ(pt.LineCount(), starts.size());
|
||||
|
||||
// Spot-check a few line ranges and contents.
|
||||
std::size_t last = starts.empty() ? (std::size_t) 0 : (starts.size() - 1);
|
||||
std::size_t mid = (starts.size() > 2) ? (std::size_t) 1 : last;
|
||||
const std::array<std::size_t, 3> probe_lines = {(std::size_t) 0, last, mid};
|
||||
for (auto line: probe_lines) {
|
||||
if (starts.empty())
|
||||
break;
|
||||
if (line >= starts.size())
|
||||
continue;
|
||||
std::size_t exp_start = starts[line];
|
||||
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
|
||||
auto r = pt.GetLineRange(line);
|
||||
ASSERT_EQ(r.first, exp_start);
|
||||
ASSERT_EQ(r.second, exp_end);
|
||||
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
|
||||
}
|
||||
|
||||
// Round-trips for a few offsets.
|
||||
const std::vector<std::size_t> probe_offsets = {
|
||||
0,
|
||||
model.size() / 2,
|
||||
model.size(),
|
||||
};
|
||||
for (auto off: probe_offsets) {
|
||||
auto lc = pt.ByteOffsetToLineCol(off);
|
||||
auto back = pt.LineColToByteOffset(lc.first, lc.second);
|
||||
ASSERT_EQ(back, off);
|
||||
}
|
||||
};
|
||||
|
||||
check_invariants("initial");
|
||||
|
||||
for (int step = 0; step < 250; step++) {
|
||||
bool do_insert = model.empty() || ((rng() % 3u) != 0u); // bias toward insert
|
||||
if (do_insert) {
|
||||
const std::string &ins = corpus[rng() % corpus.size()];
|
||||
std::size_t pos = model.empty() ? 0 : (rng() % (model.size() + 1));
|
||||
pt.Insert(pos, ins.data(), ins.size());
|
||||
model.insert(pos, ins);
|
||||
} else {
|
||||
std::size_t pos = rng() % model.size();
|
||||
std::size_t max = std::min<std::size_t>(8, model.size() - pos);
|
||||
std::size_t len = 1 + (rng() % max);
|
||||
pt.Delete(pos, len);
|
||||
model.erase(pos, len);
|
||||
}
|
||||
|
||||
// Also validate GetRange on a small random window when non-empty.
|
||||
if (!model.empty()) {
|
||||
std::size_t off = rng() % model.size();
|
||||
std::size_t max = std::min<std::size_t>(16, model.size() - off);
|
||||
std::size_t len = 1 + (rng() % max);
|
||||
ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len));
|
||||
}
|
||||
|
||||
check_invariants("step");
|
||||
}
|
||||
|
||||
// Full line-by-line range verification at the end.
|
||||
auto starts = LineStartsFor(model);
|
||||
for (std::size_t line = 0; line < starts.size(); line++) {
|
||||
std::size_t exp_start = starts[line];
|
||||
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
|
||||
auto r = pt.GetLineRange(line);
|
||||
ASSERT_EQ(r.first, exp_start);
|
||||
ASSERT_EQ(r.second, exp_end);
|
||||
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
|
||||
}
|
||||
}
|
||||
102
tests/test_reflow_paragraph.cc
Normal file
102
tests/test_reflow_paragraph.cc
Normal file
@@ -0,0 +1,102 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
|
||||
static std::string
|
||||
to_string_rows(const Buffer &buf)
|
||||
{
|
||||
std::string out;
|
||||
for (const auto &r: buf.Rows()) {
|
||||
out += static_cast<std::string>(r);
|
||||
out.push_back('\n');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
TEST (ReflowParagraph_NumberedList_HangingIndent)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
// Two list items in one paragraph (no blank lines).
|
||||
// Second line of each item already uses a hanging indent.
|
||||
const std::string initial =
|
||||
"1. one two three four five six seven eight nine ten eleven\n"
|
||||
" twelve thirteen fourteen\n"
|
||||
"10. alpha beta gamma delta epsilon zeta eta theta iota kappa lambda\n"
|
||||
" mu nu xi omicron\n";
|
||||
b.insert_text(0, 0, initial);
|
||||
// Put cursor on first item
|
||||
b.SetCursor(0, 0);
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
const int width = 25;
|
||||
ASSERT_TRUE(Execute(ed, std::string("reflow-paragraph"), std::string(), width));
|
||||
|
||||
const auto &rows = buf->Rows();
|
||||
ASSERT_TRUE(!rows.empty());
|
||||
const std::string dump = to_string_rows(*buf);
|
||||
|
||||
// Find the start of the second item.
|
||||
bool any_too_long = false;
|
||||
std::size_t idx_10 = rows.size();
|
||||
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||
const std::string line = static_cast<std::string>(rows[i]);
|
||||
if (static_cast<int>(line.size()) > width)
|
||||
any_too_long = true;
|
||||
if (line.rfind("10. ", 0) == 0) {
|
||||
idx_10 = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ASSERT_TRUE(idx_10 < rows.size());
|
||||
if (any_too_long) {
|
||||
std::cerr << "Reflow produced a line longer than width=" << width << "\n";
|
||||
std::cerr << to_string_rows(*buf) << "\n";
|
||||
}
|
||||
EXPECT_TRUE(!any_too_long);
|
||||
|
||||
// Item 1: first line has "1. ", continuation lines have 3 spaces.
|
||||
for (std::size_t i = 0; i < idx_10; ++i) {
|
||||
const std::string line = static_cast<std::string>(rows[i]);
|
||||
if (i == 0) {
|
||||
ASSERT_TRUE(line.rfind("1. ", 0) == 0);
|
||||
} else {
|
||||
ASSERT_TRUE(line.rfind(" ", 0) == 0);
|
||||
ASSERT_TRUE(line.rfind("1. ", 0) != 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Item 10: first line has "10. ", continuation lines have 4 spaces.
|
||||
ASSERT_TRUE(static_cast<std::string>(rows[idx_10]).rfind("10. ", 0) == 0);
|
||||
bool bad_10 = false;
|
||||
for (std::size_t i = idx_10 + 1; i < rows.size(); ++i) {
|
||||
const std::string line = static_cast<std::string>(rows[i]);
|
||||
if (line.empty())
|
||||
break; // paragraph terminator / trailing empty line
|
||||
if (line.rfind(" ", 0) != 0)
|
||||
bad_10 = true;
|
||||
if (line.rfind("10. ", 0) == 0)
|
||||
bad_10 = true;
|
||||
}
|
||||
if (bad_10) {
|
||||
std::cerr << "Unexpected prefix in reflow output:\n" << dump << "\n";
|
||||
}
|
||||
ASSERT_TRUE(!bad_10);
|
||||
|
||||
// Debug helper if something goes wrong (kept as a string for easy inspection).
|
||||
EXPECT_TRUE(!to_string_rows(*buf).empty());
|
||||
}
|
||||
36
tests/test_search.cc
Normal file
36
tests/test_search.cc
Normal file
@@ -0,0 +1,36 @@
|
||||
#include "Test.h"
|
||||
#include "OptimizedSearch.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
static std::vector<std::size_t> ref_find_all(const std::string &text, const std::string &pat) {
|
||||
std::vector<std::size_t> res;
|
||||
if (pat.empty()) return res;
|
||||
std::size_t from = 0;
|
||||
while (true) {
|
||||
auto p = text.find(pat, from);
|
||||
if (p == std::string::npos) break;
|
||||
res.push_back(p);
|
||||
from = p + pat.size();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
TEST(OptimizedSearch_basic_cases) {
|
||||
OptimizedSearch os;
|
||||
struct Case { std::string text; std::string pat; } cases[] = {
|
||||
{"", ""},
|
||||
{"", "a"},
|
||||
{"a", ""},
|
||||
{"a", "a"},
|
||||
{"aaaaa", "aa"},
|
||||
{"hello world", "world"},
|
||||
{"abcabcabc", "abc"},
|
||||
{"the quick brown fox", "fox"},
|
||||
};
|
||||
for (auto &c : cases) {
|
||||
auto got = os.find_all(c.text, c.pat, 0);
|
||||
auto ref = ref_find_all(c.text, c.pat);
|
||||
ASSERT_EQ(got, ref);
|
||||
}
|
||||
}
|
||||
129
tests/test_search_replace_flow.cc
Normal file
129
tests/test_search_replace_flow.cc
Normal file
@@ -0,0 +1,129 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "tests/TestHarness.h"
|
||||
|
||||
using ktet::TestHarness;
|
||||
|
||||
// These tests intentionally drive the prompt-based search/replace UI headlessly
|
||||
// via `Execute(Editor&, CommandId, ...)` to lock down behavior without ncurses.
|
||||
|
||||
TEST (SearchFlow_FindStart_Success_LeavesCursorOnMatch_And_ClearsSearchState)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
b.insert_text(0, 0, "abc def abc");
|
||||
b.SetCursor(0, 0);
|
||||
b.SetOffsets(0, 0);
|
||||
|
||||
// Keep a mark set to ensure search doesn't clobber it.
|
||||
b.SetMark(0, 0);
|
||||
ASSERT_TRUE(b.MarkSet());
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::FindStart));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::Search);
|
||||
ASSERT_TRUE(ed.SearchActive());
|
||||
|
||||
// Typing into the prompt uses InsertText and should jump to the first match.
|
||||
ASSERT_TRUE(h.Exec(CommandId::InsertText, "def"));
|
||||
ASSERT_EQ(b.Cury(), (std::size_t) 0);
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 4);
|
||||
|
||||
// Enter (Newline) accepts the prompt and ends incremental search.
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
ASSERT_TRUE(!ed.PromptActive());
|
||||
ASSERT_TRUE(!ed.SearchActive());
|
||||
ASSERT_TRUE(b.MarkSet());
|
||||
}
|
||||
|
||||
|
||||
TEST (SearchFlow_FindStart_NotFound_RestoresOrigin_And_ClearsSearchState)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
b.insert_text(0, 0, "hello world\nsecond line\n");
|
||||
b.SetCursor(3, 0);
|
||||
b.SetOffsets(1, 2);
|
||||
|
||||
const std::size_t ox = b.Curx();
|
||||
const std::size_t oy = b.Cury();
|
||||
const std::size_t orow = b.Rowoffs();
|
||||
const std::size_t ocol = b.Coloffs();
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::FindStart));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ASSERT_TRUE(ed.SearchActive());
|
||||
|
||||
// Not-found should restore cursor/viewport to the saved origin while still in prompt.
|
||||
ASSERT_TRUE(h.Exec(CommandId::InsertText, "zzzz"));
|
||||
ASSERT_EQ(b.Curx(), ox);
|
||||
ASSERT_EQ(b.Cury(), oy);
|
||||
ASSERT_EQ(b.Rowoffs(), orow);
|
||||
ASSERT_EQ(b.Coloffs(), ocol);
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
ASSERT_TRUE(!ed.PromptActive());
|
||||
ASSERT_TRUE(!ed.SearchActive());
|
||||
}
|
||||
|
||||
|
||||
TEST (SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
b.insert_text(0, 0, "abc abc\n");
|
||||
b.SetCursor(0, 0);
|
||||
|
||||
const std::string before = h.Text();
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind);
|
||||
|
||||
// Accept empty find -> proceed to ReplaceWith.
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
|
||||
|
||||
// Provide replacement and accept -> should cancel due to empty find.
|
||||
ASSERT_TRUE(h.Exec(CommandId::InsertText, "X"));
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
|
||||
ASSERT_TRUE(!ed.PromptActive());
|
||||
ASSERT_TRUE(!ed.SearchActive());
|
||||
ASSERT_EQ(h.Text(), before);
|
||||
}
|
||||
|
||||
|
||||
TEST (SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter)
|
||||
{
|
||||
TestHarness h;
|
||||
Editor &ed = h.EditorRef();
|
||||
Buffer &b = h.Buf();
|
||||
|
||||
b.insert_text(0, 0, "abc\ndef\n");
|
||||
b.SetCursor(1, 0);
|
||||
b.SetOffsets(0, 0);
|
||||
|
||||
const std::size_t ox = b.Curx();
|
||||
const std::size_t oy = b.Cury();
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::RegexFindStart));
|
||||
ASSERT_TRUE(ed.PromptActive());
|
||||
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::RegexSearch);
|
||||
|
||||
// Invalid regex should not crash; cursor should remain at origin due to no matches.
|
||||
ASSERT_TRUE(h.Exec(CommandId::InsertText, "("));
|
||||
ASSERT_EQ(b.Curx(), ox);
|
||||
ASSERT_EQ(b.Cury(), oy);
|
||||
|
||||
ASSERT_TRUE(h.Exec(CommandId::Newline));
|
||||
ASSERT_TRUE(!ed.PromptActive());
|
||||
ASSERT_TRUE(!ed.SearchActive());
|
||||
}
|
||||
104
tests/test_swap_recorder.cc
Normal file
104
tests/test_swap_recorder.cc
Normal file
@@ -0,0 +1,104 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "SwapRecorder.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
struct SwapEvent {
|
||||
enum class Type {
|
||||
Insert,
|
||||
Delete,
|
||||
};
|
||||
|
||||
Type type;
|
||||
int row;
|
||||
int col;
|
||||
std::string bytes;
|
||||
std::size_t len = 0;
|
||||
};
|
||||
|
||||
class FakeSwapRecorder final : public kte::SwapRecorder {
|
||||
public:
|
||||
std::vector<SwapEvent> events;
|
||||
|
||||
|
||||
void OnInsert(int row, int col, std::string_view bytes) override
|
||||
{
|
||||
SwapEvent e;
|
||||
e.type = SwapEvent::Type::Insert;
|
||||
e.row = row;
|
||||
e.col = col;
|
||||
e.bytes = std::string(bytes);
|
||||
e.len = 0;
|
||||
events.push_back(std::move(e));
|
||||
}
|
||||
|
||||
|
||||
void OnDelete(int row, int col, std::size_t len) override
|
||||
{
|
||||
SwapEvent e;
|
||||
e.type = SwapEvent::Type::Delete;
|
||||
e.row = row;
|
||||
e.col = col;
|
||||
e.len = len;
|
||||
events.push_back(std::move(e));
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
|
||||
TEST (SwapRecorder_InsertABC)
|
||||
{
|
||||
Buffer b;
|
||||
FakeSwapRecorder rec;
|
||||
b.SetSwapRecorder(&rec);
|
||||
|
||||
b.insert_text(0, 0, std::string_view("abc"));
|
||||
|
||||
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
|
||||
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert);
|
||||
ASSERT_EQ(rec.events[0].row, 0);
|
||||
ASSERT_EQ(rec.events[0].col, 0);
|
||||
ASSERT_EQ(rec.events[0].bytes, std::string("abc"));
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapRecorder_InsertNewline)
|
||||
{
|
||||
Buffer b;
|
||||
FakeSwapRecorder rec;
|
||||
b.SetSwapRecorder(&rec);
|
||||
|
||||
b.split_line(0, 0);
|
||||
|
||||
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
|
||||
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Insert);
|
||||
ASSERT_EQ(rec.events[0].row, 0);
|
||||
ASSERT_EQ(rec.events[0].col, 0);
|
||||
ASSERT_EQ(rec.events[0].bytes, std::string("\n"));
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapRecorder_DeleteSpanningNewline)
|
||||
{
|
||||
Buffer b;
|
||||
// Prepare content without a recorder (should be no-op)
|
||||
b.insert_text(0, 0, std::string_view("ab"));
|
||||
b.split_line(0, 2);
|
||||
b.insert_text(1, 0, std::string_view("cd"));
|
||||
|
||||
FakeSwapRecorder rec;
|
||||
b.SetSwapRecorder(&rec);
|
||||
|
||||
// Delete "b\n c" (3 bytes) starting at row 0, col 1.
|
||||
b.delete_text(0, 1, 3);
|
||||
|
||||
ASSERT_EQ(rec.events.size(), (std::size_t) 1);
|
||||
ASSERT_TRUE(rec.events[0].type == SwapEvent::Type::Delete);
|
||||
ASSERT_EQ(rec.events[0].row, 0);
|
||||
ASSERT_EQ(rec.events[0].col, 1);
|
||||
ASSERT_EQ(rec.events[0].len, (std::size_t) 3);
|
||||
}
|
||||
114
tests/test_swap_replay.cc
Normal file
114
tests/test_swap_replay.cc
Normal file
@@ -0,0 +1,114 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Swap.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
|
||||
static void
|
||||
write_file_bytes(const std::string &path, const std::string &bytes)
|
||||
{
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
out.write(bytes.data(), (std::streamsize) bytes.size());
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
read_file_bytes(const std::string &path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
buffer_bytes_via_views(const Buffer &b)
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
std::string out;
|
||||
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||
auto v = b.GetLineView(i);
|
||||
out.append(v.data(), v.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapReplay_RecordFlushReopenReplay_ExactBytesMatch)
|
||||
{
|
||||
const std::string path = "./.kte_ut_swap_replay_1.txt";
|
||||
std::remove(path.c_str());
|
||||
write_file_bytes(path, "base\nline2\n");
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
|
||||
kte::SwapManager sm;
|
||||
sm.Attach(&b);
|
||||
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||
|
||||
// Edits (no save): swap should capture these.
|
||||
b.insert_text(0, 0, std::string("X")); // Xbase\nline2\n
|
||||
b.delete_text(1, 1, 2); // delete "in" from "line2"
|
||||
b.split_line(0, 3); // Xba\nse...
|
||||
b.join_lines(0); // join back
|
||||
b.insert_text(1, 0, std::string("ZZ")); // insert at start of line2
|
||||
b.delete_text(0, 0, 1); // delete leading X
|
||||
|
||||
sm.Flush(&b);
|
||||
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||
const std::string expected = buffer_bytes_via_views(b);
|
||||
|
||||
// Close journal before replaying (for determinism)
|
||||
b.SetSwapRecorder(nullptr);
|
||||
sm.Detach(&b);
|
||||
|
||||
Buffer b2;
|
||||
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
|
||||
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapReplay_TruncatedLog_FailsSafely)
|
||||
{
|
||||
const std::string path = "./.kte_ut_swap_replay_2.txt";
|
||||
std::remove(path.c_str());
|
||||
write_file_bytes(path, "hello\n");
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
|
||||
kte::SwapManager sm;
|
||||
sm.Attach(&b);
|
||||
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||
b.insert_text(0, 0, std::string("X"));
|
||||
sm.Flush(&b);
|
||||
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||
b.SetSwapRecorder(nullptr);
|
||||
sm.Detach(&b);
|
||||
|
||||
const std::string bytes = read_file_bytes(swap_path);
|
||||
ASSERT_TRUE(bytes.size() > 70); // header + at least one record
|
||||
|
||||
const std::string trunc_path = swap_path + ".trunc";
|
||||
write_file_bytes(trunc_path, bytes.substr(0, bytes.size() - 1));
|
||||
|
||||
Buffer b2;
|
||||
ASSERT_TRUE(b2.OpenFromFile(path, err));
|
||||
std::string rerr;
|
||||
ASSERT_EQ(kte::SwapManager::ReplayFile(b2, trunc_path, rerr), false);
|
||||
ASSERT_EQ(rerr.empty(), false);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swap_path.c_str());
|
||||
std::remove(trunc_path.c_str());
|
||||
}
|
||||
236
tests/test_swap_writer.cc
Normal file
236
tests/test_swap_writer.cc
Normal file
@@ -0,0 +1,236 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Swap.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
namespace {
|
||||
std::vector<std::uint8_t>
|
||||
read_all_bytes(const std::string &path)
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
return std::vector<std::uint8_t>((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
|
||||
std::uint32_t
|
||||
read_le32(const std::uint8_t *p)
|
||||
{
|
||||
return (std::uint32_t) p[0] | ((std::uint32_t) p[1] << 8) | ((std::uint32_t) p[2] << 16) |
|
||||
((std::uint32_t) p[3] << 24);
|
||||
}
|
||||
|
||||
|
||||
std::uint64_t
|
||||
read_le64(const std::uint8_t *p)
|
||||
{
|
||||
std::uint64_t v = 0;
|
||||
for (int i = 7; i >= 0; --i) {
|
||||
v = (v << 8) | p[i];
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
std::uint32_t
|
||||
crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0)
|
||||
{
|
||||
static std::uint32_t table[256];
|
||||
static bool inited = false;
|
||||
if (!inited) {
|
||||
for (std::uint32_t i = 0; i < 256; ++i) {
|
||||
std::uint32_t c = i;
|
||||
for (int j = 0; j < 8; ++j)
|
||||
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
|
||||
table[i] = c;
|
||||
}
|
||||
inited = true;
|
||||
}
|
||||
std::uint32_t c = ~seed;
|
||||
for (std::size_t i = 0; i < len; ++i)
|
||||
c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8);
|
||||
return ~c;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
|
||||
TEST (SwapWriter_Header_Records_And_CRC)
|
||||
{
|
||||
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||
(std::string("kte_ut_xdg_state_") + std::to_string((int) ::getpid()));
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
|
||||
const char *old_xdg = std::getenv("XDG_STATE_HOME");
|
||||
setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1);
|
||||
|
||||
const std::string path = (xdg_root / "work" / "kte_ut_swap_writer.txt").string();
|
||||
std::filesystem::create_directories((xdg_root / "work"));
|
||||
|
||||
// Clean up from prior runs
|
||||
std::remove(path.c_str());
|
||||
|
||||
// Ensure file exists so buffer is file-backed
|
||||
{
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
out << "";
|
||||
}
|
||||
|
||||
Buffer b;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b.OpenFromFile(path, err));
|
||||
ASSERT_TRUE(err.empty());
|
||||
ASSERT_TRUE(b.IsFileBacked());
|
||||
|
||||
kte::SwapManager sm;
|
||||
sm.Attach(&b);
|
||||
b.SetSwapRecorder(sm.RecorderFor(&b));
|
||||
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(b);
|
||||
std::remove(swp.c_str());
|
||||
|
||||
// Emit one INS and one DEL
|
||||
b.insert_text(0, 0, std::string_view("abc"));
|
||||
b.delete_text(0, 1, 1);
|
||||
|
||||
// Ensure all records are written before reading
|
||||
sm.Flush(&b);
|
||||
sm.Detach(&b);
|
||||
b.SetSwapRecorder(nullptr);
|
||||
|
||||
ASSERT_TRUE(std::filesystem::exists(swp));
|
||||
|
||||
// Verify permissions 0600
|
||||
struct stat st{};
|
||||
ASSERT_TRUE(::stat(swp.c_str(), &st) == 0);
|
||||
ASSERT_EQ((st.st_mode & 0777), 0600);
|
||||
|
||||
const std::vector<std::uint8_t> bytes = read_all_bytes(swp);
|
||||
ASSERT_TRUE(bytes.size() >= 64);
|
||||
|
||||
// Header
|
||||
static const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
|
||||
for (int i = 0; i < 8; ++i)
|
||||
ASSERT_EQ(bytes[(std::size_t) i], magic[i]);
|
||||
ASSERT_EQ(read_le32(bytes.data() + 8), (std::uint32_t) 1);
|
||||
// flags currently 0
|
||||
ASSERT_EQ(read_le32(bytes.data() + 12), (std::uint32_t) 0);
|
||||
ASSERT_TRUE(read_le64(bytes.data() + 16) != 0);
|
||||
|
||||
// Records
|
||||
std::vector<std::uint8_t> types;
|
||||
std::size_t off = 64;
|
||||
while (off < bytes.size()) {
|
||||
ASSERT_TRUE(bytes.size() - off >= 8); // at least header+crc
|
||||
const std::uint8_t type = bytes[off + 0];
|
||||
const std::uint32_t len = (std::uint32_t) bytes[off + 1] | ((std::uint32_t) bytes[off + 2] << 8) |
|
||||
((std::uint32_t) bytes[off + 3] << 16);
|
||||
const std::size_t payload_off = off + 4;
|
||||
const std::size_t crc_off = payload_off + len;
|
||||
ASSERT_TRUE(crc_off + 4 <= bytes.size());
|
||||
|
||||
const std::uint32_t got_crc = read_le32(bytes.data() + crc_off);
|
||||
std::uint32_t c = 0;
|
||||
c = crc32(bytes.data() + off, 4, c);
|
||||
c = crc32(bytes.data() + payload_off, len, c);
|
||||
ASSERT_EQ(got_crc, c);
|
||||
|
||||
types.push_back(type);
|
||||
off = crc_off + 4;
|
||||
}
|
||||
|
||||
ASSERT_EQ(types.size(), (std::size_t) 2);
|
||||
ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::INS);
|
||||
ASSERT_EQ(types[1], (std::uint8_t) kte::SwapRecType::DEL);
|
||||
|
||||
std::remove(path.c_str());
|
||||
std::remove(swp.c_str());
|
||||
if (old_xdg) {
|
||||
setenv("XDG_STATE_HOME", old_xdg, 1);
|
||||
} else {
|
||||
unsetenv("XDG_STATE_HOME");
|
||||
}
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
}
|
||||
|
||||
|
||||
TEST (SwapWriter_NoStomp_SameBasename)
|
||||
{
|
||||
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
|
||||
(std::string("kte_ut_xdg_state_nostomp_") + std::to_string(
|
||||
(int) ::getpid()));
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
std::filesystem::create_directories(xdg_root);
|
||||
|
||||
const char *old_xdg = std::getenv("XDG_STATE_HOME");
|
||||
setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1);
|
||||
|
||||
const std::filesystem::path d1 = xdg_root / "p1";
|
||||
const std::filesystem::path d2 = xdg_root / "p2";
|
||||
std::filesystem::create_directories(d1);
|
||||
std::filesystem::create_directories(d2);
|
||||
const std::filesystem::path f1 = d1 / "same.txt";
|
||||
const std::filesystem::path f2 = d2 / "same.txt";
|
||||
|
||||
{
|
||||
std::ofstream out(f1.string(), std::ios::binary);
|
||||
out << "";
|
||||
}
|
||||
{
|
||||
std::ofstream out(f2.string(), std::ios::binary);
|
||||
out << "";
|
||||
}
|
||||
|
||||
Buffer b1;
|
||||
Buffer b2;
|
||||
std::string err;
|
||||
ASSERT_TRUE(b1.OpenFromFile(f1.string(), err));
|
||||
ASSERT_TRUE(err.empty());
|
||||
ASSERT_TRUE(b2.OpenFromFile(f2.string(), err));
|
||||
ASSERT_TRUE(err.empty());
|
||||
|
||||
const std::string swp1 = kte::SwapManager::ComputeSwapPathForTests(b1);
|
||||
const std::string swp2 = kte::SwapManager::ComputeSwapPathForTests(b2);
|
||||
ASSERT_TRUE(swp1 != swp2);
|
||||
|
||||
// Actually write to both to ensure one doesn't clobber the other.
|
||||
kte::SwapManager sm;
|
||||
sm.Attach(&b1);
|
||||
sm.Attach(&b2);
|
||||
b1.SetSwapRecorder(sm.RecorderFor(&b1));
|
||||
b2.SetSwapRecorder(sm.RecorderFor(&b2));
|
||||
|
||||
b1.insert_text(0, 0, std::string_view("one"));
|
||||
b2.insert_text(0, 0, std::string_view("two"));
|
||||
sm.Flush();
|
||||
|
||||
ASSERT_TRUE(std::filesystem::exists(swp1));
|
||||
ASSERT_TRUE(std::filesystem::exists(swp2));
|
||||
ASSERT_TRUE(std::filesystem::file_size(swp1) >= 64);
|
||||
ASSERT_TRUE(std::filesystem::file_size(swp2) >= 64);
|
||||
|
||||
sm.Detach(&b1);
|
||||
sm.Detach(&b2);
|
||||
b1.SetSwapRecorder(nullptr);
|
||||
b2.SetSwapRecorder(nullptr);
|
||||
|
||||
std::remove(swp1.c_str());
|
||||
std::remove(swp2.c_str());
|
||||
std::remove(f1.string().c_str());
|
||||
std::remove(f2.string().c_str());
|
||||
if (old_xdg) {
|
||||
setenv("XDG_STATE_HOME", old_xdg, 1);
|
||||
} else {
|
||||
unsetenv("XDG_STATE_HOME");
|
||||
}
|
||||
std::filesystem::remove_all(xdg_root);
|
||||
}
|
||||
940
tests/test_undo.cc
Normal file
940
tests/test_undo.cc
Normal file
@@ -0,0 +1,940 @@
|
||||
#include "Test.h"
|
||||
#include "Buffer.h"
|
||||
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <random>
|
||||
|
||||
#if defined(KTE_TESTS)
|
||||
#include <unordered_set>
|
||||
|
||||
static void
|
||||
validate_undo_subtree(const UndoNode *node, const UndoNode *expected_parent,
|
||||
std::unordered_set<const UndoNode *> &seen)
|
||||
{
|
||||
ASSERT_TRUE(node != nullptr);
|
||||
ASSERT_TRUE(seen.find(node) == seen.end());
|
||||
seen.insert(node);
|
||||
ASSERT_TRUE(node->parent == expected_parent);
|
||||
|
||||
// Validate each redo branch under this node.
|
||||
for (const UndoNode *ch = node->child; ch != nullptr; ch = ch->next) {
|
||||
validate_undo_subtree(ch, node, seen);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
validate_undo_tree(const UndoSystem &u)
|
||||
{
|
||||
const UndoTree &t = u.TreeForTests();
|
||||
|
||||
std::unordered_set<const UndoNode *> seen;
|
||||
for (const UndoNode *root = t.root; root != nullptr; root = root->next) {
|
||||
validate_undo_subtree(root, nullptr, seen);
|
||||
}
|
||||
|
||||
// current/saved must either be null or be reachable from some root.
|
||||
if (t.current)
|
||||
ASSERT_TRUE(seen.find(t.current) != seen.end());
|
||||
if (t.saved)
|
||||
ASSERT_TRUE(seen.find(t.saved) != seen.end());
|
||||
|
||||
// pending is detached (not part of the committed tree).
|
||||
if (t.pending) {
|
||||
ASSERT_TRUE(seen.find(t.pending) == seen.end());
|
||||
ASSERT_TRUE(t.pending->parent == nullptr);
|
||||
ASSERT_TRUE(t.pending->child == nullptr);
|
||||
ASSERT_TRUE(t.pending->next == nullptr);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
TEST (Undo_InsertRun_Coalesces)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Simulate two separate "typed" insert commands without committing in between.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("h"));
|
||||
u->Append('h');
|
||||
b.SetCursor(1, 0);
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("i"));
|
||||
u->Append('i');
|
||||
b.SetCursor(2, 0);
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
|
||||
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BackspaceRun_Coalesces)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed content.
|
||||
b.insert_text(0, 0, std::string_view("abc"));
|
||||
b.SetCursor(3, 0);
|
||||
u->mark_saved();
|
||||
|
||||
// Simulate two backspaces: delete 'c' then 'b'.
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][2];
|
||||
b.delete_text(0, 2, 1);
|
||||
b.SetCursor(2, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
b.delete_text(0, 1, 1);
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
}
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// One undo should restore both characters.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abc"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// New edit after undo creates a new branch; the old redo should remain as an alternate branch.
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("c"));
|
||||
u->Append('c');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
|
||||
// No further redo from the tip.
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
|
||||
// Undo back to the branch point and redo the original branch.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DirtyFlag_MarkSavedAndUndoRedo)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
u->mark_saved();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_TRUE(b.Dirty());
|
||||
u->undo();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
u->redo();
|
||||
ASSERT_TRUE(b.Dirty());
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Newline_UndoRedo_SplitJoin)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed a single line and split it.
|
||||
b.insert_text(0, 0, std::string_view("hello"));
|
||||
b.SetCursor(2, 0); // split after "he"
|
||||
u->Begin(UndoType::Newline);
|
||||
b.split_line(0, 2);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("he"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("llo"));
|
||||
|
||||
// Undo should join the lines back.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hello"));
|
||||
|
||||
// Redo should split again at the same point.
|
||||
u->redo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("he"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("llo"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DeleteKeyRun_Coalesces)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed content: delete-key semantics keep cursor at the same column.
|
||||
b.insert_text(0, 0, std::string_view("abcd"));
|
||||
b.SetCursor(1, 0); // on 'b'
|
||||
|
||||
// Delete 'b'
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
u->Begin(UndoType::Delete);
|
||||
b.delete_text(0, 1, 1);
|
||||
u->Append(deleted);
|
||||
b.SetCursor(1, 0);
|
||||
}
|
||||
// Delete next char (was 'c', now at same col=1)
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[0][1];
|
||||
u->Begin(UndoType::Delete);
|
||||
b.delete_text(0, 1, 1);
|
||||
u->Append(deleted);
|
||||
b.SetCursor(1, 0);
|
||||
}
|
||||
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
|
||||
|
||||
// One undo should restore both deleted characters.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abcd"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_UndoPastFirstEdit_RedoFromPreFirstEdit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Commit two separate insert edits.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
// Undo twice: we should reach the pre-first-edit state.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
|
||||
// Redo twice should restore both edits.
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_NewEditFromPreFirstEdit_PreservesOldHistoryAsAlternateRootBranch)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Build up two edits.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
// Undo past first edit so current becomes null.
|
||||
u->undo();
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
|
||||
// Commit a new edit from the pre-first-edit state.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
|
||||
|
||||
// From the tip, no further redo.
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
|
||||
|
||||
// Undo back to pre-first-edit and select the older root branch.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_MultiLineDelete_ConsumesNewline_UndoRestores)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Create two lines. PieceTable treats '\n' between logical lines.
|
||||
b.insert_text(0, 0, std::string_view("ab\ncd"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("cd"));
|
||||
|
||||
// Delete spanning the newline: delete "b\n" starting at (0,1).
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
b.delete_text(0, 1, 2);
|
||||
u->Append(std::string_view("b\n"));
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("acd"));
|
||||
|
||||
// Undo should restore exact original text/line structure.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("cd"));
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DeleteIndent_UndoRestoresCursorAtText)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed 3-line content with indentation on the middle line.
|
||||
b.insert_text(0, 0,
|
||||
std::string_view("I did a thing\n and then I edited a thing\nbut there were gaps"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
|
||||
// Cursor at start of the line (before spaces), then C-d C-d deletes two spaces.
|
||||
b.SetCursor(0, 1);
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
const auto &rows = b.Rows();
|
||||
char deleted = rows[1][0];
|
||||
ASSERT_EQ(deleted, ' ');
|
||||
u->Begin(UndoType::Delete);
|
||||
b.delete_text(1, 0, 1);
|
||||
u->Append(deleted);
|
||||
b.SetCursor(0, 1); // delete-key keeps col the same
|
||||
}
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("and then I edited a thing"));
|
||||
ASSERT_EQ(b.Cury(), (std::size_t) 1);
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 0);
|
||||
|
||||
// Undo should restore indentation, and keep cursor on the text (at 'a'), not at EOL.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string(" and then I edited a thing"));
|
||||
ASSERT_EQ(b.Cury(), (std::size_t) 1);
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 2);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_StructuralInvariants_BranchingAndRoots)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Build history: a -> b
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
// Undo past first edit; now create a new root-level branch x.
|
||||
u->undo();
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
|
||||
|
||||
// Return to the older root branch.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// Create a normal branch under 'a'.
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("c"));
|
||||
u->Append('c');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Root: a
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// Branch 1: a->b
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
// Back to branch point.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// Branch 2: a->c
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("c"));
|
||||
u->Append('c');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// Branch 3: a->d
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("d"));
|
||||
u->Append('d');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
|
||||
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// Under 'a', the sibling list should now contain 3 branches.
|
||||
validate_undo_tree(*u);
|
||||
|
||||
// Select the 3rd sibling (branch_index=2) which should be the oldest ("b"), and make it active.
|
||||
u->redo(2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// Since we selected "b", redo with default should now follow "b" again.
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// Select another branch by index and ensure it becomes the new default.
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
|
||||
u->undo();
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
|
||||
u->undo();
|
||||
|
||||
// Out-of-range selection should be a no-op.
|
||||
u->redo(99);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Build A->B.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
ASSERT_EQ(b.Cury(), (std::size_t) 0);
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 1);
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 2);
|
||||
|
||||
// Undo to A.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 1);
|
||||
|
||||
// Create sibling branch A->C.
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("c"));
|
||||
u->Append('c');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 2);
|
||||
|
||||
// Back to A.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 1);
|
||||
|
||||
// Redo into B as the alternate branch (older sibling), and confirm cursor is consistent.
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 2);
|
||||
|
||||
// Both branches remain reachable: undo to A, redo defaults to B (head reordered).
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
// And the other branch C should still be selectable.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
ASSERT_EQ(b.Curx(), (std::size_t) 2);
|
||||
|
||||
// After selecting C, default redo from A should now follow C.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Randomized_Deterministic_EditUndoRedoBranchSelect)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
std::mt19937 rng(0xC0FFEEu);
|
||||
std::uniform_int_distribution<int> op(0, 99);
|
||||
std::uniform_int_distribution<int> ch(0, 25);
|
||||
|
||||
const int steps = 300;
|
||||
const int max_len = 40;
|
||||
const int max_branch = 4;
|
||||
|
||||
for (int i = 0; i < steps; ++i) {
|
||||
ASSERT_TRUE(!b.Rows().empty());
|
||||
ASSERT_EQ(b.Cury(), (std::size_t) 0);
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
|
||||
ASSERT_TRUE(b.Curx() <= b.Rows()[0].size());
|
||||
|
||||
validate_undo_tree(*u);
|
||||
|
||||
int r = op(rng);
|
||||
std::string cur = std::string(b.Rows()[0]);
|
||||
int len = static_cast<int>(cur.size());
|
||||
|
||||
if (r < 40 && len < max_len) {
|
||||
// Insert one char at end as a standalone committed node.
|
||||
char c = static_cast<char>('a' + ch(rng));
|
||||
b.SetCursor(static_cast<std::size_t>(len), 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, len, std::string_view(&c, 1));
|
||||
u->Append(c);
|
||||
b.SetCursor(static_cast<std::size_t>(len + 1), 0);
|
||||
u->commit();
|
||||
} else if (r < 60 && len > 0) {
|
||||
// Backspace at end as a standalone committed node.
|
||||
char deleted = cur[static_cast<std::size_t>(len - 1)];
|
||||
b.delete_text(0, len - 1, 1);
|
||||
b.SetCursor(static_cast<std::size_t>(len - 1), 0);
|
||||
u->Begin(UndoType::Delete);
|
||||
u->Append(deleted);
|
||||
u->commit();
|
||||
} else if (r < 80) {
|
||||
// Undo then redo should round-trip to the exact same node/text/cursor when possible.
|
||||
const UndoNode *before_node = u->TreeForTests().current;
|
||||
const std::string before_text(std::string(b.Rows()[0]));
|
||||
const std::size_t before_x = b.Curx();
|
||||
|
||||
if (before_node) {
|
||||
u->undo();
|
||||
u->redo();
|
||||
ASSERT_TRUE(u->TreeForTests().current == before_node);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), before_text);
|
||||
ASSERT_EQ(b.Curx(), before_x);
|
||||
} else {
|
||||
// Nothing to undo; just exercise redo/branch-select paths.
|
||||
u->redo();
|
||||
}
|
||||
} else if (r < 90) {
|
||||
u->undo();
|
||||
} else {
|
||||
int idx = static_cast<int>(rng() % static_cast<std::uint32_t>(max_branch));
|
||||
if ((rng() % 8u) == 0u)
|
||||
idx = 99; // intentionally out of range sometimes
|
||||
u->redo(idx);
|
||||
}
|
||||
}
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_PendingCoalescedRun_UndoCommitsThenUndoes)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Create a coalesced insert run without an explicit commit.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
// undo() should implicitly commit pending and then undo it as one step.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
|
||||
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_PendingRunAtBranchPoint_UndoThenBranchSelectionStillWorks)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Build a->b.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
// Undo to the branch point.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// Start a pending insert "c" at the branch point, but don't commit.
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("c"));
|
||||
u->Append('c');
|
||||
b.SetCursor(2, 0);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
|
||||
// Undo should seal the pending "c" as a new branch, then undo it, leaving us at "a".
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// The active redo should now be "c".
|
||||
u->redo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
// Select the older "b" branch.
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_SavedNodeOnOtherBranch_DirtyClearsWhenReturning)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Build a->b and mark saved at the tip.
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
u->mark_saved();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
|
||||
// Move to a different branch.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("c"));
|
||||
u->Append('c');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
|
||||
ASSERT_TRUE(b.Dirty());
|
||||
|
||||
// Return to the saved node by selecting the older branch.
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
|
||||
u->redo(1);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Clear_AfterSaved_ResetsStateSafely)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 0, std::string_view("x"));
|
||||
u->Append('x');
|
||||
b.SetCursor(1, 0);
|
||||
u->commit();
|
||||
u->mark_saved();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("y"));
|
||||
u->Append('y');
|
||||
b.SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_TRUE(b.Dirty());
|
||||
|
||||
u->clear();
|
||||
ASSERT_TRUE(!b.Dirty());
|
||||
// clear() resets undo history, but does not mutate buffer contents.
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_UndoHonorsRepeatCount)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
UndoSystem *u = buf->Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Create two committed steps using the undo system directly.
|
||||
buf->SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
buf->insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
buf->SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
buf->insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
buf->SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
|
||||
|
||||
// Undo twice via command repeat count.
|
||||
ed.SetUniversalArg(1, 2);
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string(""));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_Command_RedoCountSelectsBranch)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
ed.AddBuffer(std::move(b));
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
UndoSystem *u = buf->Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Build a->b.
|
||||
buf->SetCursor(0, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
buf->insert_text(0, 0, std::string_view("a"));
|
||||
u->Append('a');
|
||||
buf->SetCursor(1, 0);
|
||||
u->commit();
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
buf->insert_text(0, 1, std::string_view("b"));
|
||||
u->Append('b');
|
||||
buf->SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
|
||||
|
||||
// Undo to the branch point and create a sibling branch "c".
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("a"));
|
||||
|
||||
u->Begin(UndoType::Insert);
|
||||
buf->insert_text(0, 1, std::string_view("c"));
|
||||
u->Append('c');
|
||||
buf->SetCursor(2, 0);
|
||||
u->commit();
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ac"));
|
||||
|
||||
// Back to branch point.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("a"));
|
||||
|
||||
// Command redo with count=2 should select branch_index=1 (the older "b" branch).
|
||||
ed.SetUniversalArg(1, 2);
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
|
||||
|
||||
// After selection, "b" should be the default redo from the branch point.
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Undo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("a"));
|
||||
ASSERT_TRUE(Execute(ed, CommandId::Redo));
|
||||
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
332
tests/test_visual_line_mode.cc
Normal file
332
tests/test_visual_line_mode.cc
Normal file
@@ -0,0 +1,332 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
|
||||
static std::string
|
||||
dump_buf(const Buffer &buf)
|
||||
{
|
||||
std::string out;
|
||||
for (const auto &r: buf.Rows()) {
|
||||
out += static_cast<std::string>(r);
|
||||
out.push_back('\n');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
dump_bytes(const std::string &s)
|
||||
{
|
||||
static const char *hex = "0123456789abcdef";
|
||||
std::string out;
|
||||
for (unsigned char c: s) {
|
||||
out.push_back(hex[(c >> 4) & 0xF]);
|
||||
out.push_back(hex[c & 0xF]);
|
||||
out.push_back(' ');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
TEST (VisualLineMode_BroadcastInsert)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
|
||||
b.SetCursor(1, 0); // fo|o
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
|
||||
// Enter visual-line mode and extend selection to 3 lines
|
||||
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
|
||||
|
||||
// Broadcast insert to all selected lines
|
||||
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
|
||||
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
// Note: buffers that end with a trailing '\n' have an extra empty row.
|
||||
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
|
||||
if (got != exp) {
|
||||
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
|
||||
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
|
||||
}
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
|
||||
|
||||
TEST (VisualLineMode_BroadcastInsert_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
|
||||
b.SetCursor(1, 0); // fo|o
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
|
||||
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
|
||||
|
||||
// Broadcast insert to all selected lines.
|
||||
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
|
||||
{
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
|
||||
// Undo should restore all affected lines in a single step.
|
||||
ASSERT_TRUE(Execute(ed, std::string("undo")));
|
||||
{
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
const std::string exp = "foo\nfoo\nfoo\n\n";
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
|
||||
// Redo should re-apply the whole insert.
|
||||
ASSERT_TRUE(Execute(ed, std::string("redo")));
|
||||
{
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST (VisualLineMode_BroadcastBackspace)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
b.insert_text(0, 0, "abcd\nabcd\nabcd\n");
|
||||
b.SetCursor(2, 0); // ab|cd
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, std::string("backspace")));
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
// Note: buffers that end with a trailing '\n' have an extra empty row.
|
||||
const std::string exp = "acd\nacd\nacd\n\n";
|
||||
if (got != exp) {
|
||||
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
|
||||
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
|
||||
}
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
|
||||
|
||||
TEST (VisualLineMode_BroadcastBackspace_UndoRedo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
b.insert_text(0, 0, "abcd\nabcd\nabcd\n");
|
||||
b.SetCursor(2, 0); // ab|cd
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, std::string("backspace")));
|
||||
{
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
const std::string exp = "acd\nacd\nacd\n\n";
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
|
||||
// Undo should restore all affected lines.
|
||||
ASSERT_TRUE(Execute(ed, std::string("undo")));
|
||||
{
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
const std::string exp = "abcd\nabcd\nabcd\n\n";
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
|
||||
// Redo should re-apply.
|
||||
ASSERT_TRUE(Execute(ed, std::string("redo")));
|
||||
{
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
const std::string exp = "acd\nacd\nacd\n\n";
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST (VisualLineMode_CancelWithCtrlG)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
|
||||
b.SetCursor(1, 0);
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
|
||||
|
||||
// C-g is mapped to "refresh" and should cancel visual-line mode.
|
||||
ASSERT_TRUE(Execute(ed, std::string("refresh")));
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
ASSERT_TRUE(!ed.CurrentBuffer()->VisualLineActive());
|
||||
|
||||
// After cancel, edits should only affect the primary cursor line.
|
||||
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
// Cursor is still on the last line we moved to (down, down).
|
||||
const std::string exp = "foo\nfoo\nfXoo\n\n";
|
||||
if (got != exp) {
|
||||
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
|
||||
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
|
||||
}
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
|
||||
|
||||
TEST (Yank_ClearsMarkAndVisualLine)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
b.insert_text(0, 0, "foo\nbar\n");
|
||||
b.SetCursor(1, 0);
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
|
||||
// Seed mark + visual-line highlighting.
|
||||
buf->SetMark(buf->Curx(), buf->Cury());
|
||||
ASSERT_TRUE(buf->MarkSet());
|
||||
|
||||
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 1));
|
||||
ASSERT_TRUE(buf->VisualLineActive());
|
||||
|
||||
// Yank should clear mark and any highlighting.
|
||||
ed.KillRingClear();
|
||||
ed.KillRingPush("X");
|
||||
ASSERT_TRUE(Execute(ed, std::string("yank")));
|
||||
|
||||
ASSERT_TRUE(!buf->MarkSet());
|
||||
ASSERT_TRUE(!buf->VisualLineActive());
|
||||
}
|
||||
|
||||
|
||||
TEST (VisualLineMode_Yank_BroadcastsToBOL_AndUndo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
b.insert_text(0, 0, "aa\nbb\ncc\n");
|
||||
b.SetCursor(1, 0); // a|a
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
|
||||
|
||||
// Enter visual-line mode and extend selection to 3 lines.
|
||||
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
|
||||
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
|
||||
ASSERT_TRUE(ed.CurrentBuffer()->VisualLineActive());
|
||||
|
||||
ed.KillRingClear();
|
||||
ed.KillRingPush("X");
|
||||
|
||||
// Yank in visual-line mode should paste at BOL on every affected line.
|
||||
ASSERT_TRUE(Execute(ed, std::string("yank")));
|
||||
{
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
// Note: buffers that end with a trailing '\n' have an extra empty row.
|
||||
const std::string exp = "Xaa\nXbb\nXcc\n\n";
|
||||
if (got != exp) {
|
||||
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
|
||||
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
|
||||
}
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
|
||||
// Undo should restore all affected lines in a single step.
|
||||
ASSERT_TRUE(Execute(ed, std::string("undo")));
|
||||
{
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
const std::string exp = "aa\nbb\ncc\n\n";
|
||||
if (got != exp) {
|
||||
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
|
||||
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
|
||||
}
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
|
||||
// Redo should re-apply the whole yank.
|
||||
ASSERT_TRUE(Execute(ed, std::string("redo")));
|
||||
{
|
||||
const std::string got = dump_buf(*ed.CurrentBuffer());
|
||||
const std::string exp = "Xaa\nXbb\nXcc\n\n";
|
||||
ASSERT_TRUE(got == exp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST (VisualLineMode_Highlight_IsPerLineCursorSpot)
|
||||
{
|
||||
Buffer b;
|
||||
// Note: buffers that end with a trailing '\n' have an extra empty row.
|
||||
b.insert_text(0, 0, "abcd\nx\nhi\n");
|
||||
// Place primary cursor on line 0 at column 3 (abc|d).
|
||||
b.SetCursor(3, 0);
|
||||
|
||||
// Select lines 0..2 in visual-line mode.
|
||||
b.VisualLineStart();
|
||||
b.VisualLineSetActiveY(2);
|
||||
ASSERT_TRUE(b.VisualLineActive());
|
||||
ASSERT_TRUE(b.VisualLineStartY() == 0);
|
||||
ASSERT_TRUE(b.VisualLineEndY() == 2);
|
||||
|
||||
// Line 0: "abcd" (len=4) => spot is 3
|
||||
ASSERT_TRUE(b.VisualLineSpotSelected(0, 3));
|
||||
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 0));
|
||||
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 2));
|
||||
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 4));
|
||||
|
||||
// Line 1: "x" (len=1) => spot clamps to EOL (1)
|
||||
ASSERT_TRUE(b.VisualLineSpotSelected(1, 1));
|
||||
ASSERT_TRUE(!b.VisualLineSpotSelected(1, 0));
|
||||
|
||||
// Line 2: "hi" (len=2) => spot clamps to EOL (2)
|
||||
ASSERT_TRUE(b.VisualLineSpotSelected(2, 2));
|
||||
ASSERT_TRUE(!b.VisualLineSpotSelected(2, 0));
|
||||
|
||||
// Outside the selected line range should never be highlighted.
|
||||
ASSERT_TRUE(!b.VisualLineSpotSelected(3, 0));
|
||||
}
|
||||
Reference in New Issue
Block a user