9 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
cc8df36bdf Implement branching undo system with tests and updates.
- Added branching model for undo/redo, enabling multiple redo paths and branch selection.
- Updated `UndoNode` to include `parent` and refined hierarchical navigation.
- Extended `UndoSystem` with branching logic for redo operations, supporting sibling branch selection.
- Overhauled tests to validate branching behavior and tree invariants.
- Refined editor command logic for undo/redo with repeat counts and branch selection.
- Enabled test-only introspection hooks for undo tree validation.
- Updated CMake to include test definitions (`KTE_TESTS` flag).
2026-02-10 23:13:00 -08:00
1c0f04f076 Bump version to 1.6.0.
- Linear undo
- Multicursor support
- Reflow numbered lists
2026-02-10 22:41:20 -08:00
ac0eadc345 Add undo system with coalescing logic and comprehensive tests.
- Implemented robust undo system supporting coalescing of text operations (insert, backspace, delete).
- Added `UndoSystem` integration into the editor/commands pipeline.
- Wrote extensive unit tests for various undo/redo scenarios, including multiline operations, cursor preservation, and history management.
- Refactored to ensure consistent cursor behavior during undo/redo actions.
- Updated CMake to include new tests.
2026-02-10 22:39:55 -08:00
f3bdced3d4 Add visual-line mode support with tests and UI integration.
- Introduced visual-line mode for multi-line selection and edits.
- Implemented commands, rendering, and keyboard shortcuts.
- Added tests for broadcast operations in visual-line mode.
2026-02-10 22:07:13 -08:00
2551388420 Support numbered lists in reflow-paragraph.
Add `reflow-paragraph` tests for numbered lists with hanging indents and extend support for numbered list parsing and wrapping logic.
2026-02-10 21:23:20 -08:00
d2d155f211 Fix data race.
+ Add thread-safety with mutexes in `PieceTable` and `Buffer`
+ Bump version to 1.5.9
2026-01-28 01:03:58 -08:00
8634eb78f0 Refactor Init method across all frontends to include argc and argv for improved argument handling consistency. 2026-01-12 10:35:24 -08:00
47 changed files with 4651 additions and 563 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)`.

126
Buffer.cc
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
@@ -292,29 +293,29 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
bool bool
Buffer::Save(std::string &err) const Buffer::Save(std::string &err) const
{ {
if (!is_file_backed_ || filename_.empty()) { if (!is_file_backed_ || filename_.empty()) {
err = "Buffer is not file-backed; use SaveAs()"; err = "Buffer is not file-backed; use SaveAs()";
return false; return false;
} }
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc); std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) { if (!out) {
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno)); err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
return false; return false;
} }
// Stream the content directly from the piece table to avoid relying on // Stream the content directly from the piece table to avoid relying on
// full materialization, which may yield an empty pointer when size > 0. // full materialization, which may yield an empty pointer when size > 0.
if (content_.Size() > 0) { if (content_.Size() > 0) {
content_.WriteToStream(out); content_.WriteToStream(out);
} }
// Ensure data hits the OS buffers // Ensure data hits the OS buffers
out.flush(); out.flush();
if (!out.good()) { if (!out.good()) {
err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno)); err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno));
return false; return false;
} }
// Note: const method cannot change dirty_. Intentionally const to allow UI code // Note: const method cannot change dirty_. Intentionally const to allow UI code
// to decide when to flip dirty flag after successful save. // to decide when to flip dirty flag after successful save.
return true; return true;
} }
@@ -346,16 +347,16 @@ Buffer::SaveAs(const std::string &path, std::string &err)
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno)); err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
return false; return false;
} }
// Stream content without forcing full materialization // Stream content without forcing full materialization
if (content_.Size() > 0) { if (content_.Size() > 0) {
content_.WriteToStream(out); content_.WriteToStream(out);
} }
// Ensure data hits the OS buffers // Ensure data hits the OS buffers
out.flush(); out.flush();
if (!out.good()) { if (!out.good()) {
err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno)); err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno));
return false; return false;
} }
filename_ = out_path; filename_ = out_path;
is_file_backed_ = true; is_file_backed_ = true;
@@ -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);
} }
} }
@@ -412,6 +415,7 @@ Buffer::GetLineView(std::size_t row) const
void void
Buffer::ensure_rows_cache() const Buffer::ensure_rows_cache() const
{ {
std::lock_guard<std::mutex> lock(buffer_mutex_);
if (!rows_cache_dirty_) if (!rows_cache_dirty_)
return; return;
rows_.clear(); rows_.clear();
@@ -442,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);
@@ -454,23 +459,26 @@ Buffer::delete_text(int row, int col, std::size_t len)
const std::size_t L = line.size(); const std::size_t L = line.size();
if (c < L) { if (c < L) {
const std::size_t take = std::min(remaining, L - c); const std::size_t take = std::min(remaining, L - c);
c += take; c += take;
remaining -= take; remaining -= take;
} }
if (remaining == 0) if (remaining == 0)
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;
} }
} }
@@ -478,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);
} }
} }
@@ -487,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));
} }
@@ -507,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);
} }
@@ -526,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));
}
} }
@@ -540,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

@@ -14,6 +14,7 @@
#include <cstdint> #include <cstdint>
#include "syntax/HighlighterEngine.h" #include "syntax/HighlighterEngine.h"
#include "Highlight.h" #include "Highlight.h"
#include <mutex>
// Forward declaration for swap journal integration // Forward declaration for swap journal integration
namespace kte { namespace kte {
@@ -369,6 +370,71 @@ public:
} }
// Visual-line selection support (multicursor/visual mode)
void VisualLineClear()
{
visual_line_active_ = false;
}
void VisualLineStart()
{
visual_line_active_ = true;
visual_line_anchor_y_ = cury_;
visual_line_active_y_ = cury_;
}
void VisualLineToggle()
{
if (visual_line_active_)
VisualLineClear();
else
VisualLineStart();
}
[[nodiscard]] bool VisualLineActive() const
{
return visual_line_active_;
}
void VisualLineSetActiveY(std::size_t y)
{
visual_line_active_y_ = y;
}
[[nodiscard]] std::size_t VisualLineStartY() const
{
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_;
}
[[nodiscard]] std::size_t VisualLineEndY() const
{
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_y_;
}
// In visual-line (multi-cursor) mode, the UI should highlight only the per-line
// cursor "spot" (Curx clamped to each line length), not the entire line.
[[nodiscard]] bool VisualLineSpotSelected(std::size_t y, std::size_t sx) const
{
if (!visual_line_active_)
return false;
if (y < VisualLineStartY() || y > VisualLineEndY())
return false;
std::string_view ln = GetLineView(y);
// `GetLineView()` returns the raw range, which may include a trailing '\n'.
if (!ln.empty() && ln.back() == '\n')
ln.remove_suffix(1);
const std::size_t spot = std::min(Curx(), ln.size());
return sx == spot;
}
[[nodiscard]] std::string AsString() const; [[nodiscard]] std::string AsString() const;
// Syntax highlighting integration (per-buffer) // Syntax highlighting integration (per-buffer)
@@ -465,11 +531,14 @@ private:
std::size_t content_LineCount_() const; std::size_t content_LineCount_() const;
std::string filename_; std::string filename_;
bool is_file_backed_ = false; bool is_file_backed_ = false;
bool dirty_ = false; bool dirty_ = false;
bool read_only_ = false; bool read_only_ = false;
bool mark_set_ = false; bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0; std::size_t mark_curx_ = 0, mark_cury_ = 0;
bool visual_line_active_ = false;
std::size_t visual_line_anchor_y_ = 0;
std::size_t visual_line_active_y_ = 0;
// Per-buffer undo state // Per-buffer undo state
std::unique_ptr<struct UndoTree> undo_tree_; std::unique_ptr<struct UndoTree> undo_tree_;
@@ -482,4 +551,6 @@ private:
std::unique_ptr<kte::HighlighterEngine> highlighter_; std::unique_ptr<kte::HighlighterEngine> highlighter_;
// Non-owning pointer to swap recorder managed by Editor/SwapManager // Non-owning pointer to swap recorder managed by Editor/SwapManager
kte::SwapRecorder *swap_rec_ = nullptr; kte::SwapRecorder *swap_rec_ = nullptr;
mutable std::mutex buffer_mutex_;
}; };

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.5.8") 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,13 +298,32 @@ 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_undo.cc
tests/test_visual_line_mode.cc
# minimal engine sources required by Buffer # minimal engine sources required by Buffer
PieceTable.cc PieceTable.cc
Buffer.cc Buffer.cc
Editor.cc
Command.cc
HelpText.cc
Swap.cc
KKeymap.cc
SwapRecorder.h
OptimizedSearch.cc OptimizedSearch.cc
UndoNode.cc UndoNode.cc
UndoTree.cc UndoTree.cc
@@ -312,6 +331,9 @@ if (BUILD_TESTS)
${SYNTAX_SOURCES} ${SYNTAX_SOURCES}
) )
# Allow test-only introspection hooks (guarded in headers) without affecting production builds.
target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
# Allow tests to include project headers like "Buffer.h" # Allow tests to include project headers like "Buffer.h"
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -761,6 +761,15 @@ cmd_quit_now(CommandContext &ctx)
static bool static bool
cmd_refresh(CommandContext &ctx) cmd_refresh(CommandContext &ctx)
{ {
// C-g is mapped to Refresh and acts as a general cancel key.
// Cancel visual-line (multicursor) mode if active.
if (Buffer *buf = ctx.editor.CurrentBuffer()) {
if (buf->VisualLineActive()) {
buf->VisualLineClear();
ctx.editor.SetStatus("Visual line: OFF");
return true;
}
}
// If a generic prompt is active, cancel it // If a generic prompt is active, cancel it
if (ctx.editor.PromptActive()) { if (ctx.editor.PromptActive()) {
// If also in search mode, restore state // If also in search mode, restore state
@@ -1968,30 +1977,77 @@ cmd_insert_text(CommandContext &ctx)
return false; return false;
} }
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
std::size_t y = buf->Cury(); std::size_t y = buf->Cury();
std::size_t x = buf->Curx(); std::size_t x = buf->Curx();
std::size_t ins_y = y; int repeat = ctx.count > 0 ? ctx.count : 1;
std::size_t ins_x = x; // remember insertion start for undo positioning std::size_t cx = x;
int repeat = ctx.count > 0 ? ctx.count : 1; std::size_t cy = y;
// Visual-line mode: broadcast inserts to each selected line at the same column.
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
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) {
if (yy >= rows.size())
break;
std::size_t xx = x;
if (xx > rows[yy].size())
xx = rows[yy].size();
if (!ins.empty()) {
buf->SetCursor(xx, yy);
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) {
cx = xx;
cy = yy;
}
}
if (u)
u->EndGroup();
buf->SetDirty(true);
buf->SetCursor(cx, cy);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
UndoSystem *u = buf->Undo();
if (u) {
// Start/extend a typed-run batch. Do NOT commit here; commit happens on boundaries
// (cursor movement, prompts, undo/redo, etc.) so consecutive InsertText commands coalesce.
buf->SetCursor(x, y);
u->Begin(UndoType::Insert);
}
// Apply edits to the underlying PieceTable through Buffer::insert_text, // Apply edits to the underlying PieceTable through Buffer::insert_text,
// not directly to the legacy rows_ cache. This ensures Save() persists text. // not directly to the legacy rows_ cache. This ensures Save() persists text.
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
buf->insert_text(static_cast<int>(y), static_cast<int>(x), std::string_view(ctx.arg)); buf->insert_text(static_cast<int>(y), static_cast<int>(x), std::string_view(ctx.arg));
if (u)
u->Append(std::string_view(ctx.arg));
x += ctx.arg.size(); x += ctx.arg.size();
} }
buf->SetDirty(true); buf->SetDirty(true);
// Record undo for this contiguous insert at the original insertion point
if (auto *u = buf->Undo()) {
// Position cursor at insertion start for the undo record
buf->SetCursor(ins_x, ins_y);
u->Begin(UndoType::Insert);
for (int i = 0; i < repeat; ++i) {
u->Append(std::string_view(ctx.arg));
}
// Finalize this contiguous insert as a single undoable action
u->commit();
}
buf->SetCursor(x, y); buf->SetCursor(x, y);
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
@@ -2784,6 +2840,41 @@ cmd_newline(CommandContext &ctx)
std::size_t y = buf->Cury(); std::size_t y = buf->Cury();
std::size_t x = buf->Curx(); std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
// Visual-line mode: broadcast newline splits across selected lines.
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
const auto &rows = buf->Rows();
if (rows.empty())
return true;
std::size_t splits_above = 0;
if (sy < y)
splits_above = std::min(ey, y - 1) - sy + 1;
// Iterate bottom-up to keep row indices stable while splitting.
for (std::size_t yy = ey + 1; yy-- > sy;) {
const auto &rows_view = buf->Rows();
if (yy >= rows_view.size())
continue;
std::size_t xx = x;
if (xx > rows_view[yy].size())
xx = rows_view[yy].size();
// First split at the cursor column; subsequent splits create blank lines.
buf->split_line(static_cast<int>(yy), static_cast<int>(xx));
for (int i = 1; i < repeat; ++i) {
buf->split_line(static_cast<int>(yy + i), 0);
}
}
buf->SetDirty(true);
// Cursor: end up on the final inserted line for the original cursor line.
std::size_t new_y = y + static_cast<std::size_t>(repeat);
new_y += splits_above;
buf->SetCursor(0, new_y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
buf->split_line(static_cast<int>(y), static_cast<int>(x)); buf->split_line(static_cast<int>(y), static_cast<int>(x));
// Move to start of next line // Move to start of next line
@@ -2858,6 +2949,50 @@ cmd_backspace(CommandContext &ctx)
std::size_t x = buf->Curx(); std::size_t x = buf->Curx();
UndoSystem *u = buf->Undo(); UndoSystem *u = buf->Undo();
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
// Visual-line mode: broadcast backspace deletes within each selected line.
// For now, we do NOT join lines when at column 0 (too ambiguous across multiple lines).
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
const auto &rows = buf->Rows();
std::uint64_t gid = 0;
if (u)
gid = u->BeginGroup();
(void) gid;
std::size_t cx = x;
for (std::size_t yy = sy; yy <= ey; ++yy) {
if (yy >= rows.size())
break;
std::size_t xx = x;
if (xx > rows[yy].size())
xx = rows[yy].size();
std::string deleted;
for (int i = 0; i < repeat; ++i) {
if (xx == 0)
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);
--xx;
}
if (u && !deleted.empty()) {
buf->SetCursor(xx, yy);
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
u->commit();
}
if (yy == y)
cx = xx;
}
if (u)
u->EndGroup();
buf->SetDirty(true);
buf->SetCursor(cx, y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
// Refresh a read-only view of lines for char capture/lengths // Refresh a read-only view of lines for char capture/lengths
const auto &rows_view = buf->Rows(); const auto &rows_view = buf->Rows();
@@ -2910,6 +3045,44 @@ cmd_delete_char(CommandContext &ctx)
std::size_t x = buf->Curx(); std::size_t x = buf->Curx();
UndoSystem *u = buf->Undo(); UndoSystem *u = buf->Undo();
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
// Visual-line mode: broadcast delete-char within each selected line.
// For now, we do NOT join lines when at end-of-line.
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
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) {
if (yy >= rows.size())
break;
std::size_t xx = x;
if (xx > rows[yy].size())
xx = rows[yy].size();
std::string deleted;
for (int i = 0; i < repeat; ++i) {
const auto &rows_view = buf->Rows();
if (yy >= rows_view.size() || xx >= rows_view[yy].size())
break;
deleted.push_back(rows_view[yy][xx]);
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);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
const auto &rows_view = buf->Rows(); const auto &rows_view = buf->Rows();
if (y >= rows_view.size()) if (y >= rows_view.size())
@@ -2947,7 +3120,9 @@ cmd_undo(CommandContext &ctx)
if (auto *u = buf->Undo()) { if (auto *u = buf->Undo()) {
// Ensure pending batch is finalized so it can be undone // Ensure pending batch is finalized so it can be undone
u->commit(); u->commit();
u->undo(); int repeat = ctx.count > 0 ? ctx.count : 1;
for (int i = 0; i < repeat; ++i)
u->undo();
// Keep cursor within buffer bounds // Keep cursor within buffer bounds
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
ctx.editor.SetStatus("Undone"); ctx.editor.SetStatus("Undone");
@@ -2966,7 +3141,14 @@ cmd_redo(CommandContext &ctx)
if (auto *u = buf->Undo()) { if (auto *u = buf->Undo()) {
// Finalize any pending batch before redoing // Finalize any pending batch before redoing
u->commit(); u->commit();
u->redo(); // With branching undo, a universal-argument count selects an alternate redo branch:
// - no count (or 1): redo the active branch
// - n>1: redo the (n-1)th sibling branch from this point and make it active
if (ctx.count > 1) {
u->redo(ctx.count - 1);
} else {
u->redo();
}
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
ctx.editor.SetStatus("Redone"); ctx.editor.SetStatus("Redone");
return true; return true;
@@ -3088,9 +3270,68 @@ 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.
buf->ClearMark();
if (buf->VisualLineActive())
buf->VisualLineClear();
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
// Start a new kill chain only from kill commands; yanking should break it // Start a new kill chain only from kill commands; yanking should break it
ctx.editor.SetKillChain(false); ctx.editor.SetKillChain(false);
@@ -3107,6 +3348,8 @@ cmd_move_file_start(CommandContext &ctx)
return false; return false;
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
buf->SetCursor(0, 0); buf->SetCursor(0, 0);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
} }
@@ -3123,6 +3366,8 @@ cmd_move_file_end(CommandContext &ctx)
std::size_t y = rows.empty() ? 0 : rows.size() - 1; std::size_t y = rows.empty() ? 0 : rows.size() - 1;
std::size_t x = rows.empty() ? 0 : rows[y].size(); std::size_t x = rows.empty() ? 0 : rows[y].size();
buf->SetCursor(x, y); buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
} }
@@ -3145,6 +3390,18 @@ cmd_toggle_mark(CommandContext &ctx)
} }
static bool
cmd_visual_line_mode_toggle(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
buf->VisualLineToggle();
ctx.editor.SetStatus(std::string("Visual line: ") + (buf->VisualLineActive() ? "ON" : "OFF"));
return true;
}
static bool static bool
cmd_jump_to_mark(CommandContext &ctx) cmd_jump_to_mark(CommandContext &ctx)
{ {
@@ -3306,6 +3563,8 @@ cmd_move_left(CommandContext &ctx)
} }
} }
buf->SetCursor(x, y); buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
} }
@@ -3383,6 +3642,8 @@ cmd_move_right(CommandContext &ctx)
} }
} }
buf->SetCursor(x, y); buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
} }
@@ -3423,6 +3684,8 @@ cmd_move_up(CommandContext &ctx)
if (x > rows[y].size()) if (x > rows[y].size())
x = rows[y].size(); x = rows[y].size();
buf->SetCursor(x, y); buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
} }
@@ -3464,6 +3727,8 @@ cmd_move_down(CommandContext &ctx)
if (x > rows[y].size()) if (x > rows[y].size())
x = rows[y].size(); x = rows[y].size();
buf->SetCursor(x, y); buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
} }
@@ -4026,6 +4291,29 @@ cmd_reflow_paragraph(CommandContext &ctx)
return false; return false;
}; };
auto is_numbered_line = [&](const std::string &s,
std::string &indent_out,
std::string &marker_out,
std::size_t &after_prefix_idx) -> bool {
indent_out = leading_ws(s);
std::size_t i = indent_out.size();
if (i >= s.size() || !std::isdigit(static_cast<unsigned char>(s[i])))
return false;
std::size_t j = i;
while (j < s.size() && std::isdigit(static_cast<unsigned char>(s[j])))
++j;
if (j >= s.size())
return false;
char delim = s[j];
if (!(delim == '.' || delim == ')'))
return false;
if (j + 1 >= s.size() || s[j + 1] != ' ')
return false;
marker_out = s.substr(i, (j - i) + 1); // e.g. "1." or "10)"
after_prefix_idx = j + 2; // after delimiter + space
return true;
};
auto normalize_spaces = [](const std::string &in) { auto normalize_spaces = [](const std::string &in) {
std::string out; std::string out;
out.reserve(in.size()); out.reserve(in.size());
@@ -4107,20 +4395,25 @@ cmd_reflow_paragraph(CommandContext &ctx)
std::vector<std::string> new_lines; std::vector<std::string> new_lines;
// Determine if this region looks like a list: any line starting with bullet // Determine if this region looks like a list: any line starting with bullet or number
bool region_has_bullet = false; bool region_has_list = false;
for (std::size_t i = para_start; i <= para_end; ++i) { for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]); std::string s = static_cast<std::string>(rows[i]);
std::string indent; std::string indent;
char marker; char marker;
std::size_t idx; std::size_t idx;
if (is_bullet_line(s, indent, marker, idx)) { if (is_bullet_line(s, indent, marker, idx)) {
region_has_bullet = true; region_has_list = true;
break;
}
std::string nmarker;
if (is_numbered_line(s, indent, nmarker, idx)) {
region_has_list = true;
break; break;
} }
} }
if (region_has_bullet) { if (region_has_list) {
// Parse as list items; support hanging indent continuations // Parse as list items; support hanging indent continuations
for (std::size_t i = para_start; i <= para_end; ++i) { for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]); std::string s = static_cast<std::string>(rows[i]);
@@ -4148,6 +4441,10 @@ cmd_reflow_paragraph(CommandContext &ctx)
if (is_bullet_line(ns, nindent, nmarker, nidx)) { if (is_bullet_line(ns, nindent, nmarker, nidx)) {
break; // next item break; // next item
} }
std::string nnmarker;
if (is_numbered_line(ns, nindent, nnmarker, nidx)) {
break; // next item
}
// Not a continuation and not a bullet: stop (treat as separate paragraph chunk) // Not a continuation and not a bullet: stop (treat as separate paragraph chunk)
break; break;
} }
@@ -4155,30 +4452,65 @@ cmd_reflow_paragraph(CommandContext &ctx)
wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines); wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines);
i = j - 1; // advance i = j - 1; // advance
} else { } else {
// A non-bullet line within a list region; treat as its own wrapped paragraph preserving its indent std::string nmarker;
std::string base_indent = leading_ws(s); if (is_numbered_line(s, indent, nmarker, after_idx)) {
std::string content = s.substr(base_indent.size()); std::string first_prefix = indent + nmarker + " ";
std::size_t j = i + 1; std::string cont_prefix = indent + std::string(nmarker.size() + 1, ' ');
while (j <= para_end) { std::string content = s.substr(after_idx);
std::string ns = static_cast<std::string>(rows[j]); // consume continuation lines that are part of this numbered item
std::string nindent = leading_ws(ns); std::size_t j = i + 1;
std::string tmp_indent; while (j <= para_end) {
char tmp_marker; std::string ns = static_cast<std::string>(rows[j]);
std::size_t tmp_idx; if (starts_with(ns, cont_prefix)) {
if (is_bullet_line(ns, tmp_indent, tmp_marker, tmp_idx)) { content += ' ';
break; // next bullet starts content += ns.substr(cont_prefix.size());
} ++j;
if (nindent.size() >= base_indent.size()) { continue;
content += ' '; }
content += ns.substr(base_indent.size()); // stop if next item
++j; std::string nindent2;
} else { char bmarker;
std::size_t nidx;
if (is_bullet_line(ns, nindent2, bmarker, nidx))
break;
std::string nnmarker;
if (is_numbered_line(ns, nindent2, nnmarker, nidx))
break;
break; break;
} }
content = normalize_spaces(content);
wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines);
i = j - 1;
} else {
// A non-bullet line within a list region; treat as its own wrapped paragraph preserving its indent
std::string base_indent = leading_ws(s);
std::string content = s.substr(base_indent.size());
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
std::string nindent = leading_ws(ns);
std::string tmp_indent;
char tmp_marker;
std::size_t tmp_idx;
if (is_bullet_line(ns, tmp_indent, tmp_marker, tmp_idx)) {
break; // next bullet starts
}
std::string tmp_nmarker;
if (is_numbered_line(ns, tmp_indent, tmp_nmarker, tmp_idx)) {
break; // next numbered starts
}
if (nindent.size() >= base_indent.size()) {
content += ' ';
content += ns.substr(base_indent.size());
++j;
} else {
break;
}
}
content = normalize_spaces(content);
wrap_with_prefixes(content, base_indent, base_indent, width, new_lines);
i = j - 1;
} }
content = normalize_spaces(content);
wrap_with_prefixes(content, base_indent, base_indent, width, new_lines);
i = j - 1;
} }
} }
} else { } else {
@@ -4396,6 +4728,10 @@ InstallDefaultCommands()
}); });
CommandRegistry::Register({CommandId::MoveFileEnd, "file-end", "Move to end of file", cmd_move_file_end}); CommandRegistry::Register({CommandId::MoveFileEnd, "file-end", "Move to end of file", cmd_move_file_end});
CommandRegistry::Register({CommandId::ToggleMark, "toggle-mark", "Toggle mark at cursor", cmd_toggle_mark}); CommandRegistry::Register({CommandId::ToggleMark, "toggle-mark", "Toggle mark at cursor", cmd_toggle_mark});
CommandRegistry::Register({
CommandId::VisualLineModeToggle, "visual-line-toggle", "Toggle visual-line (multicursor) mode",
cmd_visual_line_mode_toggle, false, false
});
CommandRegistry::Register({ CommandRegistry::Register({
CommandId::JumpToMark, "jump-to-mark", "Jump to mark (swap mark)", cmd_jump_to_mark CommandId::JumpToMark, "jump-to-mark", "Jump to mark (swap mark)", cmd_jump_to_mark
}); });

View File

@@ -47,6 +47,7 @@ enum class CommandId {
MoveFileStart, // move to beginning of file MoveFileStart, // move to beginning of file
MoveFileEnd, // move to end of file MoveFileEnd, // move to end of file
ToggleMark, // toggle mark at cursor ToggleMark, // toggle mark at cursor
VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /)
JumpToMark, // jump to mark, set mark to previous cursor JumpToMark, // jump to mark, set mark to previous cursor
KillRegion, // kill region between mark and cursor (to kill ring) KillRegion, // kill region between mark and cursor (to kill ring)
CopyRegion, // copy region to kill ring (Alt-w) CopyRegion, // copy region to kill ring (Alt-w)

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)
@@ -197,22 +197,18 @@ Editor::OpenFile(const std::string &path, std::string &err)
eng->InvalidateFrom(0); eng->InvalidateFrom(0);
} }
} }
// Defensive: ensure any active prompt is closed after a successful open // Defensive: ensure any active prompt is closed after a successful open
CancelPrompt(); CancelPrompt();
return true; return true;
} }
} }
Buffer b; Buffer b;
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,10 +235,13 @@ 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));
SwitchTo(idx); if (swap_) {
// Defensive: ensure any active prompt is closed after a successful open swap_->NotifyFilenameChanged(buffers_[idx]);
CancelPrompt(); }
return true; SwitchTo(idx);
// Defensive: ensure any active prompt is closed after a successful open
CancelPrompt();
return true;
} }
@@ -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

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

View File

@@ -30,8 +30,10 @@
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible) static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
bool bool
GUIFrontend::Init(Editor &ed) GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc;
(void) argv;
// Attach editor to input handler for editor-owned features (e.g., universal argument) // Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed); input_.Attach(&ed);
// editor dimensions will be initialized during the first Step() frame // editor dimensions will be initialized during the first Step() frame

View File

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

View File

@@ -150,73 +150,84 @@ ImGuiRenderer::Draw(Editor &ed)
// Cache current horizontal offset in rendered columns for click handling // Cache current horizontal offset in rendered columns for click handling
const std::size_t coloffs_now = buf->Coloffs(); const std::size_t coloffs_now = buf->Coloffs();
// Handle mouse click before rendering to avoid dependent on drawn items // Mark selection state (mark -> cursor), in source coordinates
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { bool sel_active = false;
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
if (buf->MarkSet()) {
sel_sy = buf->MarkCury();
sel_sx = buf->MarkCurx();
sel_ey = buf->Cury();
sel_ex = buf->Curx();
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
std::swap(sel_sy, sel_ey);
std::swap(sel_sx, sel_ex);
}
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
}
// Visual-line selection: full-line highlight range
const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
static bool mouse_selecting = false;
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
ImVec2 mp = ImGui::GetIO().MousePos; ImVec2 mp = ImGui::GetIO().MousePos;
// Compute content-relative position accounting for scroll // Convert mouse pos to buffer row
// mp.y - child_window_pos.y gives us pixels from top of child window
// Adding scroll_y gives us pixels from top of content (buffer row 0)
float content_y = (mp.y - child_window_pos.y) + scroll_y; float content_y = (mp.y - child_window_pos.y) + scroll_y;
long by_l = static_cast<long>(content_y / row_h); long by_l = static_cast<long>(content_y / row_h);
if (by_l < 0) if (by_l < 0)
by_l = 0; by_l = 0;
// Convert to buffer row
std::size_t by = static_cast<std::size_t>(by_l); std::size_t by = static_cast<std::size_t>(by_l);
if (by >= lines.size()) { if (by >= lines.size())
if (!lines.empty()) by = lines.empty() ? 0 : (lines.size() - 1);
by = lines.size() - 1;
else
by = 0;
}
// Compute click X position relative to left edge of child window (in pixels) // Convert mouse pos to rendered x
// This gives us the visual offset from the start of displayed content
float visual_x = mp.x - child_window_pos.x; float visual_x = mp.x - child_window_pos.x;
if (visual_x < 0.0f) if (visual_x < 0.0f)
visual_x = 0.0f; visual_x = 0.0f;
// Convert visual pixel offset to rendered column, then add coloffs_now
// to get the absolute rendered column in the buffer
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now; std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
// Empty buffer guard: if there are no lines yet, just move to 0:0 // Convert rendered column to source column
if (lines.empty()) { if (lines.empty())
Execute(ed, CommandId::MoveCursorTo, std::string("0:0")); return {0, 0};
} else { std::string line_clicked = static_cast<std::string>(lines[by]);
// Convert rendered column (clicked_rx) to source column accounting for tabs const std::size_t tabw = 8;
std::string line_clicked = static_cast<std::string>(lines[by]); std::size_t rx = 0;
const std::size_t tabw = 8; std::size_t best_col = 0;
float best_dist = std::numeric_limits<float>::infinity();
// Iterate through source columns, computing rendered position, to find closest match float clicked_rx_f = static_cast<float>(clicked_rx);
std::size_t rx = 0; // rendered column position for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
std::size_t best_col = 0; float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
float best_dist = std::numeric_limits<float>::infinity(); if (dist < best_dist) {
float clicked_rx_f = static_cast<float>(clicked_rx); best_dist = dist;
best_col = i;
for (std::size_t i = 0; i <= line_clicked.size(); ++i) { }
// Check current position if (i < line_clicked.size()) {
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx)); rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1;
if (dist < best_dist) {
best_dist = dist;
best_col = i;
}
// Advance to next position if not at end
if (i < line_clicked.size()) {
if (line_clicked[i] == '\t') {
rx += (tabw - (rx % tabw));
} else {
rx += 1;
}
}
} }
// Dispatch absolute buffer coordinates (row:col)
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
} }
return {by, best_col};
};
// Mouse-driven selection: set mark on press, update cursor on drag
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
mouse_selecting = true;
auto [by, bx] = mouse_pos_to_buf();
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetMark(bx, by);
}
}
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
auto [by, bx] = mouse_pos_to_buf();
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
}
if (mouse_selecting && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
mouse_selecting = false;
} }
for (std::size_t i = rowoffs; i < lines.size(); ++i) { for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Capture the screen position before drawing the line // Capture the screen position before drawing the line
@@ -295,6 +306,71 @@ ImGuiRenderer::Draw(Editor &ed)
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
} }
} }
// Draw selection background (over search highlight; under text)
if (sel_active) {
bool line_has = false;
std::size_t sx = 0, ex = 0;
if (i < sel_sy || i > sel_ey) {
line_has = false;
} else if (sel_sy == sel_ey) {
sx = sel_sx;
ex = sel_ex;
line_has = ex > sx;
} else if (i == sel_sy) {
sx = sel_sx;
ex = line.size();
line_has = ex > sx;
} else if (i == sel_ey) {
sx = 0;
ex = std::min(sel_ex, line.size());
line_has = ex > sx;
} else {
sx = 0;
ex = line.size();
line_has = ex > sx;
}
if (line_has) {
std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex);
if (rx_end > coloffs_now) {
std::size_t vx0 = (rx_start > coloffs_now)
? (rx_start - coloffs_now)
: 0;
std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
line_pos.y + line_h);
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
}
}
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
const std::size_t spot_sx = std::min(buf->Curx(), line.size());
const std::size_t rx_start = src_to_rx(spot_sx);
std::size_t rx_end = rx_start;
if (spot_sx < line.size()) {
rx_end = src_to_rx(spot_sx + 1);
} else {
// EOL spot: draw a 1-cell highlight just past the last character.
rx_end = rx_start + 1;
}
if (rx_end > coloffs_now) {
std::size_t vx0 = (rx_start > coloffs_now)
? (rx_start - coloffs_now)
: 0;
std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
line_pos.y + line_h);
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
}
// Emit entire line to an expanded buffer (tabs -> spaces) // 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;
@@ -114,6 +120,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case '=': case '=':
out = CommandId::IndentRegion; out = CommandId::IndentRegion;
return true; return true;
case '/':
out = CommandId::VisualLineModeToggle;
return true;
case ';': case ';':
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
return true; return true;

View File

@@ -218,9 +218,9 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
std::size_t expectStart = last.start + last.len; std::size_t expectStart = last.start + last.len;
if (expectStart == start) { if (expectStart == start) {
last.len += len; last.len += len;
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
@@ -231,7 +231,7 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
pieces_.push_back(Piece{src, start, len}); pieces_.push_back(Piece{src, start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
@@ -251,9 +251,9 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
Piece &first = pieces_.front(); Piece &first = pieces_.front();
if (first.src == src && start + len == first.start) { if (first.src == src && start + len == first.start) {
first.start = start; first.start = start;
first.len += len; first.len += len;
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
find_cache_ = {}; find_cache_ = {};
@@ -262,7 +262,7 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
} }
pieces_.insert(pieces_.begin(), Piece{src, start, len}); pieces_.insert(pieces_.begin(), Piece{src, start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
version_++; version_++;
range_cache_ = {}; range_cache_ = {};
@@ -273,6 +273,7 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
void void
PieceTable::materialize() const PieceTable::materialize() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
if (!dirty_) { if (!dirty_) {
return; return;
} }
@@ -348,6 +349,7 @@ PieceTable::coalesceNeighbors(std::size_t index)
void void
PieceTable::InvalidateLineIndex() const PieceTable::InvalidateLineIndex() const
{ {
std::lock_guard<std::mutex> lock(mutex_);
line_index_dirty_ = true; line_index_dirty_ = true;
} }
@@ -355,22 +357,29 @@ PieceTable::InvalidateLineIndex() const
void void
PieceTable::RebuildLineIndex() const PieceTable::RebuildLineIndex() const
{ {
if (!line_index_dirty_) std::lock_guard<std::mutex> lock(mutex_);
if (!line_index_dirty_) {
return; return;
}
line_index_.clear(); line_index_.clear();
line_index_.push_back(0); line_index_.push_back(0);
std::size_t pos = 0; std::size_t pos = 0;
for (const auto &pc: pieces_) { for (const auto &pc: pieces_) {
const std::string &src = pc.src == Source::Original ? original_ : add_; const std::string &src = pc.src == Source::Original ? original_ : add_;
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start); const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
for (std::size_t j = 0; j < pc.len; ++j) { for (std::size_t j = 0; j < pc.len; ++j) {
if (base[j] == '\n') { if (base[j] == '\n') {
// next line starts after the newline // next line starts after the newline
line_index_.push_back(pos + j + 1); line_index_.push_back(pos + j + 1);
} }
} }
pos += pc.len; pos += pc.len;
} }
line_index_dirty_ = false; line_index_dirty_ = false;
} }
@@ -391,7 +400,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
if (pieces_.empty()) { if (pieces_.empty()) {
pieces_.push_back(Piece{Source::Add, add_start, len}); pieces_.push_back(Piece{Source::Add, add_start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
maybeConsolidate(); maybeConsolidate();
version_++; version_++;
@@ -405,7 +414,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
// insert at end // insert at end
pieces_.push_back(Piece{Source::Add, add_start, len}); pieces_.push_back(Piece{Source::Add, add_start, len});
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
coalesceNeighbors(pieces_.size() - 1); coalesceNeighbors(pieces_.size() - 1);
maybeConsolidate(); maybeConsolidate();
@@ -433,7 +442,7 @@ PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx), repl.begin(), repl.end()); pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx), repl.begin(), repl.end());
total_size_ += len; total_size_ += len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
// Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0)) // Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0))
std::size_t ins_index = idx + (inner > 0 ? 1 : 0); std::size_t ins_index = idx + (inner > 0 ? 1 : 0);
@@ -488,13 +497,13 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
// entire piece removed // entire piece removed
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx)); pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx));
// stay at same idx for next piece // stay at same idx for next piece
inner = 0; inner = 0;
remaining -= take; remaining -= take;
continue; continue;
} }
// After modifying current idx, next deletion continues at beginning of the next logical region // After modifying current idx, next deletion continues at beginning of the next logical region
inner = 0; inner = 0;
remaining -= take; remaining -= take;
if (remaining == 0) if (remaining == 0)
break; break;
@@ -503,7 +512,7 @@ PieceTable::Delete(std::size_t byte_offset, std::size_t len)
} }
total_size_ -= len; total_size_ -= len;
dirty_ = true; dirty_ = true;
InvalidateLineIndex(); InvalidateLineIndex();
if (idx < pieces_.size()) if (idx < pieces_.size())
coalesceNeighbors(idx); coalesceNeighbors(idx);
@@ -692,14 +701,18 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
len = total_size_ - byte_offset; len = total_size_ - byte_offset;
// Fast path: return cached value if version/offset/len match // Fast path: return cached value if version/offset/len match
if (range_cache_.valid && range_cache_.version == version_ && {
range_cache_.off == byte_offset && range_cache_.len == len) { std::lock_guard<std::mutex> lock(mutex_);
return range_cache_.data; if (range_cache_.valid && range_cache_.version == version_ &&
range_cache_.off == byte_offset && range_cache_.len == len) {
return range_cache_.data;
}
} }
std::string out; std::string out;
out.reserve(len); out.reserve(len);
if (!dirty_) { if (!dirty_) {
std::lock_guard<std::mutex> lock(mutex_);
// Already materialized; slice directly // Already materialized; slice directly
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len); out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
} else { } else {
@@ -714,8 +727,8 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start + inner); const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start + inner);
out.append(base, take); out.append(base, take);
remaining -= take; remaining -= take;
inner = 0; inner = 0;
idx += 1; idx += 1;
} else { } else {
break; break;
} }
@@ -723,11 +736,14 @@ PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
} }
// Update cache // Update cache
range_cache_.valid = true; {
range_cache_.version = version_; std::lock_guard<std::mutex> lock(mutex_);
range_cache_.off = byte_offset; range_cache_.valid = true;
range_cache_.len = len; range_cache_.version = version_;
range_cache_.data = out; range_cache_.off = byte_offset;
range_cache_.len = len;
range_cache_.data = out;
}
return out; return out;
} }
@@ -739,23 +755,30 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
return start <= total_size_ ? start : std::numeric_limits<std::size_t>::max(); return start <= total_size_ ? start : std::numeric_limits<std::size_t>::max();
if (start > total_size_) if (start > total_size_)
return std::numeric_limits<std::size_t>::max(); return std::numeric_limits<std::size_t>::max();
if (find_cache_.valid && {
find_cache_.version == version_ && std::lock_guard<std::mutex> lock(mutex_);
find_cache_.needle == needle && if (find_cache_.valid &&
find_cache_.start == start) { find_cache_.version == version_ &&
return find_cache_.result; find_cache_.needle == needle &&
find_cache_.start == start) {
return find_cache_.result;
}
} }
materialize(); materialize();
auto pos = materialized_.find(needle, start); std::size_t pos;
if (pos == std::string::npos) {
pos = std::numeric_limits<std::size_t>::max(); std::lock_guard<std::mutex> lock(mutex_);
// Update cache pos = materialized_.find(needle, start);
find_cache_.valid = true; if (pos == std::string::npos)
find_cache_.version = version_; pos = std::numeric_limits<std::size_t>::max();
find_cache_.needle = needle; // Update cache
find_cache_.start = start; find_cache_.valid = true;
find_cache_.result = pos; find_cache_.version = version_;
find_cache_.needle = needle;
find_cache_.start = start;
find_cache_.result = pos;
}
return pos; return pos;
} }
@@ -763,12 +786,15 @@ PieceTable::Find(const std::string &needle, std::size_t start) const
void void
PieceTable::WriteToStream(std::ostream &out) const PieceTable::WriteToStream(std::ostream &out) const
{ {
// Stream the content piece-by-piece without forcing full materialization // Stream the content piece-by-piece without forcing full materialization
for (const auto &p : pieces_) { // No lock needed for original_ and add_ if they are not being modified.
if (p.len == 0) // Since this is a const method and kte's piece table isn't modified by multiple threads
continue; // (only queried), we just iterate pieces_.
const std::string &src = (p.src == Source::Original) ? original_ : add_; for (const auto &p: pieces_) {
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start); if (p.len == 0)
out.write(base, static_cast<std::streamsize>(p.len)); continue;
} const std::string &src = (p.src == Source::Original) ? original_ : add_;
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start);
out.write(base, static_cast<std::streamsize>(p.len));
}
} }

View File

@@ -8,6 +8,7 @@
#include <ostream> #include <ostream>
#include <vector> #include <vector>
#include <limits> #include <limits>
#include <mutex>
class PieceTable { class PieceTable {
@@ -181,4 +182,6 @@ private:
mutable RangeCache range_cache_; mutable RangeCache range_cache_;
mutable FindCache find_cache_; mutable FindCache find_cache_;
mutable std::mutex mutex_;
}; };

View File

@@ -658,11 +658,9 @@ private:
} // namespace } // namespace
bool bool
GUIFrontend::Init(Editor &ed) GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
int argc = 0; app_ = new QApplication(argc, argv);
char **argv = nullptr;
app_ = new QApplication(argc, argv);
window_ = new MainWindow(input_); window_ = new MainWindow(input_);
window_->show(); window_->show();

View File

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

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

659
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,23 +21,66 @@ 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;
// 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 fs::path
xdg_state_home()
{ {
const std::uint8_t *p = static_cast<const std::uint8_t *>(buf); if (const char *p = std::getenv("XDG_STATE_HOME")) {
while (len > 0) { if (*p)
ssize_t n = ::write(fd, p, len); return fs::path(p);
if (n < 0) { }
if (errno == EINTR) if (const char *home = std::getenv("HOME")) {
continue; if (*home)
return false; return fs::path(home) / ".local" / "state";
} }
if (n == 0) // Last resort: still provide a stable per-user-ish location.
return false; // shouldn't happen for regular files; treat as error return fs::temp_directory_path() / "kte" / "state";
p += static_cast<size_t>(n); }
len -= static_cast<size_t>(n);
}
return true; static std::uint64_t
fnv1a64(std::string_view s)
{
std::uint64_t h = 14695981039346656037ULL;
for (unsigned char ch: s) {
h ^= (std::uint64_t) ch;
h *= 1099511628211ULL;
}
return h;
}
static std::string
hex_u64(std::uint64_t v)
{
static const char *kHex = "0123456789abcdef";
char out[16];
for (int i = 15; i >= 0; --i) {
out[i] = kHex[v & 0xFULL];
v >>= 4;
}
return std::string(out, sizeof(out));
}
// Write all bytes in buf to fd, handling EINTR and partial writes.
static bool
write_full(int fd, const void *buf, size_t len)
{
const std::uint8_t *p = static_cast<const std::uint8_t *>(buf);
while (len > 0) {
ssize_t n = ::write(fd, p, len);
if (n < 0) {
if (errno == EINTR)
continue;
return false;
}
if (n == 0)
return false; // shouldn't happen for regular files; treat as error
p += static_cast<size_t>(n);
len -= static_cast<size_t>(n);
}
return true;
} }
} }
@@ -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.
// This avoids cluttering working directories and prevents stomping on
// swap files when multiple different paths share the same basename.
fs::path root = xdg_state_home() / "kte" / "swap";
auto encode_path = [](std::string s) -> std::string {
// Turn an absolute path like "/home/kyle/tmp/test.txt" into
// "home!kyle!tmp!test.txt" so swap files are human-identifiable.
//
// Notes:
// - We strip a single leading path separator so absolute paths don't start with '!'.
// - We replace both '/' and '\\' with '!'.
// - We leave other characters as-is (spaces are OK on POSIX).
if (!s.empty() && (s[0] == '/' || s[0] == '\\'))
s.erase(0, 1);
for (char &ch: s) {
if (ch == '/' || ch == '\\')
ch = '!';
}
return s;
};
if (!buf.Filename().empty()) {
fs::path p(buf.Filename()); fs::path p(buf.Filename());
fs::path dir = p.parent_path(); std::string key;
std::string base = p.filename().string(); try {
std::string side = "." + base + ".kte.swp"; key = fs::weakly_canonical(p).string();
return (dir / side).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: $TMPDIR/kte/unnamed-<ptr>.kte.swp (best-effort)
const char *tmp = std::getenv("TMPDIR"); // Unnamed buffers: unique within the process.
fs::path t = tmp ? fs::path(tmp) : fs::temp_directory_path(); static std::atomic<std::uint64_t> ctr{0};
fs::path d = t / "kte"; const std::uint64_t n = ++ctr;
char bufptr[32]; const int pid = (int) ::getpid();
std::snprintf(bufptr, sizeof(bufptr), "%p", (const void *) &buf); const std::string name = "unnamed-" + std::to_string(pid) + "-" + std::to_string(n) + ".swp";
return (d / (std::string("unnamed-") + bufptr + ".kte.swp")).string(); 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;
return false; #ifdef O_CLOEXEC
tflags |= O_CLOEXEC;
#endif
fd = ::open(path.c_str(), tflags, 0600);
if (fd < 0)
return false;
(void) ::fchmod(fd, 0600);
st.st_size = 0;
} }
ctx.fd = fd;
ctx.path = path;
if (st.st_size == 0) { 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;
} }
@@ -240,35 +431,48 @@ SwapManager::crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed
for (std::uint32_t i = 0; i < 256; ++i) { for (std::uint32_t i = 0; i < 256; ++i) {
std::uint32_t c = i; std::uint32_t c = i;
for (int j = 0; j < 8; ++j) for (int j = 0; j < 8; ++j)
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
table[i] = c; table[i] = c;
} }
inited = true; inited = true;
} }
std::uint32_t c = ~seed; std::uint32_t c = ~seed;
for (std::size_t i = 0; i < len; ++i) for (std::size_t i = 0; i < len; ++i)
c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8); c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8);
return ~c; return ~c;
} }
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();
for (auto &kv: journals_) { {
JournalCtx &ctx = kv.second; std::lock_guard<std::mutex> lg(mtx_);
if (ctx.fd >= 0) { for (auto &kv: journals_) {
if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >= cfg_. JournalCtx &ctx = kv.second;
fsync_interval_ms) { if (ctx.fd >= 0) {
::fsync(ctx.fd); if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >=
ctx.last_fsync_ns = now; cfg_.fsync_interval_ms) {
ctx.last_fsync_ns = now;
to_sync.push_back(ctx.fd);
}
} }
} }
} }
for (int fd: to_sync) {
(void) ::fsync(fd);
}
} }
// Wake any waiters.
cv_.notify_all();
} }
void 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

@@ -8,8 +8,10 @@
bool bool
TerminalFrontend::Init(Editor &ed) TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc;
(void) argv;
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS) // Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
{ {
struct termios tio{}; struct termios tio{};

View File

@@ -21,7 +21,7 @@ public:
// Adjust if your terminal needs a different threshold. // Adjust if your terminal needs a different threshold.
static constexpr int kEscDelayMs = 50; static constexpr int kEscDelayMs = 50;
bool Init(Editor &ed) override; bool Init(int &argc, char **argv, Editor &ed) override;
void Step(Editor &ed, bool &running) override; void Step(Editor &ed, bool &running) override;

View File

@@ -3,6 +3,7 @@
#include "TerminalInputHandler.h" #include "TerminalInputHandler.h"
#include "KKeymap.h" #include "KKeymap.h"
#include "Command.h"
#include "Editor.h" #include "Editor.h"
namespace { namespace {
@@ -23,6 +24,7 @@ map_key_to_command(const int ch,
bool &k_prefix, bool &k_prefix,
bool &esc_meta, bool &esc_meta,
bool &k_ctrl_pending, bool &k_ctrl_pending,
bool &mouse_selecting,
Editor *ed, Editor *ed,
MappedInput &out) MappedInput &out)
{ {
@@ -54,12 +56,33 @@ map_key_to_command(const int ch,
} }
#endif #endif
// React to left button click/press // React to left button click/press
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) { if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED |
REPORT_MOUSE_POSITION))) {
char buf[64]; char buf[64];
// Use screen coordinates; command handler will translate via offsets // Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x); std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
out = {true, CommandId::MoveCursorTo, std::string(buf), 0}; const bool pressed = (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) != 0;
return true; const bool released = (ev.bstate & BUTTON1_RELEASED) != 0;
const bool moved = (ev.bstate & REPORT_MOUSE_POSITION) != 0;
if (pressed) {
mouse_selecting = true;
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
if (Buffer *b = ed->CurrentBuffer()) {
b->SetMark(b->Curx(), b->Cury());
}
out.hasCommand = false;
return true;
}
if (mouse_selecting && moved) {
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
out.hasCommand = false;
return true;
}
if (released) {
mouse_selecting = false;
out.hasCommand = false;
return true;
}
} }
} }
// No actionable mouse event // No actionable mouse event
@@ -178,15 +201,15 @@ map_key_to_command(const int ch,
ctrl = true; ctrl = true;
ascii_key = 'a' + (ch - 1); ascii_key = 'a' + (ch - 1);
} }
// If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending // If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending
// Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose). // Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose).
if (ascii_key == 'C' || ascii_key == '^') { if (ascii_key == 'C' || ascii_key == '^') {
k_ctrl_pending = true; k_ctrl_pending = true;
if (ed) if (ed)
ed->SetStatus("C-k C _"); ed->SetStatus("C-k C _");
out.hasCommand = false; out.hasCommand = false;
return true; return true;
} }
// For actual suffix, consume the k-prefix // For actual suffix, consume the k-prefix
k_prefix = false; k_prefix = false;
// Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings // Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings
@@ -292,6 +315,7 @@ TerminalInputHandler::decode_(MappedInput &out)
ch, ch,
k_prefix_, esc_meta_, k_prefix_, esc_meta_,
k_ctrl_pending_, k_ctrl_pending_,
mouse_selecting_,
ed_, ed_,
out); out);
if (!consumed) if (!consumed)

View File

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

View File

@@ -107,13 +107,82 @@ TerminalRenderer::Draw(Editor &ed)
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0; const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0; const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0; const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
bool hl_on = false;
bool cur_on = false; // Mark selection (mark -> cursor), in source coordinates
int written = 0; bool sel_active = false;
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
if (buf->MarkSet()) {
sel_sy = buf->MarkCury();
sel_sx = buf->MarkCurx();
sel_ey = buf->Cury();
sel_ex = buf->Curx();
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
std::swap(sel_sy, sel_ey);
std::swap(sel_sx, sel_ex);
}
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
}
// Visual-line selection: full-line selection range
const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool {
if (!sel_active)
return false;
if (y < sel_sy || y > sel_ey)
return false;
if (sel_sy == sel_ey)
return sx >= sel_sx && sx < sel_ex;
if (y == sel_sy)
return sx >= sel_sx;
if (y == sel_ey)
return sx < sel_ex;
return true;
};
int written = 0;
if (li < lines.size()) { if (li < lines.size()) {
std::string line = static_cast<std::string>(lines[li]); std::string line = static_cast<std::string>(lines[li]);
src_i = 0; const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
render_col = 0; const std::size_t vsel_spot_src = vsel_on_line
? std::min(buf->Curx(), line.size())
: 0;
const bool vsel_spot_is_eol = vsel_on_line && vsel_spot_src == line.size();
std::size_t vsel_line_rx = 0;
if (vsel_spot_is_eol) {
// Compute the rendered (column) width of the line so we can highlight a
// single cell at EOL when the spot falls beyond the last character.
std::size_t rc = 0;
std::size_t si = 0;
while (si < line.size()) {
wchar_t wch = L' ';
int wch_len = 1;
std::mbstate_t state = std::mbstate_t();
size_t res = std::mbrtowc(&wch, &line[si], line.size() - si, &state);
if (res == (size_t) -1 || res == (size_t) -2) {
wch = static_cast<unsigned char>(line[si]);
wch_len = 1;
} else if (res == 0) {
wch = L'\0';
wch_len = 1;
} else {
wch_len = static_cast<int>(res);
}
if (wch == L'\t') {
constexpr std::size_t tab_width = 8;
const std::size_t next_tab = tab_width - (rc % tab_width);
rc += next_tab;
} else {
int w = wcwidth(wch);
if (w < 0)
w = 1;
rc += static_cast<std::size_t>(w);
}
si += static_cast<std::size_t>(wch_len);
}
vsel_line_rx = rc;
}
src_i = 0;
render_col = 0;
// Syntax highlighting: fetch per-line spans (sanitized copy) // Syntax highlighting: fetch per-line spans (sanitized copy)
std::vector<kte::HighlightSpan> sane_spans; std::vector<kte::HighlightSpan> sane_spans;
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()-> if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
@@ -156,27 +225,21 @@ TerminalRenderer::Draw(Editor &ed)
} }
return kte::TokenKind::Default; return kte::TokenKind::Default;
}; };
auto apply_token_attr = [&](kte::TokenKind k) { auto token_attr = [&](kte::TokenKind k) -> attr_t {
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
attrset(A_NORMAL);
switch (k) { switch (k) {
case kte::TokenKind::Keyword: case kte::TokenKind::Keyword:
case kte::TokenKind::Type: case kte::TokenKind::Type:
case kte::TokenKind::Constant: case kte::TokenKind::Constant:
case kte::TokenKind::Function: case kte::TokenKind::Function:
attron(A_BOLD); return A_BOLD;
break;
case kte::TokenKind::Comment: case kte::TokenKind::Comment:
attron(A_DIM); return A_DIM;
break;
case kte::TokenKind::String: case kte::TokenKind::String:
case kte::TokenKind::Char: case kte::TokenKind::Char:
case kte::TokenKind::Number: case kte::TokenKind::Number:
// standout a bit using A_UNDERLINE if available return A_UNDERLINE;
attron(A_UNDERLINE);
break;
default: default:
break; return 0;
} }
}; };
while (written < cols) { while (written < cols) {
@@ -218,36 +281,27 @@ 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_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
&& src_i < cur_mend; &&
// Toggle highlight attributes src_i < cur_mend;
int attr = 0; attr_t a = A_NORMAL;
if (in_hl) a |= token_attr(token_at(src_i));
attr |= A_STANDOUT; if (in_sel) {
if (in_cur) a |= A_REVERSE;
attr |= A_BOLD; } else {
if ((attr & A_STANDOUT) && !hl_on) { if (in_hl)
attron(A_STANDOUT); a |= A_STANDOUT;
hl_on = true; if (in_cur)
} a |= A_BOLD;
if (!(attr & A_STANDOUT) && hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if ((attr & A_BOLD) && !cur_on) {
attron(A_BOLD);
cur_on = true;
}
if (!(attr & A_BOLD) && cur_on) {
attroff(A_BOLD);
cur_on = false;
}
// Apply syntax attribute only if not in search highlight
if (!in_hl) {
apply_token_attr(token_at(src_i));
} }
attrset(a);
addch(' '); addch(' ');
++written; ++written;
++render_col; ++render_col;
@@ -281,34 +335,36 @@ TerminalRenderer::Draw(Editor &ed)
break; break;
} }
bool in_mark = from_src && is_src_in_mark_sel(li, src_i);
bool in_vsel = false;
if (vsel_on_line) {
if (from_src) {
in_vsel = !vsel_spot_is_eol && src_i == vsel_spot_src;
} else {
in_vsel = vsel_spot_is_eol && render_col == vsel_line_rx;
}
}
bool in_sel = in_mark || in_vsel;
bool in_hl = search_mode && from_src && is_src_in_hl(src_i); bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
bool in_cur = bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i < src_i < cur_mend;
cur_mend; attr_t a = A_NORMAL;
if (in_hl && !hl_on) { if (from_src)
attron(A_STANDOUT); a |= token_attr(token_at(src_i));
hl_on = true; if (in_sel) {
} a |= A_REVERSE;
if (!in_hl && hl_on) { } else {
attroff(A_STANDOUT); if (in_hl)
hl_on = false; a |= A_STANDOUT;
} if (in_cur)
if (in_cur && !cur_on) { a |= A_BOLD;
attron(A_BOLD);
cur_on = true;
}
if (!in_cur && cur_on) {
attroff(A_BOLD);
cur_on = false;
}
if (!in_hl && from_src) {
apply_token_attr(token_at(src_i));
} }
attrset(a);
if (from_src) { if (from_src) {
cchar_t cch; cchar_t cch;
wchar_t warr[2] = {wch, L'\0'}; wchar_t warr[2] = {wch, L'\0'};
setcchar(&cch, warr, A_NORMAL, 0, nullptr); setcchar(&cch, warr, 0, 0, nullptr);
add_wch(&cch); add_wch(&cch);
} else { } else {
addch(' '); addch(' ');
@@ -322,14 +378,6 @@ TerminalRenderer::Draw(Editor &ed)
break; break;
} }
} }
if (hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if (cur_on) {
attroff(A_BOLD);
cur_on = false;
}
attrset(A_NORMAL); attrset(A_NORMAL);
clrtoeol(); clrtoeol();
} }

View File

@@ -4,8 +4,10 @@
bool bool
TestFrontend::Init(Editor &ed) TestFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc;
(void) argv;
ed.SetDimensions(24, 80); ed.SetDimensions(24, 80);
return true; return true;
} }

View File

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

View File

@@ -15,7 +15,9 @@ 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 *child = nullptr; // next in current timeline UndoNode *parent = nullptr; // previous state; null means pre-first-edit
UndoNode *next = nullptr; // redo branch UndoNode *child = nullptr; // next in current timeline
UndoNode *next = nullptr; // redo branch
}; };

View File

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

View File

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

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);
@@ -22,7 +28,10 @@ public:
void undo(); void undo();
void redo(); // Redo the current node's active child branch.
// If `branch_index` > 0, selects that redo sibling (0-based) and makes it active.
// When current is null (pre-first-edit), branches are selected among `tree_.root` siblings.
void redo(int branch_index = 0);
void mark_saved(); void mark_saved();
@@ -32,7 +41,20 @@ public:
void UpdateBufferReference(Buffer &new_buf); void UpdateBufferReference(Buffer &new_buf);
#if defined(KTE_TESTS)
// Test-only introspection hook.
const UndoTree &TreeForTests() const
{
return tree_;
}
#endif
private: private:
enum class PendingAppendMode : std::uint8_t {
Append,
Prepend,
};
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
void free_node(UndoNode *node); void free_node(UndoNode *node);
@@ -48,6 +70,11 @@ private:
void update_dirty_flag(); void update_dirty_flag();
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
std::uint64_t active_group_id_ = 0;
std::uint64_t next_group_id_ = 1;
Buffer *buf_; 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

View File

@@ -112,7 +112,7 @@ RunStressHighlighter(unsigned seconds)
int int
main(int argc, const char *argv[]) main(int argc, char *argv[])
{ {
std::setlocale(LC_ALL, ""); std::setlocale(LC_ALL, "");
@@ -136,7 +136,7 @@ main(int argc, const char *argv[])
int opt; int opt;
int long_index = 0; int long_index = 0;
unsigned stress_seconds = 0; unsigned stress_seconds = 0;
while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "gthV", long_opts, &long_index)) != -1) { while ((opt = getopt_long(argc, argv, "gthV", long_opts, &long_index)) != -1) {
switch (opt) { switch (opt) {
case 'g': case 'g':
req_gui = true; req_gui = true;
@@ -303,7 +303,7 @@ main(int argc, const char *argv[])
} }
#endif #endif
if (!fe->Init(editor)) { if (!fe->Init(argc, argv, editor)) {
std::cerr << "kte: failed to initialize frontend" << std::endl; std::cerr << "kte: failed to initialize frontend" << std::endl;
return 1; return 1;
} }

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,49 +1,181 @@
#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) {
PieceTable pt;
// start empty
ASSERT_EQ(pt.Size(), (std::size_t)0);
ASSERT_EQ(pt.LineCount(), (std::size_t)1); // empty buffer has 1 logical line
// Insert some text with newlines static std::vector<std::size_t>
const char *t = "abc\n123\nxyz"; // last line without trailing NL LineStartsFor(const std::string &s)
pt.Insert(0, t, 11); {
ASSERT_EQ(pt.Size(), (std::size_t)11); std::vector<std::size_t> starts;
ASSERT_EQ(pt.LineCount(), (std::size_t)3); starts.push_back(0);
for (std::size_t i = 0; i < s.size(); i++) {
// Check get line if (s[i] == '\n')
ASSERT_EQ(pt.GetLine(0), std::string("abc")); starts.push_back(i + 1);
ASSERT_EQ(pt.GetLine(1), std::string("123")); }
ASSERT_EQ(pt.GetLine(2), std::string("xyz")); return starts;
// Delete middle line entirely including its trailing NL
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
pt.Delete(r.first, r.second - r.first);
ASSERT_EQ(pt.LineCount(), (std::size_t)2);
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
} }
TEST(PieceTable_LineCol_Conversions) {
PieceTable pt;
std::string s = "hello\nworld\n"; // two lines with trailing NL
pt.Insert(0, s.data(), s.size());
// Byte offsets of starts static std::string
auto off0 = pt.LineColToByteOffset(0, 0); LineContentFor(const std::string &s, std::size_t line_num)
auto off1 = pt.LineColToByteOffset(1, 0); {
auto off2 = pt.LineColToByteOffset(2, 0); // EOF auto starts = LineStartsFor(s);
ASSERT_EQ(off0, (std::size_t)0); if (starts.empty() || line_num >= starts.size())
ASSERT_EQ(off1, (std::size_t)6); // "hello\n" return std::string();
ASSERT_EQ(off2, pt.Size()); std::size_t start = starts[line_num];
std::size_t end = (line_num + 1 < starts.size()) ? starts[line_num + 1] : s.size();
auto lc0 = pt.ByteOffsetToLineCol(0); if (end > start && s[end - 1] == '\n')
auto lc1 = pt.ByteOffsetToLineCol(6); end -= 1;
ASSERT_EQ(lc0.first, (std::size_t)0); return s.substr(start, end - start);
ASSERT_EQ(lc0.second, (std::size_t)0); }
ASSERT_EQ(lc1.first, (std::size_t)1);
ASSERT_EQ(lc1.second, (std::size_t)0);
TEST (PieceTable_Insert_Delete_LineCount)
{
PieceTable pt;
// start empty
ASSERT_EQ(pt.Size(), (std::size_t) 0);
ASSERT_EQ(pt.LineCount(), (std::size_t) 1); // empty buffer has 1 logical line
// Insert some text with newlines
const char *t = "abc\n123\nxyz"; // last line without trailing NL
pt.Insert(0, t, 11);
ASSERT_EQ(pt.Size(), (std::size_t) 11);
ASSERT_EQ(pt.LineCount(), (std::size_t) 3);
// Check get line
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("123"));
ASSERT_EQ(pt.GetLine(2), std::string("xyz"));
// Delete middle line entirely including its trailing NL
auto r = pt.GetLineRange(1); // [start,end) points to start of line 1 to start of line 2
pt.Delete(r.first, r.second - r.first);
ASSERT_EQ(pt.LineCount(), (std::size_t) 2);
ASSERT_EQ(pt.GetLine(0), std::string("abc"));
ASSERT_EQ(pt.GetLine(1), std::string("xyz"));
}
TEST (PieceTable_LineCol_Conversions)
{
PieceTable pt;
std::string s = "hello\nworld\n"; // two lines with trailing NL
pt.Insert(0, s.data(), s.size());
// Byte offsets of starts
auto off0 = pt.LineColToByteOffset(0, 0);
auto off1 = pt.LineColToByteOffset(1, 0);
auto off2 = pt.LineColToByteOffset(2, 0); // EOF
ASSERT_EQ(off0, (std::size_t) 0);
ASSERT_EQ(off1, (std::size_t) 6); // "hello\n"
ASSERT_EQ(off2, pt.Size());
auto lc0 = pt.ByteOffsetToLineCol(0);
auto lc1 = pt.ByteOffsetToLineCol(6);
ASSERT_EQ(lc0.first, (std::size_t) 0);
ASSERT_EQ(lc0.second, (std::size_t) 0);
ASSERT_EQ(lc1.first, (std::size_t) 1);
ASSERT_EQ(lc1.second, (std::size_t) 0);
}
TEST (PieceTable_ReferenceModel_RandomEdits_Deterministic)
{
PieceTable pt;
std::string model;
std::mt19937 rng(0xC0FFEEu);
const std::vector<std::string> corpus = {
"a",
"b",
"c",
"xyz",
"123",
"\n",
"!\n",
"foo\nbar",
"end\n",
};
auto check_invariants = [&](const char *where) {
(void) where;
ASSERT_EQ(pt.Size(), model.size());
ASSERT_EQ(pt.GetRange(0, pt.Size()), model);
auto starts = LineStartsFor(model);
ASSERT_EQ(pt.LineCount(), starts.size());
// Spot-check a few line ranges and contents.
std::size_t last = starts.empty() ? (std::size_t) 0 : (starts.size() - 1);
std::size_t mid = (starts.size() > 2) ? (std::size_t) 1 : last;
const std::array<std::size_t, 3> probe_lines = {(std::size_t) 0, last, mid};
for (auto line: probe_lines) {
if (starts.empty())
break;
if (line >= starts.size())
continue;
std::size_t exp_start = starts[line];
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
auto r = pt.GetLineRange(line);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
}
// Round-trips for a few offsets.
const std::vector<std::size_t> probe_offsets = {
0,
model.size() / 2,
model.size(),
};
for (auto off: probe_offsets) {
auto lc = pt.ByteOffsetToLineCol(off);
auto back = pt.LineColToByteOffset(lc.first, lc.second);
ASSERT_EQ(back, off);
}
};
check_invariants("initial");
for (int step = 0; step < 250; step++) {
bool do_insert = model.empty() || ((rng() % 3u) != 0u); // bias toward insert
if (do_insert) {
const std::string &ins = corpus[rng() % corpus.size()];
std::size_t pos = model.empty() ? 0 : (rng() % (model.size() + 1));
pt.Insert(pos, ins.data(), ins.size());
model.insert(pos, ins);
} else {
std::size_t pos = rng() % model.size();
std::size_t max = std::min<std::size_t>(8, model.size() - pos);
std::size_t len = 1 + (rng() % max);
pt.Delete(pos, len);
model.erase(pos, len);
}
// Also validate GetRange on a small random window when non-empty.
if (!model.empty()) {
std::size_t off = rng() % model.size();
std::size_t max = std::min<std::size_t>(16, model.size() - off);
std::size_t len = 1 + (rng() % max);
ASSERT_EQ(pt.GetRange(off, len), model.substr(off, len));
}
check_invariants("step");
}
// Full line-by-line range verification at the end.
auto starts = LineStartsFor(model);
for (std::size_t line = 0; line < starts.size(); line++) {
std::size_t exp_start = starts[line];
std::size_t exp_end = (line + 1 < starts.size()) ? starts[line + 1] : model.size();
auto r = pt.GetLineRange(line);
ASSERT_EQ(r.first, exp_start);
ASSERT_EQ(r.second, exp_end);
ASSERT_EQ(pt.GetLine(line), LineContentFor(model, line));
}
} }

View File

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

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

940
tests/test_undo.cc Normal file
View File

@@ -0,0 +1,940 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include <cstddef>
#include <random>
#if defined(KTE_TESTS)
#include <unordered_set>
static void
validate_undo_subtree(const UndoNode *node, const UndoNode *expected_parent,
std::unordered_set<const UndoNode *> &seen)
{
ASSERT_TRUE(node != nullptr);
ASSERT_TRUE(seen.find(node) == seen.end());
seen.insert(node);
ASSERT_TRUE(node->parent == expected_parent);
// Validate each redo branch under this node.
for (const UndoNode *ch = node->child; ch != nullptr; ch = ch->next) {
validate_undo_subtree(ch, node, seen);
}
}
static void
validate_undo_tree(const UndoSystem &u)
{
const UndoTree &t = u.TreeForTests();
std::unordered_set<const UndoNode *> seen;
for (const UndoNode *root = t.root; root != nullptr; root = root->next) {
validate_undo_subtree(root, nullptr, seen);
}
// current/saved must either be null or be reachable from some root.
if (t.current)
ASSERT_TRUE(seen.find(t.current) != seen.end());
if (t.saved)
ASSERT_TRUE(seen.find(t.saved) != seen.end());
// pending is detached (not part of the committed tree).
if (t.pending) {
ASSERT_TRUE(seen.find(t.pending) == seen.end());
ASSERT_TRUE(t.pending->parent == nullptr);
ASSERT_TRUE(t.pending->child == nullptr);
ASSERT_TRUE(t.pending->next == nullptr);
}
}
#endif
TEST (Undo_InsertRun_Coalesces)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Simulate two separate "typed" insert commands without committing in between.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("h"));
u->Append('h');
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("i"));
u->Append('i');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hi"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
}
TEST (Undo_BackspaceRun_Coalesces)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed content.
b.insert_text(0, 0, std::string_view("abc"));
b.SetCursor(3, 0);
u->mark_saved();
// Simulate two backspaces: delete 'c' then 'b'.
{
const auto &rows = b.Rows();
char deleted = rows[0][2];
b.delete_text(0, 2, 1);
b.SetCursor(2, 0);
u->Begin(UndoType::Delete);
u->Append(deleted);
}
{
const auto &rows = b.Rows();
char deleted = rows[0][1];
b.delete_text(0, 1, 1);
b.SetCursor(1, 0);
u->Begin(UndoType::Delete);
u->Append(deleted);
}
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// One undo should restore both characters.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abc"));
}
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// New edit after undo creates a new branch; the old redo should remain as an alternate branch.
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
// No further redo from the tip.
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
// Undo back to the branch point and redo the original branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
}
TEST (Undo_DirtyFlag_MarkSavedAndUndoRedo)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
u->mark_saved();
ASSERT_TRUE(!b.Dirty());
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 0);
u->commit();
ASSERT_TRUE(b.Dirty());
u->undo();
ASSERT_TRUE(!b.Dirty());
u->redo();
ASSERT_TRUE(b.Dirty());
}
TEST (Undo_Newline_UndoRedo_SplitJoin)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed a single line and split it.
b.insert_text(0, 0, std::string_view("hello"));
b.SetCursor(2, 0); // split after "he"
u->Begin(UndoType::Newline);
b.split_line(0, 2);
u->commit();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("he"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("llo"));
// Undo should join the lines back.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("hello"));
// Redo should split again at the same point.
u->redo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("he"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("llo"));
}
TEST (Undo_DeleteKeyRun_Coalesces)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed content: delete-key semantics keep cursor at the same column.
b.insert_text(0, 0, std::string_view("abcd"));
b.SetCursor(1, 0); // on 'b'
// Delete 'b'
{
const auto &rows = b.Rows();
char deleted = rows[0][1];
u->Begin(UndoType::Delete);
b.delete_text(0, 1, 1);
u->Append(deleted);
b.SetCursor(1, 0);
}
// Delete next char (was 'c', now at same col=1)
{
const auto &rows = b.Rows();
char deleted = rows[0][1];
u->Begin(UndoType::Delete);
b.delete_text(0, 1, 1);
u->Append(deleted);
b.SetCursor(1, 0);
}
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
// One undo should restore both deleted characters.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("abcd"));
}
TEST (Undo_UndoPastFirstEdit_RedoFromPreFirstEdit)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Commit two separate insert edits.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Undo twice: we should reach the pre-first-edit state.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
// Redo twice should restore both edits.
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
}
TEST (Undo_NewEditFromPreFirstEdit_PreservesOldHistoryAsAlternateRootBranch)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build up two edits.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Undo past first edit so current becomes null.
u->undo();
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
// Commit a new edit from the pre-first-edit state.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
// From the tip, no further redo.
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
// Undo back to pre-first-edit and select the older root branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
}
TEST (Undo_MultiLineDelete_ConsumesNewline_UndoRestores)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Create two lines. PieceTable treats '\n' between logical lines.
b.insert_text(0, 0, std::string_view("ab\ncd"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("cd"));
// Delete spanning the newline: delete "b\n" starting at (0,1).
b.SetCursor(1, 0);
u->Begin(UndoType::Delete);
b.delete_text(0, 1, 2);
u->Append(std::string_view("b\n"));
u->commit();
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("acd"));
// Undo should restore exact original text/line structure.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("cd"));
}
TEST (Undo_DeleteIndent_UndoRestoresCursorAtText)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed 3-line content with indentation on the middle line.
b.insert_text(0, 0,
std::string_view("I did a thing\n and then I edited a thing\nbut there were gaps"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
// Cursor at start of the line (before spaces), then C-d C-d deletes two spaces.
b.SetCursor(0, 1);
for (int i = 0; i < 2; ++i) {
const auto &rows = b.Rows();
char deleted = rows[1][0];
ASSERT_EQ(deleted, ' ');
u->Begin(UndoType::Delete);
b.delete_text(1, 0, 1);
u->Append(deleted);
b.SetCursor(0, 1); // delete-key keeps col the same
}
u->commit();
ASSERT_EQ(std::string(b.Rows()[1]), std::string("and then I edited a thing"));
ASSERT_EQ(b.Cury(), (std::size_t) 1);
ASSERT_EQ(b.Curx(), (std::size_t) 0);
// Undo should restore indentation, and keep cursor on the text (at 'a'), not at EOL.
u->undo();
ASSERT_EQ(std::string(b.Rows()[1]), std::string(" and then I edited a thing"));
ASSERT_EQ(b.Cury(), (std::size_t) 1);
ASSERT_EQ(b.Curx(), (std::size_t) 2);
}
TEST (Undo_StructuralInvariants_BranchingAndRoots)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build history: a -> b
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Undo past first edit; now create a new root-level branch x.
u->undo();
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
// Return to the older root branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Create a normal branch under 'a'.
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
validate_undo_tree(*u);
}
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Root: a
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Branch 1: a->b
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Back to branch point.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Branch 2: a->c
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Branch 3: a->d
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("d"));
u->Append('d');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Under 'a', the sibling list should now contain 3 branches.
validate_undo_tree(*u);
// Select the 3rd sibling (branch_index=2) which should be the oldest ("b"), and make it active.
u->redo(2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Since we selected "b", redo with default should now follow "b" again.
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Select another branch by index and ensure it becomes the new default.
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
u->undo();
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ad"));
u->undo();
// Out-of-range selection should be a no-op.
u->redo(99);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
validate_undo_tree(*u);
}
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build A->B.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
ASSERT_EQ(b.Cury(), (std::size_t) 0);
ASSERT_EQ(b.Curx(), (std::size_t) 1);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
// Undo to A.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
ASSERT_EQ(b.Curx(), (std::size_t) 1);
// Create sibling branch A->C.
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
// Back to A.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
ASSERT_EQ(b.Curx(), (std::size_t) 1);
// Redo into B as the alternate branch (older sibling), and confirm cursor is consistent.
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
// Both branches remain reachable: undo to A, redo defaults to B (head reordered).
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// And the other branch C should still be selectable.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
ASSERT_EQ(b.Curx(), (std::size_t) 2);
// After selecting C, default redo from A should now follow C.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
validate_undo_tree(*u);
}
TEST (Undo_Randomized_Deterministic_EditUndoRedoBranchSelect)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
std::mt19937 rng(0xC0FFEEu);
std::uniform_int_distribution<int> op(0, 99);
std::uniform_int_distribution<int> ch(0, 25);
const int steps = 300;
const int max_len = 40;
const int max_branch = 4;
for (int i = 0; i < steps; ++i) {
ASSERT_TRUE(!b.Rows().empty());
ASSERT_EQ(b.Cury(), (std::size_t) 0);
ASSERT_EQ(b.Rows().size(), (std::size_t) 1);
ASSERT_TRUE(b.Curx() <= b.Rows()[0].size());
validate_undo_tree(*u);
int r = op(rng);
std::string cur = std::string(b.Rows()[0]);
int len = static_cast<int>(cur.size());
if (r < 40 && len < max_len) {
// Insert one char at end as a standalone committed node.
char c = static_cast<char>('a' + ch(rng));
b.SetCursor(static_cast<std::size_t>(len), 0);
u->Begin(UndoType::Insert);
b.insert_text(0, len, std::string_view(&c, 1));
u->Append(c);
b.SetCursor(static_cast<std::size_t>(len + 1), 0);
u->commit();
} else if (r < 60 && len > 0) {
// Backspace at end as a standalone committed node.
char deleted = cur[static_cast<std::size_t>(len - 1)];
b.delete_text(0, len - 1, 1);
b.SetCursor(static_cast<std::size_t>(len - 1), 0);
u->Begin(UndoType::Delete);
u->Append(deleted);
u->commit();
} else if (r < 80) {
// Undo then redo should round-trip to the exact same node/text/cursor when possible.
const UndoNode *before_node = u->TreeForTests().current;
const std::string before_text(std::string(b.Rows()[0]));
const std::size_t before_x = b.Curx();
if (before_node) {
u->undo();
u->redo();
ASSERT_TRUE(u->TreeForTests().current == before_node);
ASSERT_EQ(std::string(b.Rows()[0]), before_text);
ASSERT_EQ(b.Curx(), before_x);
} else {
// Nothing to undo; just exercise redo/branch-select paths.
u->redo();
}
} else if (r < 90) {
u->undo();
} else {
int idx = static_cast<int>(rng() % static_cast<std::uint32_t>(max_branch));
if ((rng() % 8u) == 0u)
idx = 99; // intentionally out of range sometimes
u->redo(idx);
}
}
validate_undo_tree(*u);
}
TEST (Undo_PendingCoalescedRun_UndoCommitsThenUndoes)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Create a coalesced insert run without an explicit commit.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// undo() should implicitly commit pending and then undo it as one step.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
validate_undo_tree(*u);
}
TEST (Undo_PendingRunAtBranchPoint_UndoThenBranchSelectionStillWorks)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build a->b.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
// Undo to the branch point.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Start a pending insert "c" at the branch point, but don't commit.
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
// Undo should seal the pending "c" as a new branch, then undo it, leaving us at "a".
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// The active redo should now be "c".
u->redo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
// Select the older "b" branch.
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
validate_undo_tree(*u);
}
TEST (Undo_SavedNodeOnOtherBranch_DirtyClearsWhenReturning)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Build a->b and mark saved at the tip.
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("a"));
u->Append('a');
b.SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("b"));
u->Append('b');
b.SetCursor(2, 0);
u->commit();
u->mark_saved();
ASSERT_TRUE(!b.Dirty());
// Move to a different branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("c"));
u->Append('c');
b.SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ac"));
ASSERT_TRUE(b.Dirty());
// Return to the saved node by selecting the older branch.
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("a"));
u->redo(1);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("ab"));
ASSERT_TRUE(!b.Dirty());
validate_undo_tree(*u);
}
TEST (Undo_Clear_AfterSaved_ResetsStateSafely)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.SetCursor(0, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 0, std::string_view("x"));
u->Append('x');
b.SetCursor(1, 0);
u->commit();
u->mark_saved();
ASSERT_TRUE(!b.Dirty());
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("y"));
u->Append('y');
b.SetCursor(2, 0);
u->commit();
ASSERT_TRUE(b.Dirty());
u->clear();
ASSERT_TRUE(!b.Dirty());
// clear() resets undo history, but does not mutate buffer contents.
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
validate_undo_tree(*u);
}
TEST (Undo_Command_UndoHonorsRepeatCount)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
UndoSystem *u = buf->Undo();
ASSERT_TRUE(u != nullptr);
// Create two committed steps using the undo system directly.
buf->SetCursor(0, 0);
u->Begin(UndoType::Insert);
buf->insert_text(0, 0, std::string_view("a"));
u->Append('a');
buf->SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
buf->insert_text(0, 1, std::string_view("b"));
u->Append('b');
buf->SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
// Undo twice via command repeat count.
ed.SetUniversalArg(1, 2);
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string(""));
validate_undo_tree(*u);
}
TEST (Undo_Command_RedoCountSelectsBranch)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
UndoSystem *u = buf->Undo();
ASSERT_TRUE(u != nullptr);
// Build a->b.
buf->SetCursor(0, 0);
u->Begin(UndoType::Insert);
buf->insert_text(0, 0, std::string_view("a"));
u->Append('a');
buf->SetCursor(1, 0);
u->commit();
u->Begin(UndoType::Insert);
buf->insert_text(0, 1, std::string_view("b"));
u->Append('b');
buf->SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
// Undo to the branch point and create a sibling branch "c".
u->undo();
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("a"));
u->Begin(UndoType::Insert);
buf->insert_text(0, 1, std::string_view("c"));
u->Append('c');
buf->SetCursor(2, 0);
u->commit();
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ac"));
// Back to branch point.
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("a"));
// Command redo with count=2 should select branch_index=1 (the older "b" branch).
ed.SetUniversalArg(1, 2);
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
// After selection, "b" should be the default redo from the branch point.
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("a"));
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("ab"));
validate_undo_tree(*u);
}

View File

@@ -0,0 +1,332 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include <string>
static std::string
dump_buf(const Buffer &buf)
{
std::string out;
for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r);
out.push_back('\n');
}
return out;
}
static std::string
dump_bytes(const std::string &s)
{
static const char *hex = "0123456789abcdef";
std::string out;
for (unsigned char c: s) {
out.push_back(hex[(c >> 4) & 0xF]);
out.push_back(hex[c & 0xF]);
out.push_back(' ');
}
return out;
}
TEST (VisualLineMode_BroadcastInsert)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
b.SetCursor(1, 0); // fo|o
ed.AddBuffer(std::move(b));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
// Enter visual-line mode and extend selection to 3 lines
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
// Broadcast insert to all selected lines
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
const std::string got = dump_buf(*ed.CurrentBuffer());
// Note: buffers that end with a trailing '\n' have an extra empty row.
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
TEST (VisualLineMode_BroadcastInsert_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
b.SetCursor(1, 0); // fo|o
ed.AddBuffer(std::move(b));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
// Broadcast insert to all selected lines.
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
ASSERT_TRUE(got == exp);
}
// Undo should restore all affected lines in a single step.
ASSERT_TRUE(Execute(ed, std::string("undo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "foo\nfoo\nfoo\n\n";
ASSERT_TRUE(got == exp);
}
// Redo should re-apply the whole insert.
ASSERT_TRUE(Execute(ed, std::string("redo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "fXoo\nfXoo\nfXoo\n\n";
ASSERT_TRUE(got == exp);
}
}
TEST (VisualLineMode_BroadcastBackspace)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "abcd\nabcd\nabcd\n");
b.SetCursor(2, 0); // ab|cd
ed.AddBuffer(std::move(b));
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
ASSERT_TRUE(Execute(ed, std::string("backspace")));
const std::string got = dump_buf(*ed.CurrentBuffer());
// Note: buffers that end with a trailing '\n' have an extra empty row.
const std::string exp = "acd\nacd\nacd\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
TEST (VisualLineMode_BroadcastBackspace_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "abcd\nabcd\nabcd\n");
b.SetCursor(2, 0); // ab|cd
ed.AddBuffer(std::move(b));
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
ASSERT_TRUE(Execute(ed, std::string("backspace")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "acd\nacd\nacd\n\n";
ASSERT_TRUE(got == exp);
}
// Undo should restore all affected lines.
ASSERT_TRUE(Execute(ed, std::string("undo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "abcd\nabcd\nabcd\n\n";
ASSERT_TRUE(got == exp);
}
// Redo should re-apply.
ASSERT_TRUE(Execute(ed, std::string("redo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "acd\nacd\nacd\n\n";
ASSERT_TRUE(got == exp);
}
}
TEST (VisualLineMode_CancelWithCtrlG)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "foo\nfoo\nfoo\n");
b.SetCursor(1, 0);
ed.AddBuffer(std::move(b));
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
// C-g is mapped to "refresh" and should cancel visual-line mode.
ASSERT_TRUE(Execute(ed, std::string("refresh")));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_TRUE(!ed.CurrentBuffer()->VisualLineActive());
// After cancel, edits should only affect the primary cursor line.
ASSERT_TRUE(Execute(ed, std::string("insert"), std::string("X")));
const std::string got = dump_buf(*ed.CurrentBuffer());
// Cursor is still on the last line we moved to (down, down).
const std::string exp = "foo\nfoo\nfXoo\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
TEST (Yank_ClearsMarkAndVisualLine)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "foo\nbar\n");
b.SetCursor(1, 0);
ed.AddBuffer(std::move(b));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
Buffer *buf = ed.CurrentBuffer();
// Seed mark + visual-line highlighting.
buf->SetMark(buf->Curx(), buf->Cury());
ASSERT_TRUE(buf->MarkSet());
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 1));
ASSERT_TRUE(buf->VisualLineActive());
// Yank should clear mark and any highlighting.
ed.KillRingClear();
ed.KillRingPush("X");
ASSERT_TRUE(Execute(ed, std::string("yank")));
ASSERT_TRUE(!buf->MarkSet());
ASSERT_TRUE(!buf->VisualLineActive());
}
TEST (VisualLineMode_Yank_BroadcastsToBOL_AndUndo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
b.insert_text(0, 0, "aa\nbb\ncc\n");
b.SetCursor(1, 0); // a|a
ed.AddBuffer(std::move(b));
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
// Enter visual-line mode and extend selection to 3 lines.
ASSERT_TRUE(Execute(ed, std::string("visual-line-toggle")));
ASSERT_TRUE(Execute(ed, std::string("down"), std::string(), 2));
ASSERT_TRUE(ed.CurrentBuffer()->VisualLineActive());
ed.KillRingClear();
ed.KillRingPush("X");
// Yank in visual-line mode should paste at BOL on every affected line.
ASSERT_TRUE(Execute(ed, std::string("yank")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
// Note: buffers that end with a trailing '\n' have an extra empty row.
const std::string exp = "Xaa\nXbb\nXcc\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
// Undo should restore all affected lines in a single step.
ASSERT_TRUE(Execute(ed, std::string("undo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "aa\nbb\ncc\n\n";
if (got != exp) {
std::cerr << "Expected (len=" << exp.size() << ") bytes: " << dump_bytes(exp) << "\n";
std::cerr << "Got (len=" << got.size() << ") bytes: " << dump_bytes(got) << "\n";
}
ASSERT_TRUE(got == exp);
}
// Redo should re-apply the whole yank.
ASSERT_TRUE(Execute(ed, std::string("redo")));
{
const std::string got = dump_buf(*ed.CurrentBuffer());
const std::string exp = "Xaa\nXbb\nXcc\n\n";
ASSERT_TRUE(got == exp);
}
}
TEST (VisualLineMode_Highlight_IsPerLineCursorSpot)
{
Buffer b;
// Note: buffers that end with a trailing '\n' have an extra empty row.
b.insert_text(0, 0, "abcd\nx\nhi\n");
// Place primary cursor on line 0 at column 3 (abc|d).
b.SetCursor(3, 0);
// Select lines 0..2 in visual-line mode.
b.VisualLineStart();
b.VisualLineSetActiveY(2);
ASSERT_TRUE(b.VisualLineActive());
ASSERT_TRUE(b.VisualLineStartY() == 0);
ASSERT_TRUE(b.VisualLineEndY() == 2);
// Line 0: "abcd" (len=4) => spot is 3
ASSERT_TRUE(b.VisualLineSpotSelected(0, 3));
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 0));
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 2));
ASSERT_TRUE(!b.VisualLineSpotSelected(0, 4));
// Line 1: "x" (len=1) => spot clamps to EOL (1)
ASSERT_TRUE(b.VisualLineSpotSelected(1, 1));
ASSERT_TRUE(!b.VisualLineSpotSelected(1, 0));
// Line 2: "hi" (len=2) => spot clamps to EOL (2)
ASSERT_TRUE(b.VisualLineSpotSelected(2, 2));
ASSERT_TRUE(!b.VisualLineSpotSelected(2, 0));
// Outside the selected line range should never be highlighted.
ASSERT_TRUE(!b.VisualLineSpotSelected(3, 0));
}