4 Commits

Author SHA1 Message Date
11c523ad52 Bump patch version. 2026-02-26 13:27:13 -08:00
c261261e26 Initialize ErrorHandler early and ensure immediate log file creation
- Added early initialization of `ErrorHandler` in `main.cc` for robust error handling.
- Modified `ErrorHandler` to create the log file immediately, ensuring its presence in the state directory.
- Simplified conditional checks for log file operations and updated timestamp handling to use `system_clock`.
2026-02-26 13:25:57 -08:00
27dcb41857 Add ReflowUndo tests and integrate InsertRow undo support
- Added `test_reflow_undo.cc` to validate undo/redo workflows for reflow operations.
- Introduced `UndoType::InsertRow` in `UndoSystem` for tracking row insertion changes in undo history.
- Updated `UndoNode.h` and `UndoSystem.cc` to support row insertion as a standalone undo step.
- Enhanced reflow paragraph functionality to properly record undo/redo actions for both row deletion and insertion.
- Enabled legacy/extended undo tests in `test_undo.cc` for comprehensive validation.
- Updated `CMakeLists.txt` to include new test file in the build target.
2026-02-26 13:21:07 -08:00
bc3433e988 Add SmartNewline command with tests and editor integration
- Introduced `CommandId::SmartNewline` for auto-indented newlines, enhancing text editing workflows.
- Added `cmd_smart_newline` to implement indentation-aware newline logic.
- Integrated SmartNewline with keymaps, mouse/keyboard input handlers, and terminal/editor commands.
- Wrote comprehensive tests in `test_smart_newline.cc` to validate behavior for spaces, tabs, and no-indentation cases.
- Updated `Command.h` and `CMakeLists.txt` to register and build the new command.
2026-02-26 13:08:56 -08:00
14 changed files with 493 additions and 66 deletions

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.7.0") set(KTE_VERSION "1.7.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.
@@ -336,6 +336,8 @@ if (BUILD_TESTS)
tests/test_visual_line_mode.cc tests/test_visual_line_mode.cc
tests/test_benchmarks.cc tests/test_benchmarks.cc
tests/test_migration_coverage.cc tests/test_migration_coverage.cc
tests/test_smart_newline.cc
tests/test_reflow_undo.cc
# minimal engine sources required by Buffer # minimal engine sources required by Buffer
PieceTable.cc PieceTable.cc

View File

@@ -1109,7 +1109,6 @@ cmd_theme_set_by_name(const CommandContext &ctx)
static bool static bool
cmd_theme_set_by_name(CommandContext &ctx) cmd_theme_set_by_name(CommandContext &ctx)
{ {
# if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT) # if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
// Qt GUI build: schedule theme change for frontend // Qt GUI build: schedule theme change for frontend
std::string name = ctx.arg; std::string name = ctx.arg;
@@ -2949,6 +2948,58 @@ cmd_newline(CommandContext &ctx)
} }
static bool
cmd_smart_newline(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
if (buf->IsReadOnly()) {
ctx.editor.SetStatus("Read-only buffer");
return true;
}
// Smart newline behavior: add a newline with the same indentation as the current line.
// Find indentation of current line
std::size_t y = buf->Cury();
std::string line = buf->GetLineString(y);
std::string indent;
for (char c: line) {
if (c == ' ' || c == '\t') {
indent += c;
} else {
break;
}
}
// Perform standard newline first
if (!cmd_newline(ctx)) {
return false;
}
// Now insert the indentation at the new cursor position
if (!indent.empty()) {
std::size_t new_y = buf->Cury();
std::size_t new_x = buf->Curx();
buf->insert_text(static_cast<int>(new_y), static_cast<int>(new_x), indent);
buf->SetCursor(new_x + indent.size(), new_y);
buf->SetDirty(true);
if (auto *u = buf->Undo()) {
u->Begin(UndoType::Insert);
u->Append(indent);
u->commit();
}
}
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool static bool
cmd_backspace(CommandContext &ctx) cmd_backspace(CommandContext &ctx)
{ {
@@ -4624,7 +4675,14 @@ cmd_reflow_paragraph(CommandContext &ctx)
new_lines.push_back(""); new_lines.push_back("");
// Replace paragraph lines via PieceTable-backed operations // Replace paragraph lines via PieceTable-backed operations
UndoSystem *u = buf->Undo();
for (std::size_t i = para_end; i + 1 > para_start; --i) { for (std::size_t i = para_end; i + 1 > para_start; --i) {
if (u) {
buf->SetCursor(0, i);
u->Begin(UndoType::DeleteRow);
u->Append(static_cast<std::string>(buf->Rows()[i]));
u->commit();
}
buf->delete_row(static_cast<int>(i)); buf->delete_row(static_cast<int>(i));
if (i == 0) if (i == 0)
break; // prevent wrap on size_t break; // prevent wrap on size_t
@@ -4633,6 +4691,12 @@ cmd_reflow_paragraph(CommandContext &ctx)
std::size_t insert_y = para_start; std::size_t insert_y = para_start;
for (const auto &ln: new_lines) { for (const auto &ln: new_lines) {
buf->insert_row(static_cast<int>(insert_y), std::string_view(ln)); buf->insert_row(static_cast<int>(insert_y), std::string_view(ln));
if (u) {
buf->SetCursor(0, insert_y);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view(ln));
u->commit();
}
insert_y += 1; insert_y += 1;
} }
@@ -4806,6 +4870,9 @@ InstallDefaultCommands()
CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text, false, true CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text, false, true
}); });
CommandRegistry::Register({CommandId::Newline, "newline", "Insert newline at cursor", cmd_newline}); CommandRegistry::Register({CommandId::Newline, "newline", "Insert newline at cursor", cmd_newline});
CommandRegistry::Register({
CommandId::SmartNewline, "smart-newline", "Insert newline with auto-indent", cmd_smart_newline
});
CommandRegistry::Register({CommandId::Backspace, "backspace", "Delete char before cursor", cmd_backspace}); CommandRegistry::Register({CommandId::Backspace, "backspace", "Delete char before cursor", cmd_backspace});
CommandRegistry::Register({CommandId::DeleteChar, "delete-char", "Delete char at cursor", cmd_delete_char}); CommandRegistry::Register({CommandId::DeleteChar, "delete-char", "Delete char at cursor", cmd_delete_char});
CommandRegistry::Register({CommandId::KillToEOL, "kill-to-eol", "Delete to end of line", cmd_kill_to_eol}); CommandRegistry::Register({CommandId::KillToEOL, "kill-to-eol", "Delete to end of line", cmd_kill_to_eol});

View File

@@ -38,6 +38,7 @@ enum class CommandId {
// Editing // Editing
InsertText, // arg: text to insert at cursor (UTF-8, no newlines) InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
Newline, // insert a newline at cursor Newline, // insert a newline at cursor
SmartNewline, // insert a newline with auto-indent (Shift-Enter)
Backspace, // delete char before cursor (may join lines) Backspace, // delete char before cursor (may join lines)
DeleteChar, // delete char at cursor (may join lines) DeleteChar, // delete char at cursor (may join lines)
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline KillToEOL, // delete from cursor to end of line; if at EOL, delete newline

View File

@@ -20,6 +20,8 @@ ErrorHandler::ErrorHandler()
fs::create_directories(log_dir); fs::create_directories(log_dir);
} }
log_file_path_ = (log_dir / "error.log").string(); log_file_path_ = (log_dir / "error.log").string();
// Create the log file immediately so it exists in the state directory
ensure_log_file();
} catch (...) { } catch (...) {
// If we can't create the directory, disable file logging // If we can't create the directory, disable file logging
file_logging_enabled_ = false; file_logging_enabled_ = false;
@@ -34,11 +36,7 @@ ErrorHandler::ErrorHandler()
ErrorHandler::~ErrorHandler() ErrorHandler::~ErrorHandler()
{ {
std::lock_guard<std::mutex> lg(mtx_); std::lock_guard<std::mutex> lg(mtx_);
if (log_file_ &&log_file_ if (log_file_ && log_file_->is_open()) {
->
is_open()
)
{
log_file_->flush(); log_file_->flush();
log_file_->close(); log_file_->close();
} }
@@ -249,10 +247,7 @@ void
ErrorHandler::ensure_log_file() ErrorHandler::ensure_log_file()
{ {
// Must be called with mtx_ held // Must be called with mtx_ held
if (log_file_ &&log_file_ if (log_file_ && log_file_->is_open())
->
is_open()
)
return; return;
if (log_file_path_.empty()) if (log_file_path_.empty())
@@ -313,6 +308,6 @@ std::uint64_t
ErrorHandler::now_ns() ErrorHandler::now_ns()
{ {
using namespace std::chrono; using namespace std::chrono;
return duration_cast<nanoseconds>(steady_clock::now().time_since_epoch()).count(); return duration_cast<nanoseconds>(system_clock::now().time_since_epoch()).count();
} }
} // namespace kte } // namespace kte

View File

@@ -125,7 +125,11 @@ map_key(const SDL_Keycode key,
case SDLK_KP_ENTER: case SDLK_KP_ENTER:
k_prefix = false; k_prefix = false;
k_ctrl_pending = false; k_ctrl_pending = false;
if (mod & KMOD_SHIFT) {
out = {true, CommandId::SmartNewline, "", 0};
} else {
out = {true, CommandId::Newline, "", 0}; out = {true, CommandId::Newline, "", 0};
}
return true; return true;
case SDLK_ESCAPE: case SDLK_ESCAPE:
k_prefix = false; k_prefix = false;
@@ -442,9 +446,11 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
if (ed_ &&ed_ if (ed_ &&ed_
-> ->
UArg() != 0 UArg() != 0
) { )
{
const char *txt = e.text.text; const char *txt = e.text.text;
if (txt && *txt) { if (txt && *txt) {
unsigned char c0 = static_cast<unsigned char>(txt[0]); unsigned char c0 = static_cast<unsigned char>(txt[0]);

View File

@@ -209,19 +209,35 @@ ImGuiRenderer::Draw(Editor &ed)
return {by, best_col}; return {by, best_col};
}; };
// Mouse-driven selection: set mark on press, update cursor on drag // Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
mouse_selecting = true; mouse_selecting = true;
auto [by, bx] = mouse_pos_to_buf(); auto [by, bx] = mouse_pos_to_buf();
char tmp[64]; char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx); std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
// Only set mark on double click.
// Dragging will also set the mark if not already set (handled below).
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) { if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetMark(bx, by); mbuf->SetMark(bx, by);
} }
} }
}
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
auto [by, bx] = mouse_pos_to_buf(); auto [by, bx] = mouse_pos_to_buf();
// If we are dragging (mouse moved while down), ensure mark is set to start selection
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
if (!mbuf->MarkSet()) {
// We'd need to convert click_pos to buf coords, but it's complex here.
// Setting it to where the cursor was *before* we started moving it
// in this frame is a good approximation, or just using current.
mbuf->SetMark(mbuf->Curx(), mbuf->Cury());
}
}
}
char tmp[64]; char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx); std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); Execute(ed, CommandId::MoveCursorTo, std::string(tmp));

View File

@@ -226,6 +226,10 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
case 'q': case 'q':
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph) out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
return true; return true;
case '\n':
case '\r':
out = CommandId::SmartNewline; // Shift+Enter (some terminals send this as Alt+Enter sequences)
return true;
default: default:
break; break;
} }

View File

@@ -67,13 +67,20 @@ map_key_to_command(const int ch,
if (pressed) { if (pressed) {
mouse_selecting = true; mouse_selecting = true;
Execute(*ed, CommandId::MoveCursorTo, std::string(buf)); Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
if (Buffer *b = ed->CurrentBuffer()) { // We don't set the mark on simple click anymore in ncurses either,
b->SetMark(b->Curx(), b->Cury()); // to be consistent. ncurses doesn't easily support double-click
} // or drag-threshold in a platform-independent way here,
// but we can at least only set mark on MOVED.
out.hasCommand = false; out.hasCommand = false;
return true; return true;
} }
if (mouse_selecting && moved) { if (mouse_selecting && moved) {
if (Buffer *b = ed->CurrentBuffer()) {
if (!b->MarkSet()) {
// Set mark at CURRENT cursor position (which is where we were before this move)
b->SetMark(b->Curx(), b->Cury());
}
}
Execute(*ed, CommandId::MoveCursorTo, std::string(buf)); Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
out.hasCommand = false; out.hasCommand = false;
return true; return true;

View File

@@ -9,6 +9,7 @@ enum class UndoType : std::uint8_t {
Paste, Paste,
Newline, Newline,
DeleteRow, DeleteRow,
InsertRow,
}; };
struct UndoNode { struct UndoNode {

View File

@@ -36,7 +36,8 @@ UndoSystem::Begin(UndoType type)
const int col = static_cast<int>(buf_->Curx()); const int col = static_cast<int>(buf_->Curx());
// Some operations should always be standalone undo steps. // Some operations should always be standalone undo steps.
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow); const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
UndoType::InsertRow);
if (always_standalone) { if (always_standalone) {
commit(); commit();
} }
@@ -75,6 +76,7 @@ UndoSystem::Begin(UndoType type)
} }
case UndoType::Newline: case UndoType::Newline:
case UndoType::DeleteRow: case UndoType::DeleteRow:
case UndoType::InsertRow:
break; break;
} }
} }
@@ -314,6 +316,15 @@ UndoSystem::apply(const UndoNode *node, int direction)
buf_->SetCursor(0, static_cast<std::size_t>(node->row)); buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::InsertRow:
if (direction > 0) {
buf_->insert_row(node->row, node->text);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} else {
buf_->delete_row(node->row);
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
}
break;
} }
} }
@@ -411,6 +422,8 @@ UndoSystem::type_str(UndoType t)
return "Newline"; return "Newline";
case UndoType::DeleteRow: case UndoType::DeleteRow:
return "DeleteRow"; return "DeleteRow";
case UndoType::InsertRow:
return "InsertRow";
} }
return "?"; return "?";
} }

View File

@@ -117,6 +117,9 @@ main(int argc, char *argv[])
{ {
std::setlocale(LC_ALL, ""); std::setlocale(LC_ALL, "");
// Ensure the error handler (and its log file) is initialised early.
kte::ErrorHandler::Instance();
Editor editor; Editor editor;
// CLI parsing using getopt_long // CLI parsing using getopt_long

69
tests/test_reflow_undo.cc Normal file
View File

@@ -0,0 +1,69 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include "UndoSystem.h"
#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 (ReflowUndo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
const std::string initial =
"This is a very long line that should be reflowed into multiple lines to see if undo works correctly.\n";
b.insert_text(0, 0, initial);
b.SetCursor(0, 0);
// Commit initial insertion so it's its own undo step
if (auto *u = b.Undo())
u->commit();
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
const std::string original_dump = to_string_rows(*buf);
// Reflow with small width
const int width = 20;
ASSERT_TRUE(Execute(ed, "reflow-paragraph", "", width));
const std::string reflowed_dump = to_string_rows(*buf);
ASSERT_TRUE(reflowed_dump != original_dump);
ASSERT_TRUE(buf->Rows().size() > 1);
// Undo reflow
ASSERT_TRUE(Execute(ed, "undo", "", 1));
const std::string after_undo_dump = to_string_rows(*buf);
if (after_undo_dump != original_dump) {
fprintf(stderr, "Undo failed.\nExpected:\n%s\nGot:\n%s\n", original_dump.c_str(),
after_undo_dump.c_str());
}
EXPECT_TRUE(after_undo_dump == original_dump);
// Redo reflow
ASSERT_TRUE(Execute(ed, "redo", "", 1));
const std::string after_redo_dump = to_string_rows(*buf);
EXPECT_TRUE(after_redo_dump == reflowed_dump);
}

View File

@@ -0,0 +1,79 @@
#include "Test.h"
#include "Buffer.h"
#include "Editor.h"
#include "Command.h"
#include <string>
TEST (SmartNewline_AutoIndent)
{
Editor editor;
InstallDefaultCommands();
Buffer &buf = editor.Buffers().emplace_back();
// Set up initial state: " line1"
buf.insert_text(0, 0, " line1");
buf.SetCursor(7, 0); // At end of line
// Execute SmartNewline
bool ok = Execute(editor, CommandId::SmartNewline);
ASSERT_TRUE(ok);
// Should have two lines now
ASSERT_EQ(buf.Nrows(), 2);
// Line 0 remains " line1"
ASSERT_EQ(buf.GetLineString(0), " line1");
// Line 1 should have " " (two spaces)
ASSERT_EQ(buf.GetLineString(1), " ");
// Cursor should be at (2, 1)
ASSERT_EQ(buf.Curx(), 2);
ASSERT_EQ(buf.Cury(), 1);
}
TEST (SmartNewline_TabIndent)
{
Editor editor;
InstallDefaultCommands();
Buffer &buf = editor.Buffers().emplace_back();
// Set up initial state: "\tline1"
buf.insert_text(0, 0, "\tline1");
buf.SetCursor(6, 0); // At end of line
// Execute SmartNewline
bool ok = Execute(editor, CommandId::SmartNewline);
ASSERT_TRUE(ok);
// Should have two lines now
ASSERT_EQ(buf.Nrows(), 2);
// Line 1 should have "\t"
ASSERT_EQ(buf.GetLineString(1), "\t");
// Cursor should be at (1, 1)
ASSERT_EQ(buf.Curx(), 1);
ASSERT_EQ(buf.Cury(), 1);
}
TEST (SmartNewline_NoIndent)
{
Editor editor;
InstallDefaultCommands();
Buffer &buf = editor.Buffers().emplace_back();
// Set up initial state: "line1"
buf.insert_text(0, 0, "line1");
buf.SetCursor(5, 0); // At end of line
// Execute SmartNewline
bool ok = Execute(editor, CommandId::SmartNewline);
ASSERT_TRUE(ok);
// Should have two lines now
ASSERT_EQ(buf.Nrows(), 2);
// Line 1 should be empty
ASSERT_EQ(buf.GetLineString(1), "");
// Cursor should be at (0, 1)
ASSERT_EQ(buf.Curx(), 0);
ASSERT_EQ(buf.Cury(), 1);
}

View File

@@ -368,7 +368,7 @@ TEST(Undo_RoundTrip_Lossless_RandomEdits)
// Legacy/extended undo tests follow. Keep them available for debugging, // Legacy/extended undo tests follow. Keep them available for debugging,
// but disable them by default to keep the suite focused (~10 tests). // but disable them by default to keep the suite focused (~10 tests).
#if 0 #if 1
TEST (Undo_Branching_RedoPreservedAfterNewEdit) TEST (Undo_Branching_RedoPreservedAfterNewEdit)
@@ -713,6 +713,7 @@ TEST (Undo_StructuralInvariants_BranchingAndRoots)
validate_undo_tree(*u); validate_undo_tree(*u);
} }
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists) TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
{ {
Buffer b; Buffer b;
@@ -796,7 +797,7 @@ TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
// Additional legacy tests below are useful, but kept disabled by default. // Additional legacy tests below are useful, but kept disabled by default.
#if 0 #if 1
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor) TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
{ {
@@ -1196,4 +1197,167 @@ TEST (Undo_Command_RedoCountSelectsBranch)
validate_undo_tree(*u); validate_undo_tree(*u);
} }
TEST (Undo_InsertRow_UndoDeletesRow)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed two lines so insert_row has proper newline context.
b.insert_text(0, 0, std::string_view("first\nlast"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
// Insert a row at position 1 (between first and last), then record it.
b.insert_row(1, std::string_view("second"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
ASSERT_EQ(std::string(b.Rows()[1]), std::string("second"));
b.SetCursor(0, 1);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view("second"));
u->commit();
// Undo should remove the inserted row.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("first"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("last"));
// Redo should re-insert it.
u->redo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
ASSERT_EQ(std::string(b.Rows()[1]), std::string("second"));
validate_undo_tree(*u);
}
TEST (Undo_DeleteRow_UndoRestoresRow)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.insert_text(0, 0, std::string_view("alpha\nbeta\ngamma"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
// Record a DeleteRow for row 1 ("beta").
b.SetCursor(0, 1);
u->Begin(UndoType::DeleteRow);
u->Append(static_cast<std::string>(b.Rows()[1]));
u->commit();
b.delete_row(1);
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("alpha"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("gamma"));
// Undo should restore "beta" at row 1.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
ASSERT_EQ(std::string(b.Rows()[1]), std::string("beta"));
// Redo should delete it again.
u->redo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[1]), std::string("gamma"));
validate_undo_tree(*u);
}
TEST (Undo_InsertRow_IsStandalone)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed with two lines so InsertRow has proper newline context.
b.insert_text(0, 0, std::string_view("x\nend"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
// Start a pending insert on row 0.
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("y"));
u->Append('y');
b.SetCursor(2, 0);
// InsertRow should seal the pending "y" and become its own step.
b.insert_row(1, std::string_view("row2"));
b.SetCursor(0, 1);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view("row2"));
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("row2"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
// Undo InsertRow only.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
// Undo the insert "y".
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
validate_undo_tree(*u);
}
TEST (Undo_GroupedDeleteAndInsertRows_UndoesAsUnit)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed three lines (with trailing newline so delete_row/insert_row work cleanly).
b.insert_text(0, 0, std::string_view("aaa\nbbb\nccc\n"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 4); // 3 content + 1 empty trailing
const std::string original = b.AsString();
// Group: delete content rows then insert replacements (simulates reflow).
(void) u->BeginGroup();
// Delete rows 2,1,0 in reverse order (like reflow does).
for (int i = 2; i >= 0; --i) {
b.SetCursor(0, static_cast<std::size_t>(i));
u->Begin(UndoType::DeleteRow);
u->Append(static_cast<std::string>(b.Rows()[static_cast<std::size_t>(i)]));
u->commit();
b.delete_row(i);
}
// Insert replacement rows.
b.insert_row(0, std::string_view("aaa bbb"));
b.SetCursor(0, 0);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view("aaa bbb"));
u->commit();
b.insert_row(1, std::string_view("ccc"));
b.SetCursor(0, 1);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view("ccc"));
u->commit();
u->EndGroup();
const std::string reflowed = b.AsString();
// Single undo should restore original content.
u->undo();
ASSERT_EQ(b.AsString(), original);
// Redo should restore the reflowed state.
u->redo();
ASSERT_EQ(b.AsString(), reflowed);
validate_undo_tree(*u);
}
#endif // legacy tests #endif // legacy tests