2 Commits

Author SHA1 Message Date
895e4ccb1e Add swap journaling and group undo/redo with extensive tests.
- Introduced SwapManager for sidecar journaling of buffer mutations, with a safe recovery mechanism.
- Added group undo/redo functionality, allowing atomic grouping of related edits.
- Implemented `SwapRecorder` and integrated it as a callback interface for mutations.
- Added unit tests for swap journaling (save/load/replay) and undo grouping.
- Refactored undo to support group tracking and ID management.
- Updated CMake to include the new tests and swap journaling logic.
2026-02-11 20:47:18 -08:00
15b350bfaa Add TestHarness infrastructure and initial smoke test
- Implemented `TestHarness` class for headless editor testing.
- Added utility methods for text insertion, editing, and querying.
- Introduced `test_daily_driver_harness` for verifying basic text buffer operations.
- Updated CMake to include the new test files.
2026-02-10 23:34:01 -08:00
29 changed files with 2571 additions and 290 deletions

View File

@@ -1,6 +1,6 @@
# Project Guidelines # Project Guidelines
kte is Kyle's Text Editor — a simple, fast text editor written in C++17. kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
It It
replaces the earlier C implementation, ke (see the ke manual in replaces the earlier C implementation, ke (see the ke manual in
`docs/ke.md`). The `docs/ke.md`). The
@@ -43,7 +43,7 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
## Contributing/Development Notes ## Contributing/Development Notes
- C++ standard: C++17. - C++ standard: C++20.
- Keep dependencies minimal. - Keep dependencies minimal.
- Prefer small, focused changes that preserve kes UX unless explicitly - Prefer small, focused changes that preserve kes UX unless explicitly
changing changing
@@ -55,3 +55,4 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
for now). for now).
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`. - Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.

View File

@@ -8,6 +8,7 @@
#include <string_view> #include <string_view>
#include "Buffer.h" #include "Buffer.h"
#include "SwapRecorder.h"
#include "UndoSystem.h" #include "UndoSystem.h"
#include "UndoTree.h" #include "UndoTree.h"
// For reconstructing highlighter state on copies // For reconstructing highlighter state on copies
@@ -390,6 +391,8 @@ Buffer::insert_text(int row, int col, std::string_view text)
if (!text.empty()) { if (!text.empty()) {
content_.Insert(off, text.data(), text.size()); content_.Insert(off, text.data(), text.size());
rows_cache_dirty_ = true; rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnInsert(row, col, text);
} }
} }
@@ -443,6 +446,7 @@ Buffer::delete_text(int row, int col, std::size_t len)
row = 0; row = 0;
if (col < 0) if (col < 0)
col = 0; col = 0;
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row), const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
static_cast<std::size_t>(col)); static_cast<std::size_t>(col));
std::size_t r = static_cast<std::size_t>(row); std::size_t r = static_cast<std::size_t>(row);
@@ -462,16 +466,19 @@ Buffer::delete_text(int row, int col, std::size_t len)
break; break;
// Consume newline between lines as one char, if there is a next line // Consume newline between lines as one char, if there is a next line
if (r + 1 < lc) { if (r + 1 < lc) {
if (remaining > 0) {
remaining -= 1; // the newline remaining -= 1; // the newline
r += 1; r += 1;
c = 0; c = 0;
}
} else { } else {
// At last line and still remaining: delete to EOF // At last line and still remaining: delete to EOF
std::size_t total = content_.Size(); const std::size_t total = content_.Size();
content_.Delete(start, total - start); const std::size_t actual = (total > start) ? (total - start) : 0;
if (actual == 0)
return;
content_.Delete(start, actual);
rows_cache_dirty_ = true; rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, actual);
return; return;
} }
} }
@@ -479,8 +486,11 @@ Buffer::delete_text(int row, int col, std::size_t len)
// Compute end offset at (r,c) // Compute end offset at (r,c)
std::size_t end = content_.LineColToByteOffset(r, c); std::size_t end = content_.LineColToByteOffset(r, c);
if (end > start) { if (end > start) {
content_.Delete(start, end - start); const std::size_t actual = end - start;
content_.Delete(start, actual);
rows_cache_dirty_ = true; rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, actual);
} }
} }
@@ -488,15 +498,18 @@ Buffer::delete_text(int row, int col, std::size_t len)
void void
Buffer::split_line(int row, const int col) Buffer::split_line(int row, const int col)
{ {
int c = col;
if (row < 0) if (row < 0)
row = 0; row = 0;
if (col < 0) if (c < 0)
row = 0; c = 0;
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row), const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
static_cast<std::size_t>(col)); static_cast<std::size_t>(c));
const char nl = '\n'; const char nl = '\n';
content_.Insert(off, &nl, 1); content_.Insert(off, &nl, 1);
rows_cache_dirty_ = true; rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
} }
@@ -508,11 +521,14 @@ Buffer::join_lines(int row)
std::size_t r = static_cast<std::size_t>(row); std::size_t r = static_cast<std::size_t>(row);
if (r + 1 >= content_.LineCount()) if (r + 1 >= content_.LineCount())
return; return;
const int col = static_cast<int>(content_.GetLine(r).size());
// Delete the newline between line r and r+1 // 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()); 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. // end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
content_.Delete(end_of_line, 1); content_.Delete(end_of_line, 1);
rows_cache_dirty_ = true; rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, col, 1);
} }
@@ -527,6 +543,12 @@ Buffer::insert_row(int row, const std::string_view text)
const char nl = '\n'; const char nl = '\n';
content_.Insert(off + text.size(), &nl, 1); content_.Insert(off + text.size(), &nl, 1);
rows_cache_dirty_ = true; 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));
}
} }
@@ -541,10 +563,15 @@ Buffer::delete_row(int row)
auto range = content_.GetLineRange(r); // [start,end) 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 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. // If last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
std::size_t start = range.first; const std::size_t start = range.first;
std::size_t end = range.second; const std::size_t end = range.second;
content_.Delete(start, end - start); const std::size_t actual = (end > start) ? (end - start) : 0;
if (actual == 0)
return;
content_.Delete(start, actual);
rows_cache_dirty_ = true; rows_cache_dirty_ = true;
if (swap_rec_)
swap_rec_->OnDelete(row, 0, actual);
} }

View File

@@ -418,6 +418,23 @@ public:
} }
// In visual-line (multi-cursor) mode, the UI should highlight only the per-line
// cursor "spot" (Curx clamped to each line length), not the entire line.
[[nodiscard]] bool VisualLineSpotSelected(std::size_t y, std::size_t sx) const
{
if (!visual_line_active_)
return false;
if (y < VisualLineStartY() || y > VisualLineEndY())
return false;
std::string_view ln = GetLineView(y);
// `GetLineView()` returns the raw range, which may include a trailing '\n'.
if (!ln.empty() && ln.back() == '\n')
ln.remove_suffix(1);
const std::size_t spot = std::min(Curx(), ln.size());
return sx == spot;
}
[[nodiscard]] std::string AsString() const; [[nodiscard]] std::string AsString() const;
// Syntax highlighting integration (per-buffer) // Syntax highlighting integration (per-buffer)

View File

@@ -4,13 +4,13 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.6.0") set(KTE_VERSION "1.6.1")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.") set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.") set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.") set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI") set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF) option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF) option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
@@ -298,9 +298,19 @@ if (BUILD_TESTS)
add_executable(kte_tests add_executable(kte_tests
tests/TestRunner.cc tests/TestRunner.cc
tests/Test.h 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_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_piece_table.cc
tests/test_search.cc tests/test_search.cc
tests/test_search_replace_flow.cc
tests/test_reflow_paragraph.cc tests/test_reflow_paragraph.cc
tests/test_undo.cc tests/test_undo.cc
tests/test_visual_line_mode.cc tests/test_visual_line_mode.cc
@@ -312,6 +322,8 @@ if (BUILD_TESTS)
Command.cc Command.cc
HelpText.cc HelpText.cc
Swap.cc Swap.cc
KKeymap.cc
SwapRecorder.h
OptimizedSearch.cc OptimizedSearch.cc
UndoNode.cc UndoNode.cc
UndoTree.cc UndoTree.cc

View File

@@ -1988,21 +1988,44 @@ cmd_insert_text(CommandContext &ctx)
const std::size_t sy = buf->VisualLineStartY(); const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY(); const std::size_t ey = buf->VisualLineEndY();
const auto &rows = buf->Rows(); const auto &rows = buf->Rows();
UndoSystem *u = buf->Undo();
std::uint64_t gid = 0;
if (u)
gid = u->BeginGroup();
(void) gid;
std::string ins;
if (repeat == 1) {
ins = ctx.arg;
} else {
ins.reserve(ctx.arg.size() * static_cast<std::size_t>(repeat));
for (int i = 0; i < repeat; ++i)
ins += ctx.arg;
}
for (std::size_t yy = sy; yy <= ey; ++yy) { for (std::size_t yy = sy; yy <= ey; ++yy) {
if (yy >= rows.size()) if (yy >= rows.size())
break; break;
std::size_t xx = x; std::size_t xx = x;
if (xx > rows[yy].size()) if (xx > rows[yy].size())
xx = rows[yy].size(); xx = rows[yy].size();
for (int i = 0; i < repeat; ++i) { if (!ins.empty()) {
buf->insert_text(static_cast<int>(yy), static_cast<int>(xx), std::string_view(ctx.arg)); buf->SetCursor(xx, yy);
xx += ctx.arg.size(); if (u)
u->Begin(UndoType::Insert);
buf->insert_text(static_cast<int>(yy), static_cast<int>(xx), std::string_view(ins));
xx += ins.size();
if (u) {
u->Append(std::string_view(ins));
u->commit();
}
} }
if (yy == y) { if (yy == y) {
cx = xx; cx = xx;
cy = yy; cy = yy;
} }
} }
if (u)
u->EndGroup();
buf->SetDirty(true); buf->SetDirty(true);
buf->SetCursor(cx, cy); buf->SetCursor(cx, cy);
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
@@ -2933,6 +2956,10 @@ cmd_backspace(CommandContext &ctx)
const std::size_t sy = buf->VisualLineStartY(); const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY(); const std::size_t ey = buf->VisualLineEndY();
const auto &rows = buf->Rows(); const auto &rows = buf->Rows();
std::uint64_t gid = 0;
if (u)
gid = u->BeginGroup();
(void) gid;
std::size_t cx = x; std::size_t cx = x;
for (std::size_t yy = sy; yy <= ey; ++yy) { for (std::size_t yy = sy; yy <= ey; ++yy) {
if (yy >= rows.size()) if (yy >= rows.size())
@@ -2940,19 +2967,30 @@ cmd_backspace(CommandContext &ctx)
std::size_t xx = x; std::size_t xx = x;
if (xx > rows[yy].size()) if (xx > rows[yy].size())
xx = rows[yy].size(); xx = rows[yy].size();
std::string deleted;
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
if (xx == 0) if (xx == 0)
break; break;
const auto &rows_view = buf->Rows();
if (yy < rows_view.size() && (xx - 1) < rows_view[yy].size())
deleted.insert(deleted.begin(), rows_view[yy][xx - 1]);
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx - 1), 1); buf->delete_text(static_cast<int>(yy), static_cast<int>(xx - 1), 1);
--xx; --xx;
} }
if (u && !deleted.empty()) {
buf->SetCursor(xx, yy);
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
u->commit();
}
if (yy == y) if (yy == y)
cx = xx; cx = xx;
} }
if (u)
u->EndGroup();
buf->SetDirty(true); buf->SetDirty(true);
buf->SetCursor(cx, y); buf->SetCursor(cx, y);
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
(void) u;
return true; return true;
} }
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
@@ -3014,21 +3052,35 @@ cmd_delete_char(CommandContext &ctx)
const std::size_t sy = buf->VisualLineStartY(); const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY(); const std::size_t ey = buf->VisualLineEndY();
const auto &rows = buf->Rows(); const auto &rows = buf->Rows();
std::uint64_t gid = 0;
if (u)
gid = u->BeginGroup();
(void) gid;
for (std::size_t yy = sy; yy <= ey; ++yy) { for (std::size_t yy = sy; yy <= ey; ++yy) {
if (yy >= rows.size()) if (yy >= rows.size())
break; break;
std::size_t xx = x; std::size_t xx = x;
if (xx > rows[yy].size()) if (xx > rows[yy].size())
xx = rows[yy].size(); xx = rows[yy].size();
std::string deleted;
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
if (xx >= buf->Rows()[yy].size()) const auto &rows_view = buf->Rows();
if (yy >= rows_view.size() || xx >= rows_view[yy].size())
break; break;
deleted.push_back(rows_view[yy][xx]);
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx), 1); buf->delete_text(static_cast<int>(yy), static_cast<int>(xx), 1);
} }
if (u && !deleted.empty()) {
buf->SetCursor(xx, yy);
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
u->commit();
} }
}
if (u)
u->EndGroup();
buf->SetDirty(true); buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
(void) u;
return true; return true;
} }
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
@@ -3218,8 +3270,63 @@ cmd_yank(CommandContext &ctx)
} }
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
for (int i = 0; i < repeat; ++i) { std::string ins;
insert_text_at_cursor(*buf, text); if (repeat == 1) {
ins = text;
} else {
ins.reserve(text.size() * static_cast<std::size_t>(repeat));
for (int i = 0; i < repeat; ++i)
ins += text;
}
UndoSystem *u = buf->Undo();
// Visual-line mode: broadcast yank to beginning-of-line on every affected line.
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
const std::size_t y0 = buf->Cury();
std::uint64_t gid = 0;
if (u)
gid = u->BeginGroup();
(void) gid;
// Iterate from bottom to top so insertions don't invalidate remaining line indices.
for (std::size_t yy = ey + 1; yy-- > sy;) {
buf->SetCursor(0, yy);
if (u)
u->Begin(UndoType::Paste);
insert_text_at_cursor(*buf, ins);
if (u) {
u->Append(std::string_view(ins));
u->commit();
}
}
if (u)
u->EndGroup();
// Keep the point on the primary cursor line (as it was before yank), at the end of the
// inserted text for that line.
std::size_t nl_count = 0;
std::size_t last_nl = std::string::npos;
for (std::size_t i = 0; i < ins.size(); ++i) {
if (ins[i] == '\n') {
++nl_count;
last_nl = i;
}
}
const std::size_t delta_y = nl_count;
const std::size_t delta_x = (last_nl == std::string::npos) ? ins.size() : (ins.size() - last_nl - 1);
const std::size_t above = (y0 >= sy) ? (y0 - sy) : 0;
buf->SetCursor(delta_x, y0 + delta_y + above * nl_count);
} else {
if (u)
u->Begin(UndoType::Paste);
insert_text_at_cursor(*buf, ins);
if (u) {
u->Append(std::string_view(ins));
u->commit();
}
} }
// Yank is a paste operation; it should clear the mark/region and any selection highlighting. // Yank is a paste operation; it should clear the mark/region and any selection highlighting.
buf->ClearMark(); buf->ClearMark();

View File

@@ -128,8 +128,8 @@ Editor::AddBuffer(const Buffer &buf)
buffers_.push_back(buf); buffers_.push_back(buf);
// Attach swap recorder // Attach swap recorder
if (swap_) { if (swap_) {
buffers_.back().SetSwapRecorder(swap_.get());
swap_->Attach(&buffers_.back()); swap_->Attach(&buffers_.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
} }
if (buffers_.size() == 1) { if (buffers_.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
@@ -143,8 +143,8 @@ Editor::AddBuffer(Buffer &&buf)
{ {
buffers_.push_back(std::move(buf)); buffers_.push_back(std::move(buf));
if (swap_) { if (swap_) {
buffers_.back().SetSwapRecorder(swap_.get());
swap_->Attach(&buffers_.back()); swap_->Attach(&buffers_.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
} }
if (buffers_.size() == 1) { if (buffers_.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
@@ -171,8 +171,8 @@ Editor::OpenFile(const std::string &path, std::string &err)
return false; return false;
// Ensure swap recorder is attached for this buffer // Ensure swap recorder is attached for this buffer
if (swap_) { if (swap_) {
cur.SetSwapRecorder(swap_.get());
swap_->Attach(&cur); swap_->Attach(&cur);
cur.SetSwapRecorder(swap_->RecorderFor(&cur));
swap_->NotifyFilenameChanged(cur); swap_->NotifyFilenameChanged(cur);
} }
// Setup highlighting using registry (extension + shebang) // Setup highlighting using registry (extension + shebang)
@@ -207,12 +207,8 @@ Editor::OpenFile(const std::string &path, std::string &err)
if (!b.OpenFromFile(path, err)) { if (!b.OpenFromFile(path, err)) {
return false; return false;
} }
if (swap_) { // NOTE: swap recorder/attach must happen after the buffer is stored in its
b.SetSwapRecorder(swap_.get()); // final location (vector) because swap manager keys off Buffer*.
// path is known, notify
swap_->Attach(&b);
swap_->NotifyFilenameChanged(b);
}
// Initialize syntax highlighting by extension + shebang via registry (v2) // Initialize syntax highlighting by extension + shebang via registry (v2)
b.EnsureHighlighter(); b.EnsureHighlighter();
std::string first = ""; std::string first = "";
@@ -239,6 +235,9 @@ Editor::OpenFile(const std::string &path, std::string &err)
} }
// Add as a new buffer and switch to it // Add as a new buffer and switch to it
std::size_t idx = AddBuffer(std::move(b)); std::size_t idx = AddBuffer(std::move(b));
if (swap_) {
swap_->NotifyFilenameChanged(buffers_[idx]);
}
SwitchTo(idx); SwitchTo(idx);
// Defensive: ensure any active prompt is closed after a successful open // Defensive: ensure any active prompt is closed after a successful open
CancelPrompt(); CancelPrompt();
@@ -284,6 +283,10 @@ Editor::CloseBuffer(std::size_t index)
if (index >= buffers_.size()) { if (index >= buffers_.size()) {
return false; return false;
} }
if (swap_) {
swap_->Detach(&buffers_[index]);
buffers_[index].SetSwapRecorder(nullptr);
}
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index)); buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
if (buffers_.empty()) { if (buffers_.empty()) {
curbuf_ = 0; curbuf_ = 0;

View File

@@ -308,14 +308,10 @@ ImGuiRenderer::Draw(Editor &ed)
} }
// Draw selection background (over search highlight; under text) // Draw selection background (over search highlight; under text)
if (sel_active || vsel_active) { if (sel_active) {
bool line_has = false; bool line_has = false;
std::size_t sx = 0, ex = 0; std::size_t sx = 0, ex = 0;
if (vsel_active && i >= vsel_sy && i <= vsel_ey) { if (i < sel_sy || i > sel_ey) {
sx = 0;
ex = line.size();
line_has = ex > sx;
} else if (i < sel_sy || i > sel_ey) {
line_has = false; line_has = false;
} else if (sel_sy == sel_ey) { } else if (sel_sy == sel_ey) {
sx = sel_sx; sx = sel_sx;
@@ -351,6 +347,30 @@ ImGuiRenderer::Draw(Editor &ed)
} }
} }
} }
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) // Emit entire line to an expanded buffer (tabs -> spaces)
for (std::size_t src = 0; src < line.size(); ++src) { for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src]; char c = line[src];

View File

@@ -17,6 +17,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'd': case 'd':
out = CommandId::KillLine; out = CommandId::KillLine;
return true; return true;
case 's':
out = CommandId::Save;
return true;
case 'q': case 'q':
out = CommandId::QuitNow; out = CommandId::QuitNow;
return true; return true;
@@ -42,6 +45,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'a': case 'a':
out = CommandId::MarkAllAndJumpEnd; out = CommandId::MarkAllAndJumpEnd;
return true; return true;
case ' ': // C-k SPACE
out = CommandId::ToggleMark;
return true;
case 'i': case 'i':
out = CommandId::BufferNew; // C-k i new empty buffer out = CommandId::BufferNew; // C-k i new empty buffer
return true; return true;

View File

@@ -32,7 +32,8 @@ Project Goals
Keybindings Keybindings
----------- -----------
kte maintains kes command model while internals evolve. Highlights (subject to refinement): kte maintains kes command model while internals evolve. Highlights (
subject to refinement):
- Kcommand prefix: `C-k` enters kcommand mode; exit with `ESC` or - Kcommand prefix: `C-k` enters kcommand mode; exit with `ESC` or
`C-g`. `C-g`.
@@ -52,7 +53,8 @@ See `ke.md` for the canonical ke reference retained for now.
Build and Run Build and Run
------------- -------------
Prerequisites: C++17 compiler, CMake, and ncurses development headers/libs. Prerequisites: C++20 compiler, CMake, and ncurses development
headers/libs.
Dependencies by platform Dependencies by platform
------------------------ ------------------------
@@ -62,30 +64,38 @@ Dependencies by platform
- `brew install ncurses` - `brew install ncurses`
- Optional GUI (enable with `-DBUILD_GUI=ON`): - Optional GUI (enable with `-DBUILD_GUI=ON`):
- `brew install sdl2 freetype` - `brew install sdl2 freetype`
- OpenGL is provided by the system framework on macOS; no package needed. - OpenGL is provided by the system framework on macOS; no
package needed.
- Debian/Ubuntu - Debian/Ubuntu
- Terminal (default): - Terminal (default):
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev` - `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
- Optional GUI (enable with `-DBUILD_GUI=ON`): - Optional GUI (enable with `-DBUILD_GUI=ON`):
- `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev` -
- The `mesa-common-dev` package provides OpenGL headers/libs (`libGL`). `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
- The `mesa-common-dev` package provides OpenGL headers/libs (
`libGL`).
- NixOS/Nix - NixOS/Nix
- Terminal (default): - Terminal (default):
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses` - Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
- Optional GUI (enable with `-DBUILD_GUI=ON`): - Optional GUI (enable with `-DBUILD_GUI=ON`):
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL` - Ad-hoc shell:
- With flakes/devshell (example `flake.nix` inputs not provided): include `nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell. - With flakes/devshell (example `flake.nix` inputs not provided):
include
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your
devShell.
Notes Notes
----- -----
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable it by - The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable
it by
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
installed for your platform. installed for your platform.
- If you previously configured with GUI ON and want to disable it, reconfigure - If you previously configured with GUI ON and want to disable it,
reconfigure
the build directory with `-DBUILD_GUI=OFF`. the build directory with `-DBUILD_GUI=OFF`.
Example build: Example build:
@@ -113,7 +123,8 @@ built as `kge`) or request the GUI from `kte`:
GUI build example GUI build example
----------------- -----------------
To build with the optional GUI (after installing the GUI dependencies listed above): To build with the optional GUI (after installing the GUI dependencies
listed above):
``` ```
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON

607
Swap.cc
View File

@@ -5,6 +5,9 @@
#include <chrono> #include <chrono>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <ctime>
#include <cstdlib>
#include <fstream>
#include <filesystem> #include <filesystem>
#include <fcntl.h> #include <fcntl.h>
#include <unistd.h> #include <unistd.h>
@@ -18,8 +21,51 @@ namespace {
constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'}; constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
constexpr std::uint32_t VERSION = 1; 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. // 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); const std::uint8_t *p = static_cast<const std::uint8_t *>(buf);
while (len > 0) { while (len > 0) {
@@ -50,6 +96,8 @@ SwapManager::SwapManager()
SwapManager::~SwapManager() SwapManager::~SwapManager()
{ {
// Best-effort: drain queued records before stopping the writer.
Flush();
running_.store(false); running_.store(false);
cv_.notify_all(); cv_.notify_all();
if (worker_.joinable()) if (worker_.joinable())
@@ -62,30 +110,108 @@ SwapManager::~SwapManager()
void 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 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 void
SwapManager::NotifyFilenameChanged(Buffer &buf) 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_); std::lock_guard<std::mutex> lg(mtx_);
auto it = journals_.find(&buf); auto it = journals_.find(&buf);
if (it == journals_.end()) if (it == journals_.end())
return; return;
JournalCtx &ctx = it->second; JournalCtx &ctx = it->second;
// Close existing file handle, update path; lazily reopen on next write
close_ctx(ctx); close_ctx(ctx);
ctx.path = ComputeSidecarPath(buf); ctx.path = ComputeSidecarPath(buf);
ctx.suspended = false;
} }
@@ -93,47 +219,100 @@ void
SwapManager::SetSuspended(Buffer &buf, bool on) SwapManager::SetSuspended(Buffer &buf, bool on)
{ {
std::lock_guard<std::mutex> lg(mtx_); std::lock_guard<std::mutex> lg(mtx_);
auto path = ComputeSidecarPath(buf); auto it = journals_.find(&buf);
// Create/update context for this buffer if (it == journals_.end())
JournalCtx &ctx = journals_[&buf]; return;
ctx.path = path; it->second.suspended = on;
ctx.suspended = on;
} }
SwapManager::SuspendGuard::SuspendGuard(SwapManager &m, Buffer *b) SwapManager::SuspendGuard::SuspendGuard(SwapManager &m, Buffer *b)
: m_(m), buf_(b), prev_(false) : m_(m), buf_(b), prev_(false)
{ {
// Suspend recording while guard is alive if (!buf_)
if (buf_) return;
m_.SetSuspended(*buf_, true); {
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() SwapManager::SuspendGuard::~SuspendGuard()
{ {
if (buf_) if (!buf_)
m_.SetSuspended(*buf_, false); 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 std::string
SwapManager::ComputeSidecarPath(const Buffer &buf) SwapManager::ComputeSidecarPath(const Buffer &buf)
{ {
if (buf.IsFileBacked() || !buf.Filename().empty()) { // Always place swap under an XDG home-appropriate state directory.
fs::path p(buf.Filename()); // This avoids cluttering working directories and prevents stomping on
fs::path dir = p.parent_path(); // swap files when multiple different paths share the same basename.
std::string base = p.filename().string(); fs::path root = xdg_state_home() / "kte" / "swap";
std::string side = "." + base + ".kte.swp";
return (dir / side).string(); 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) return s;
const char *tmp = std::getenv("TMPDIR"); };
fs::path t = tmp ? fs::path(tmp) : fs::temp_directory_path();
fs::path d = t / "kte"; if (!buf.Filename().empty()) {
char bufptr[32]; fs::path p(buf.Filename());
std::snprintf(bufptr, sizeof(bufptr), "%p", (const void *) &buf); std::string key;
return (d / (std::string("unnamed-") + bufptr + ".kte.swp")).string(); 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 bool
SwapManager::write_header(JournalCtx &ctx) SwapManager::write_header(int fd)
{ {
if (ctx.fd < 0) if (fd < 0)
return false; 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::uint8_t hdr[64];
std::memset(hdr, 0, sizeof(hdr)); std::memset(hdr, 0, sizeof(hdr));
std::memcpy(hdr, MAGIC, 8); std::memcpy(hdr, MAGIC, 8);
std::uint32_t ver = VERSION; // version (little-endian)
std::memcpy(hdr + 8, &ver, sizeof(ver)); 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::uint64_t ts = static_cast<std::uint64_t>(std::time(nullptr));
std::memcpy(hdr + 16, &ts, sizeof(ts)); put_le64(hdr + 16, ts);
ssize_t w = ::write(ctx.fd, hdr, sizeof(hdr)); return write_full(fd, hdr, sizeof(hdr));
return (w == (ssize_t) sizeof(hdr));
} }
bool bool
SwapManager::open_ctx(JournalCtx &ctx) SwapManager::open_ctx(JournalCtx &ctx, const std::string &path)
{ {
if (ctx.fd >= 0) if (ctx.fd >= 0)
return true; return true;
if (!ensure_parent_dir(ctx.path)) if (!ensure_parent_dir(path))
return false; return false;
// Create or open with 0600 perms int flags = O_CREAT | O_WRONLY | O_APPEND;
int fd = ::open(ctx.path.c_str(), O_CREAT | O_RDWR, 0600); #ifdef O_CLOEXEC
flags |= O_CLOEXEC;
#endif
int fd = ::open(path.c_str(), flags, 0600);
if (fd < 0) if (fd < 0)
return false; 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{}; struct stat st{};
if (fstat(fd, &st) != 0) { if (fstat(fd, &st) != 0) {
::close(fd); ::close(fd);
return false; return false;
} }
ctx.fd = fd; // If an existing file is too small to contain the fixed header, truncate
ctx.file = fdopen(fd, "ab"); // and restart.
if (!ctx.file) { if (st.st_size > 0 && st.st_size < 64) {
::close(fd); ::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; return false;
(void) ::fchmod(fd, 0600);
st.st_size = 0;
} }
ctx.fd = fd;
ctx.path = path;
if (st.st_size == 0) { if (st.st_size == 0) {
ctx.header_ok = write_header(ctx); ctx.header_ok = write_header(fd);
} else { } else {
ctx.header_ok = true; // trust existing file for stage 1 ctx.header_ok = true; // stage 1: trust existing header
// Seek to end to append
::lseek(ctx.fd, 0, SEEK_END);
} }
return ctx.header_ok; return ctx.header_ok;
} }
@@ -218,16 +413,12 @@ SwapManager::open_ctx(JournalCtx &ctx)
void void
SwapManager::close_ctx(JournalCtx &ctx) 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) { if (ctx.fd >= 0) {
(void) ::fsync(ctx.fd);
::close(ctx.fd); ::close(ctx.fd);
ctx.fd = -1; 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 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 & 0xFFu));
out.push_back(static_cast<std::uint8_t>(v) | 0x80); out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFFu));
v >>= 7; out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFFu));
} out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFFu));
out.push_back(static_cast<std::uint8_t>(v));
} }
void 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[0] = static_cast<std::uint8_t>(v & 0xFFu);
dst[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF); dst[1] = static_cast<std::uint8_t>((v >> 8) & 0xFFu);
dst[2] = static_cast<std::uint8_t>(v & 0xFF); 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_); std::lock_guard<std::mutex> lg(mtx_);
p.seq = ++next_seq_;
queue_.emplace_back(std::move(p)); queue_.emplace_back(std::move(p));
} }
cv_.notify_one(); 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_); std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended) auto it = journals_.find(&buf);
if (it == journals_.end() || it->second.suspended)
return; return;
} }
Pending p; Pending p;
p.buf = &buf; p.buf = &buf;
p.type = SwapRecType::INS; p.type = SwapRecType::INS;
// payload: varint row, varint col, varint len, bytes // payload v1: [encver u8=1][row u32][col u32][nbytes u32][bytes]
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row))); if (text.size() > 0xFFFFFFFFu)
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col))); return;
put_varu64(p.payload, static_cast<std::uint64_t>(text.size())); 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()), p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(text.data()),
reinterpret_cast<const std::uint8_t *>(text.data()) + text.size()); reinterpret_cast<const std::uint8_t *>(text.data()) + text.size());
enqueue(std::move(p)); 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_); std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended) auto it = journals_.find(&buf);
if (it == journals_.end() || it->second.suspended)
return; return;
} }
if (len > 0xFFFFFFFFu)
return;
Pending p; Pending p;
p.buf = &buf; p.buf = &buf;
p.type = SwapRecType::DEL; p.type = SwapRecType::DEL;
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row))); // payload v1: [encver u8=1][row u32][col u32][len u32]
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col))); p.payload.push_back(1);
put_varu64(p.payload, static_cast<std::uint64_t>(len)); 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)); enqueue(std::move(p));
} }
@@ -327,14 +541,17 @@ SwapManager::RecordSplit(Buffer &buf, int row, int col)
{ {
{ {
std::lock_guard<std::mutex> lg(mtx_); std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended) auto it = journals_.find(&buf);
if (it == journals_.end() || it->second.suspended)
return; return;
} }
Pending p; Pending p;
p.buf = &buf; p.buf = &buf;
p.type = SwapRecType::SPLIT; p.type = SwapRecType::SPLIT;
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row))); // payload v1: [encver u8=1][row u32][col u32]
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col))); 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)); enqueue(std::move(p));
} }
@@ -344,13 +561,16 @@ SwapManager::RecordJoin(Buffer &buf, int row)
{ {
{ {
std::lock_guard<std::mutex> lg(mtx_); std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended) auto it = journals_.find(&buf);
if (it == journals_.end() || it->second.suspended)
return; return;
} }
Pending p; Pending p;
p.buf = &buf; p.buf = &buf;
p.type = SwapRecType::JOIN; 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)); enqueue(std::move(p));
} }
@@ -358,59 +578,91 @@ SwapManager::RecordJoin(Buffer &buf, int row)
void void
SwapManager::writer_loop() SwapManager::writer_loop()
{ {
while (running_.load()) { for (;;) {
std::vector<Pending> batch; std::vector<Pending> batch;
{ {
std::unique_lock<std::mutex> lk(mtx_); std::unique_lock<std::mutex> lk(mtx_);
if (queue_.empty()) { if (queue_.empty()) {
if (!running_.load())
break;
cv_.wait_for(lk, std::chrono::milliseconds(cfg_.flush_interval_ms)); cv_.wait_for(lk, std::chrono::milliseconds(cfg_.flush_interval_ms));
} }
if (!queue_.empty()) { if (!queue_.empty()) {
batch.swap(queue_); batch.swap(queue_);
inflight_ += batch.size();
} }
} }
if (batch.empty()) if (batch.empty())
continue; continue;
// Group by buffer path to minimize fsyncs
for (const Pending &p: batch) { for (const Pending &p: batch) {
process_one(p); 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 // Throttled fsync: best-effort (grouped)
// Iterate unique contexts and fsync if needed std::vector<int> to_sync;
// For stage 1, fsync all once per interval
std::uint64_t now = now_ns(); std::uint64_t now = now_ns();
{
std::lock_guard<std::mutex> lg(mtx_);
for (auto &kv: journals_) { for (auto &kv: journals_) {
JournalCtx &ctx = kv.second; JournalCtx &ctx = kv.second;
if (ctx.fd >= 0) { if (ctx.fd >= 0) {
if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >= cfg_. if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >=
fsync_interval_ms) { cfg_.fsync_interval_ms) {
::fsync(ctx.fd);
ctx.last_fsync_ns = now; 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 void
SwapManager::process_one(const Pending &p) SwapManager::process_one(const Pending &p)
{ {
if (!p.buf)
return;
Buffer &buf = *p.buf; Buffer &buf = *p.buf;
// Resolve context by path derived from buffer
std::string path = ComputeSidecarPath(buf); JournalCtx *ctxp = nullptr;
// Get or create context keyed by this buffer pointer (stage 1 simplification) std::string path;
JournalCtx &ctx = journals_[p.buf]; {
if (ctx.path.empty()) std::lock_guard<std::mutex> lg(mtx_);
ctx.path = path; auto it = journals_.find(p.buf);
if (!open_ctx(ctx)) 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; return;
// Build record: [type u8][len u24][payload][crc32 u32] // Build record: [type u8][len u24][payload][crc32 u32]
std::uint8_t len3[3]; 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]; std::uint8_t head[4];
head[0] = static_cast<std::uint8_t>(p.type); 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); c = crc32(head, sizeof(head), c);
if (!p.payload.empty()) if (!p.payload.empty())
c = crc32(p.payload.data(), p.payload.size(), c); 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) // 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()) 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) 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 (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 } // namespace kte

95
Swap.h
View File

@@ -7,11 +7,14 @@
#include <string_view> #include <string_view>
#include <vector> #include <vector>
#include <unordered_map> #include <unordered_map>
#include <memory>
#include <mutex> #include <mutex>
#include <condition_variable> #include <condition_variable>
#include <thread> #include <thread>
#include <atomic> #include <atomic>
#include "SwapRecorder.h"
class Buffer; class Buffer;
namespace kte { namespace kte {
@@ -31,30 +34,12 @@ struct SwapConfig {
unsigned fsync_interval_ms{1000}; // at most once per second 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. // SwapManager manages sidecar swap files and a single background writer thread.
class SwapManager final : public SwapRecorder { class SwapManager final {
public: public:
SwapManager(); SwapManager();
~SwapManager() override; ~SwapManager();
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent. // Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
void Attach(Buffer *buf); void Attach(Buffer *buf);
@@ -62,17 +47,34 @@ public:
// Detach and close journal. // Detach and close journal.
void Detach(Buffer *buf); void Detach(Buffer *buf);
// SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs) // Block until all currently queued records have been written.
void NotifyFilenameChanged(Buffer &buf) override; // If buf is non-null, flushes all records (stage 1) but is primarily intended
// for tests and shutdown.
void Flush(Buffer *buf = nullptr);
// SwapRecorder // Obtain a per-buffer recorder adapter that emits records for that buffer.
void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override; // 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 // RAII guard to suspend recording for internal operations
class SuspendGuard { class SuspendGuard {
@@ -88,12 +90,32 @@ public:
}; };
// Per-buffer toggle // Per-buffer toggle
void SetSuspended(Buffer &buf, bool on) override; void SetSuspended(Buffer &buf, bool on);
private: 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 { struct JournalCtx {
std::string path; std::string path;
void *file{nullptr}; // FILE*
int fd{-1}; int fd{-1};
bool header_ok{false}; bool header_ok{false};
bool suspended{false}; bool suspended{false};
@@ -106,6 +128,7 @@ private:
SwapRecType type{SwapRecType::INS}; SwapRecType type{SwapRecType::INS};
std::vector<std::uint8_t> payload; // framed payload only std::vector<std::uint8_t> payload; // framed payload only
bool urgent_flush{false}; bool urgent_flush{false};
std::uint64_t seq{0};
}; };
// Helpers // Helpers
@@ -115,17 +138,19 @@ private:
static bool ensure_parent_dir(const std::string &path); 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 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 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); void enqueue(Pending &&p);
@@ -136,9 +161,13 @@ private:
// State // State
SwapConfig cfg_{}; SwapConfig cfg_{};
std::unordered_map<Buffer *, JournalCtx> journals_; std::unordered_map<Buffer *, JournalCtx> journals_;
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
std::mutex mtx_; std::mutex mtx_;
std::condition_variable cv_; std::condition_variable cv_;
std::vector<Pending> queue_; 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::atomic<bool> running_{false};
std::thread worker_; std::thread worker_;
}; };

19
SwapRecorder.h Normal file
View File

@@ -0,0 +1,19 @@
// SwapRecorder.h - minimal swap journal recording interface for Buffer mutations
#pragma once
#include <cstddef>
#include <string_view>
namespace kte {
// SwapRecorder is a tiny, non-blocking callback interface.
// Implementations must return quickly; Buffer calls these hooks after a
// mutation succeeds.
class SwapRecorder {
public:
virtual ~SwapRecorder() = default;
virtual void OnInsert(int row, int col, std::string_view bytes) = 0;
virtual void OnDelete(int row, int col, std::size_t len) = 0;
};
} // namespace kte

View File

@@ -126,12 +126,7 @@ TerminalRenderer::Draw(Editor &ed)
const bool vsel_active = buf->VisualLineActive(); const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0; const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0; const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
auto is_src_in_sel = [&](std::size_t y, std::size_t sx) -> bool { auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool {
(void) sx;
if (vsel_active) {
if (y >= vsel_sy && y <= vsel_ey)
return true;
}
if (!sel_active) if (!sel_active)
return false; return false;
if (y < sel_sy || y > sel_ey) if (y < sel_sy || y > sel_ey)
@@ -147,6 +142,45 @@ TerminalRenderer::Draw(Editor &ed)
int written = 0; int written = 0;
if (li < lines.size()) { if (li < lines.size()) {
std::string line = static_cast<std::string>(lines[li]); 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; src_i = 0;
render_col = 0; render_col = 0;
// Syntax highlighting: fetch per-line spans (sanitized copy) // Syntax highlighting: fetch per-line spans (sanitized copy)
@@ -247,7 +281,11 @@ TerminalRenderer::Draw(Editor &ed)
} }
// Now render visible spaces // Now render visible spaces
while (next_tab > 0 && written < cols) { while (next_tab > 0 && written < cols) {
bool in_sel = is_src_in_sel(li, src_i); bool in_mark = is_src_in_mark_sel(li, src_i);
bool in_vsel =
vsel_on_line && !vsel_spot_is_eol && src_i ==
vsel_spot_src;
bool in_sel = in_mark || in_vsel;
bool in_hl = search_mode && is_src_in_hl(src_i); bool in_hl = search_mode && is_src_in_hl(src_i);
bool in_cur = bool in_cur =
has_current && li == cur_my && src_i >= cur_mx has_current && li == cur_my && src_i >= cur_mx
@@ -297,7 +335,16 @@ TerminalRenderer::Draw(Editor &ed)
break; break;
} }
bool in_sel = from_src && is_src_in_sel(li, src_i); 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_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 && bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
src_i < cur_mend; src_i < cur_mend;

View File

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

View File

@@ -8,6 +8,25 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
: buf_(&owner), tree_(tree) {} : buf_(&owner), tree_(tree) {}
std::uint64_t
UndoSystem::BeginGroup()
{
// Ensure any pending typed run is sealed so the group is a distinct undo step.
commit();
if (active_group_id_ == 0)
active_group_id_ = next_group_id_++;
return active_group_id_;
}
void
UndoSystem::EndGroup()
{
commit();
active_group_id_ = 0;
}
void void
UndoSystem::Begin(UndoType type) UndoSystem::Begin(UndoType type)
{ {
@@ -68,6 +87,7 @@ UndoSystem::Begin(UndoType type)
tree_.pending->type = type; tree_.pending->type = type;
tree_.pending->row = row; tree_.pending->row = row;
tree_.pending->col = col; tree_.pending->col = col;
tree_.pending->group_id = active_group_id_;
tree_.pending->text.clear(); tree_.pending->text.clear();
tree_.pending->parent = nullptr; tree_.pending->parent = nullptr;
tree_.pending->child = nullptr; tree_.pending->child = nullptr;
@@ -158,8 +178,12 @@ UndoSystem::undo()
if (!tree_.current) if (!tree_.current)
return; return;
debug_log("undo"); debug_log("undo");
apply(tree_.current, -1); const std::uint64_t gid = tree_.current->group_id;
tree_.current = tree_.current->parent; 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(); update_dirty_flag();
} }
@@ -195,8 +219,16 @@ UndoSystem::redo(int branch_index)
} }
debug_log("redo"); debug_log("redo");
apply(*head, +1); UndoNode *node = *head;
tree_.current = *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(); update_dirty_flag();
} }
@@ -229,6 +261,8 @@ UndoSystem::clear()
tree_.root = nullptr; tree_.root = nullptr;
tree_.current = nullptr; tree_.current = nullptr;
tree_.saved = nullptr; tree_.saved = nullptr;
active_group_id_ = 0;
next_group_id_ = 1;
update_dirty_flag(); update_dirty_flag();
} }

View File

@@ -12,6 +12,12 @@ class UndoSystem {
public: public:
explicit UndoSystem(Buffer &owner, UndoTree &tree); explicit UndoSystem(Buffer &owner, UndoTree &tree);
// Begin an atomic group: subsequent committed nodes with the same group_id will be
// undone/redone as a single step. Returns the active group id.
std::uint64_t BeginGroup();
void EndGroup();
void Begin(UndoType type); void Begin(UndoType type);
void Append(char ch); void Append(char ch);
@@ -66,6 +72,9 @@ private:
PendingAppendMode pending_mode_ = PendingAppendMode::Append; PendingAppendMode pending_mode_ = PendingAppendMode::Append;
std::uint64_t active_group_id_ = 0;
std::uint64_t next_group_id_ = 1;
Buffer *buf_; Buffer *buf_;
UndoTree &tree_; UndoTree &tree_;
}; };

View File

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

138
tests/TestHarness.h Normal file
View File

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

142
tests/test_buffer_rows.cc Normal file
View File

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

View File

@@ -0,0 +1,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 "));
}

View File

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

View File

@@ -0,0 +1,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
View File

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

View File

@@ -1,8 +1,41 @@
#include "Test.h" #include "Test.h"
#include "PieceTable.h" #include "PieceTable.h"
#include <algorithm>
#include <array>
#include <random>
#include <string> #include <string>
#include <vector>
TEST(PieceTable_Insert_Delete_LineCount) {
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; PieceTable pt;
// start empty // start empty
ASSERT_EQ(pt.Size(), (std::size_t) 0); ASSERT_EQ(pt.Size(), (std::size_t) 0);
@@ -27,7 +60,9 @@ TEST(PieceTable_Insert_Delete_LineCount) {
ASSERT_EQ(pt.GetLine(1), std::string("xyz")); ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
} }
TEST(PieceTable_LineCol_Conversions) {
TEST (PieceTable_LineCol_Conversions)
{
PieceTable pt; PieceTable pt;
std::string s = "hello\nworld\n"; // two lines with trailing NL std::string s = "hello\nworld\n"; // two lines with trailing NL
pt.Insert(0, s.data(), s.size()); pt.Insert(0, s.data(), s.size());
@@ -47,3 +82,100 @@ TEST(PieceTable_LineCol_Conversions) {
ASSERT_EQ(lc1.first, (std::size_t) 1); ASSERT_EQ(lc1.first, (std::size_t) 1);
ASSERT_EQ(lc1.second, (std::size_t) 0); ASSERT_EQ(lc1.second, (std::size_t) 0);
} }
TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic)
{
PieceTable pt;
std::string model;
std::mt19937 rng(0xC0FFEEu);
const std::vector<std::string> corpus = {
"a",
"b",
"c",
"xyz",
"123",
"\n",
"!\n",
"foo\nbar",
"end\n",
};
auto check_invariants = [&](const char *where) {
(void) where;
ASSERT_EQ(pt.Size(), model.size());
ASSERT_EQ(pt.GetRange(0, pt.Size()), model);
auto starts = LineStartsFor(model);
ASSERT_EQ(pt.LineCount(), starts.size());
// Spot-check a few line ranges and contents.
std::size_t last = starts.empty() ? (std::size_t) 0 : (starts.size() - 1);
std::size_t mid = (starts.size() > 2) ? (std::size_t) 1 : last;
const std::array<std::size_t, 3> probe_lines = {(std::size_t) 0, last, mid};
for (auto line: probe_lines) {
if (starts.empty())
break;
if (line >= starts.size())
continue;
std::size_t exp_start = starts[line];
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
auto r = pt.GetLineRange(line);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
}
// Round-trips for a few offsets.
const std::vector<std::size_t> probe_offsets = {
0,
model.size() / 2,
model.size(),
};
for (auto off: probe_offsets) {
auto lc = pt.ByteOffsetToLineCol(off);
auto back = pt.LineColToByteOffset(lc.first, lc.second);
ASSERT_EQ(back, off);
}
};
check_invariants("initial");
for (int step = 0; step < 250; step++) {
bool do_insert = model.empty() || ((rng() % 3u) != 0u); // bias toward insert
if (do_insert) {
const std::string &ins = corpus[rng() % corpus.size()];
std::size_t pos = model.empty() ? 0 : (rng() % (model.size() + 1));
pt.Insert(pos, ins.data(), ins.size());
model.insert(pos, ins);
} else {
std::size_t pos = rng() % model.size();
std::size_t max = std::min<std::size_t>(8, model.size() - pos);
std::size_t len = 1 + (rng() % max);
pt.Delete(pos, len);
model.erase(pos, len);
}
// Also validate GetRange on a small random window when non-empty.
if (!model.empty()) {
std::size_t off = rng() % model.size();
std::size_t max = std::min<std::size_t>(16, model.size() - off);
std::size_t len = 1 + (rng() % max);
ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len));
}
check_invariants("step");
}
// Full line-by-line range verification at the end.
auto starts = LineStartsFor(model);
for (std::size_t line = 0; line < starts.size(); line++) {
std::size_t exp_start = starts[line];
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
auto r = pt.GetLineRange(line);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
}
}

View File

@@ -0,0 +1,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
View 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
View 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
View 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);
}

View File

@@ -65,6 +65,49 @@ TEST (VisualLineMode_BroadcastInsert)
} }
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) TEST (VisualLineMode_BroadcastBackspace)
{ {
InstallDefaultCommands(); InstallDefaultCommands();
@@ -92,6 +135,46 @@ TEST (VisualLineMode_BroadcastBackspace)
} }
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) TEST (VisualLineMode_CancelWithCtrlG)
{ {
InstallDefaultCommands(); InstallDefaultCommands();
@@ -156,3 +239,94 @@ TEST (Yank_ClearsMarkAndVisualLine)
ASSERT_TRUE(!buf->MarkSet()); ASSERT_TRUE(!buf->MarkSet());
ASSERT_TRUE(!buf->VisualLineActive()); 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));
}