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.
This commit is contained in:
186
UndoSystem.cc
186
UndoSystem.cc
@@ -11,66 +11,216 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
||||
void
|
||||
UndoSystem::Begin(UndoType type)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
(void) type;
|
||||
if (!buf_)
|
||||
return;
|
||||
const int row = static_cast<int>(buf_->Cury());
|
||||
const int col = static_cast<int>(buf_->Curx());
|
||||
|
||||
// Some operations should always be standalone undo steps.
|
||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow);
|
||||
if (always_standalone) {
|
||||
commit();
|
||||
}
|
||||
|
||||
if (tree_.pending) {
|
||||
if (tree_.pending->type == type) {
|
||||
// Typed-run coalescing rules.
|
||||
switch (type) {
|
||||
case UndoType::Insert:
|
||||
case UndoType::Paste: {
|
||||
// Cursor must be at the end of the pending insert.
|
||||
if (tree_.pending->row == row
|
||||
&& col == tree_.pending->col + static_cast<int>(tree_.pending->text.size())) {
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case UndoType::Delete: {
|
||||
if (tree_.pending->row == row) {
|
||||
// Two common delete shapes:
|
||||
// 1) backspace-run: cursor moves left each time (so new col is pending.col - 1)
|
||||
// 2) delete-run: cursor stays, always deleting at the same col
|
||||
if (col == tree_.pending->col) {
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
return;
|
||||
}
|
||||
if (col + 1 == tree_.pending->col) {
|
||||
// Extend a backspace run to the left; update the start column now.
|
||||
tree_.pending->col = col;
|
||||
pending_mode_ = PendingAppendMode::Prepend;
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case UndoType::Newline:
|
||||
case UndoType::DeleteRow:
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Can't coalesce: seal the previous pending step.
|
||||
commit();
|
||||
}
|
||||
|
||||
// Start a new pending node.
|
||||
tree_.pending = new UndoNode{};
|
||||
tree_.pending->type = type;
|
||||
tree_.pending->row = row;
|
||||
tree_.pending->col = col;
|
||||
tree_.pending->text.clear();
|
||||
tree_.pending->child = nullptr;
|
||||
tree_.pending->next = nullptr;
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::Append(char ch)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
(void) ch;
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
||||
} else {
|
||||
tree_.pending->text.push_back(ch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::Append(std::string_view text)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
(void) text;
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
if (text.empty())
|
||||
return;
|
||||
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||
tree_.pending->text.insert(0, text.data(), text.size());
|
||||
} else {
|
||||
tree_.pending->text.append(text.data(), text.size());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::commit()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
|
||||
// Drop empty text batches for text-based operations.
|
||||
if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
|
||||
|| tree_.pending->type == UndoType::Paste)
|
||||
&& tree_.pending->text.empty()) {
|
||||
delete tree_.pending;
|
||||
tree_.pending = nullptr;
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
return;
|
||||
}
|
||||
|
||||
// Linear semantics: if we are not at the tip, discard redo.
|
||||
if (tree_.current && tree_.current->child) {
|
||||
// Prevent dangling `saved` pointer if it sits in the discarded redo chain.
|
||||
if (tree_.saved && is_descendant(tree_.current->child, tree_.saved)) {
|
||||
tree_.saved = nullptr;
|
||||
}
|
||||
free_branch(tree_.current->child);
|
||||
tree_.current->child = nullptr;
|
||||
}
|
||||
|
||||
if (!tree_.root) {
|
||||
tree_.root = tree_.pending;
|
||||
tree_.current = tree_.pending;
|
||||
} else if (!tree_.current) {
|
||||
// We are at the "pre-first-edit" state. Attach as the new root child.
|
||||
// For v1 linear history, this means starting the chain anew.
|
||||
// The existing root represents edits from the past; attach the new node as the new root.
|
||||
// (This situation happens after undoing past the first node.)
|
||||
if (tree_.saved && is_descendant(tree_.root, tree_.saved)) {
|
||||
// ok
|
||||
}
|
||||
// Discard the old root chain because it is redo from the pre-edit state.
|
||||
if (tree_.saved && is_descendant(tree_.root, tree_.saved)) {
|
||||
tree_.saved = nullptr;
|
||||
}
|
||||
free_node(tree_.root);
|
||||
tree_.root = tree_.pending;
|
||||
tree_.current = tree_.pending;
|
||||
} else {
|
||||
tree_.current->child = tree_.pending;
|
||||
tree_.current = tree_.pending;
|
||||
}
|
||||
|
||||
tree_.pending = nullptr;
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
update_dirty_flag();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::undo()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
// Seal any in-progress typed run before undo.
|
||||
commit();
|
||||
if (!tree_.current)
|
||||
return;
|
||||
debug_log("undo");
|
||||
apply(tree_.current, -1);
|
||||
UndoNode *parent = find_parent(tree_.root, tree_.current);
|
||||
tree_.current = parent;
|
||||
update_dirty_flag();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::redo()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
commit();
|
||||
UndoNode *next = nullptr;
|
||||
if (!tree_.current) {
|
||||
next = tree_.root;
|
||||
} else {
|
||||
next = tree_.current->child;
|
||||
}
|
||||
if (!next)
|
||||
return;
|
||||
debug_log("redo");
|
||||
apply(next, +1);
|
||||
tree_.current = next;
|
||||
update_dirty_flag();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::mark_saved()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
commit();
|
||||
tree_.saved = tree_.current;
|
||||
update_dirty_flag();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::discard_pending()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
if (tree_.pending) {
|
||||
delete tree_.pending;
|
||||
tree_.pending = nullptr;
|
||||
}
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::clear()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
discard_pending();
|
||||
free_node(tree_.root);
|
||||
tree_.root = nullptr;
|
||||
tree_.current = nullptr;
|
||||
tree_.saved = nullptr;
|
||||
update_dirty_flag();
|
||||
}
|
||||
|
||||
|
||||
@@ -79,34 +229,46 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
||||
{
|
||||
if (!node)
|
||||
return;
|
||||
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
||||
// Low-level Buffer edit primitives do not move the cursor.
|
||||
switch (node->type) {
|
||||
case UndoType::Insert:
|
||||
case UndoType::Paste:
|
||||
if (direction > 0) {
|
||||
buf_->insert_text(node->row, node->col, node->text);
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
||||
static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->delete_text(node->row, node->col, node->text.size());
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::Delete:
|
||||
if (direction > 0) {
|
||||
buf_->delete_text(node->row, node->col, node->text.size());
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->insert_text(node->row, node->col, node->text);
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
||||
static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::Newline:
|
||||
if (direction > 0) {
|
||||
buf_->split_line(node->row, node->col);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||
} else {
|
||||
buf_->join_lines(node->row);
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::DeleteRow:
|
||||
if (direction > 0) {
|
||||
buf_->delete_row(node->row);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->insert_row(node->row, node->text);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user