5 Commits

Author SHA1 Message Date
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
16 changed files with 1979 additions and 209 deletions

View File

@@ -370,6 +370,54 @@ 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_;
}
[[nodiscard]] std::string AsString() const;
// Syntax highlighting integration (per-buffer)
@@ -466,11 +514,14 @@ private:
std::size_t content_LineCount_() const;
std::string filename_;
bool is_file_backed_ = false;
bool dirty_ = false;
bool read_only_ = false;
bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0;
bool is_file_backed_ = false;
bool dirty_ = false;
bool read_only_ = false;
bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0;
bool visual_line_active_ = false;
std::size_t visual_line_anchor_y_ = 0;
std::size_t visual_line_active_y_ = 0;
// Per-buffer undo state
std::unique_ptr<struct UndoTree> undo_tree_;

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.5.9")
set(KTE_VERSION "1.6.0")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -301,10 +301,17 @@ if (BUILD_TESTS)
tests/test_buffer_io.cc
tests/test_piece_table.cc
tests/test_search.cc
tests/test_reflow_paragraph.cc
tests/test_undo.cc
tests/test_visual_line_mode.cc
# minimal engine sources required by Buffer
PieceTable.cc
Buffer.cc
Editor.cc
Command.cc
HelpText.cc
Swap.cc
OptimizedSearch.cc
UndoNode.cc
UndoTree.cc
@@ -312,6 +319,9 @@ if (BUILD_TESTS)
${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"
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -761,6 +761,15 @@ cmd_quit_now(CommandContext &ctx)
static bool
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 (ctx.editor.PromptActive()) {
// If also in search mode, restore state
@@ -1968,30 +1977,54 @@ cmd_insert_text(CommandContext &ctx)
return false;
}
ensure_at_least_one_line(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
std::size_t ins_y = y;
std::size_t ins_x = x; // remember insertion start for undo positioning
int repeat = ctx.count > 0 ? ctx.count : 1;
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
std::size_t cx = x;
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();
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();
for (int i = 0; i < repeat; ++i) {
buf->insert_text(static_cast<int>(yy), static_cast<int>(xx), std::string_view(ctx.arg));
xx += ctx.arg.size();
}
if (yy == y) {
cx = xx;
cy = yy;
}
}
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,
// not directly to the legacy rows_ cache. This ensures Save() persists text.
for (int i = 0; i < repeat; ++i) {
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();
}
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);
ensure_cursor_visible(ctx.editor, *buf);
return true;
@@ -2784,6 +2817,41 @@ cmd_newline(CommandContext &ctx)
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
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) {
buf->split_line(static_cast<int>(y), static_cast<int>(x));
// Move to start of next line
@@ -2858,6 +2926,35 @@ cmd_backspace(CommandContext &ctx)
std::size_t x = buf->Curx();
UndoSystem *u = buf->Undo();
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::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();
for (int i = 0; i < repeat; ++i) {
if (xx == 0)
break;
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx - 1), 1);
--xx;
}
if (yy == y)
cx = xx;
}
buf->SetDirty(true);
buf->SetCursor(cx, y);
ensure_cursor_visible(ctx.editor, *buf);
(void) u;
return true;
}
for (int i = 0; i < repeat; ++i) {
// Refresh a read-only view of lines for char capture/lengths
const auto &rows_view = buf->Rows();
@@ -2910,6 +3007,30 @@ cmd_delete_char(CommandContext &ctx)
std::size_t x = buf->Curx();
UndoSystem *u = buf->Undo();
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();
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();
for (int i = 0; i < repeat; ++i) {
if (xx >= buf->Rows()[yy].size())
break;
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx), 1);
}
}
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
(void) u;
return true;
}
for (int i = 0; i < repeat; ++i) {
const auto &rows_view = buf->Rows();
if (y >= rows_view.size())
@@ -2947,7 +3068,9 @@ cmd_undo(CommandContext &ctx)
if (auto *u = buf->Undo()) {
// Ensure pending batch is finalized so it can be undone
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
ensure_cursor_visible(ctx.editor, *buf);
ctx.editor.SetStatus("Undone");
@@ -2966,7 +3089,14 @@ cmd_redo(CommandContext &ctx)
if (auto *u = buf->Undo()) {
// Finalize any pending batch before redoing
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);
ctx.editor.SetStatus("Redone");
return true;
@@ -3091,6 +3221,10 @@ cmd_yank(CommandContext &ctx)
for (int i = 0; i < repeat; ++i) {
insert_text_at_cursor(*buf, text);
}
// 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);
// Start a new kill chain only from kill commands; yanking should break it
ctx.editor.SetKillChain(false);
@@ -3107,6 +3241,8 @@ cmd_move_file_start(CommandContext &ctx)
return false;
ensure_at_least_one_line(*buf);
buf->SetCursor(0, 0);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
@@ -3123,6 +3259,8 @@ cmd_move_file_end(CommandContext &ctx)
std::size_t y = rows.empty() ? 0 : rows.size() - 1;
std::size_t x = rows.empty() ? 0 : rows[y].size();
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
@@ -3145,6 +3283,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
cmd_jump_to_mark(CommandContext &ctx)
{
@@ -3306,6 +3456,8 @@ cmd_move_left(CommandContext &ctx)
}
}
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
@@ -3383,6 +3535,8 @@ cmd_move_right(CommandContext &ctx)
}
}
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
@@ -3423,6 +3577,8 @@ cmd_move_up(CommandContext &ctx)
if (x > rows[y].size())
x = rows[y].size();
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
@@ -3464,6 +3620,8 @@ cmd_move_down(CommandContext &ctx)
if (x > rows[y].size())
x = rows[y].size();
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
@@ -4026,6 +4184,29 @@ cmd_reflow_paragraph(CommandContext &ctx)
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) {
std::string out;
out.reserve(in.size());
@@ -4107,20 +4288,25 @@ cmd_reflow_paragraph(CommandContext &ctx)
std::vector<std::string> new_lines;
// Determine if this region looks like a list: any line starting with bullet
bool region_has_bullet = false;
// Determine if this region looks like a list: any line starting with bullet or number
bool region_has_list = false;
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]);
std::string indent;
char marker;
std::size_t 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;
}
}
if (region_has_bullet) {
if (region_has_list) {
// Parse as list items; support hanging indent continuations
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]);
@@ -4148,6 +4334,10 @@ cmd_reflow_paragraph(CommandContext &ctx)
if (is_bullet_line(ns, nindent, nmarker, nidx)) {
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)
break;
}
@@ -4155,30 +4345,65 @@ cmd_reflow_paragraph(CommandContext &ctx)
wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines);
i = j - 1; // advance
} 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
}
if (nindent.size() >= base_indent.size()) {
content += ' ';
content += ns.substr(base_indent.size());
++j;
} else {
std::string nmarker;
if (is_numbered_line(s, indent, nmarker, after_idx)) {
std::string first_prefix = indent + nmarker + " ";
std::string cont_prefix = indent + std::string(nmarker.size() + 1, ' ');
std::string content = s.substr(after_idx);
// consume continuation lines that are part of this numbered item
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
if (starts_with(ns, cont_prefix)) {
content += ' ';
content += ns.substr(cont_prefix.size());
++j;
continue;
}
// stop if next item
std::string nindent2;
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;
}
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 {
@@ -4396,6 +4621,10 @@ InstallDefaultCommands()
});
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::VisualLineModeToggle, "visual-line-toggle", "Toggle visual-line (multicursor) mode",
cmd_visual_line_mode_toggle, false, false
});
CommandRegistry::Register({
CommandId::JumpToMark, "jump-to-mark", "Jump to mark (swap mark)", cmd_jump_to_mark
});
@@ -4566,4 +4795,4 @@ Execute(Editor &ed, const std::string &name, const std::string &arg, int count)
return false;
CommandContext ctx{ed, arg, count};
return cmd->handler ? cmd->handler(ctx) : false;
}
}

View File

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

View File

@@ -150,73 +150,84 @@ ImGuiRenderer::Draw(Editor &ed)
// Cache current horizontal offset in rendered columns for click handling
const std::size_t coloffs_now = buf->Coloffs();
// Handle mouse click before rendering to avoid dependent on drawn items
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
// Mark selection state (mark -> cursor), in source coordinates
bool sel_active = false;
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
if (buf->MarkSet()) {
sel_sy = buf->MarkCury();
sel_sx = buf->MarkCurx();
sel_ey = buf->Cury();
sel_ex = buf->Curx();
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
std::swap(sel_sy, sel_ey);
std::swap(sel_sx, sel_ex);
}
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
}
// Visual-line selection: full-line highlight range
const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
static bool mouse_selecting = false;
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
ImVec2 mp = ImGui::GetIO().MousePos;
// Compute content-relative position accounting for scroll
// mp.y - child_window_pos.y gives us pixels from top of child window
// Adding scroll_y gives us pixels from top of content (buffer row 0)
// Convert mouse pos to buffer row
float content_y = (mp.y - child_window_pos.y) + scroll_y;
long by_l = static_cast<long>(content_y / row_h);
if (by_l < 0)
by_l = 0;
// Convert to buffer row
std::size_t by = static_cast<std::size_t>(by_l);
if (by >= lines.size()) {
if (!lines.empty())
by = lines.size() - 1;
else
by = 0;
}
if (by >= lines.size())
by = lines.empty() ? 0 : (lines.size() - 1);
// Compute click X position relative to left edge of child window (in pixels)
// This gives us the visual offset from the start of displayed content
// Convert mouse pos to rendered x
float visual_x = mp.x - child_window_pos.x;
if (visual_x < 0.0f)
visual_x = 0.0f;
// Convert visual pixel offset to rendered column, then add coloffs_now
// to get the absolute rendered column in the buffer
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
// Empty buffer guard: if there are no lines yet, just move to 0:0
if (lines.empty()) {
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
} else {
// Convert rendered column (clicked_rx) to source column accounting for tabs
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
// Iterate through source columns, computing rendered position, to find closest match
std::size_t rx = 0; // rendered column position
std::size_t best_col = 0;
float best_dist = std::numeric_limits<float>::infinity();
float clicked_rx_f = static_cast<float>(clicked_rx);
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
// Check current position
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
if (dist < best_dist) {
best_dist = dist;
best_col = i;
}
// Advance to next position if not at end
if (i < line_clicked.size()) {
if (line_clicked[i] == '\t') {
rx += (tabw - (rx % tabw));
} else {
rx += 1;
}
}
// Convert rendered column to source column
if (lines.empty())
return {0, 0};
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
std::size_t rx = 0;
std::size_t best_col = 0;
float best_dist = std::numeric_limits<float>::infinity();
float clicked_rx_f = static_cast<float>(clicked_rx);
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
if (dist < best_dist) {
best_dist = dist;
best_col = i;
}
if (i < line_clicked.size()) {
rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 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) {
// Capture the screen position before drawing the line
@@ -295,6 +306,51 @@ ImGuiRenderer::Draw(Editor &ed)
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
}
// Draw selection background (over search highlight; under text)
if (sel_active || vsel_active) {
bool line_has = false;
std::size_t sx = 0, ex = 0;
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
sx = 0;
ex = line.size();
line_has = ex > sx;
} else 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);
}
}
}
// Emit entire line to an expanded buffer (tabs -> spaces)
for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src];

View File

@@ -114,6 +114,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case '=':
out = CommandId::IndentRegion;
return true;
case '/':
out = CommandId::VisualLineModeToggle;
return true;
case ';':
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
return true;

View File

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

View File

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

View File

@@ -107,9 +107,44 @@ TerminalRenderer::Draw(Editor &ed)
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
bool hl_on = false;
bool cur_on = false;
int written = 0;
// Mark selection (mark -> cursor), in source coordinates
bool sel_active = false;
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
if (buf->MarkSet()) {
sel_sy = buf->MarkCury();
sel_sx = buf->MarkCurx();
sel_ey = buf->Cury();
sel_ex = buf->Curx();
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
std::swap(sel_sy, sel_ey);
std::swap(sel_sx, sel_ex);
}
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
}
// Visual-line selection: full-line selection range
const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
auto is_src_in_sel = [&](std::size_t y, std::size_t sx) -> bool {
(void) sx;
if (vsel_active) {
if (y >= vsel_sy && y <= vsel_ey)
return true;
}
if (!sel_active)
return false;
if (y < sel_sy || y > sel_ey)
return false;
if (sel_sy == sel_ey)
return sx >= sel_sx && sx < sel_ex;
if (y == sel_sy)
return sx >= sel_sx;
if (y == sel_ey)
return sx < sel_ex;
return true;
};
int written = 0;
if (li < lines.size()) {
std::string line = static_cast<std::string>(lines[li]);
src_i = 0;
@@ -156,27 +191,21 @@ TerminalRenderer::Draw(Editor &ed)
}
return kte::TokenKind::Default;
};
auto apply_token_attr = [&](kte::TokenKind k) {
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
attrset(A_NORMAL);
auto token_attr = [&](kte::TokenKind k) -> attr_t {
switch (k) {
case kte::TokenKind::Keyword:
case kte::TokenKind::Type:
case kte::TokenKind::Constant:
case kte::TokenKind::Function:
attron(A_BOLD);
break;
return A_BOLD;
case kte::TokenKind::Comment:
attron(A_DIM);
break;
return A_DIM;
case kte::TokenKind::String:
case kte::TokenKind::Char:
case kte::TokenKind::Number:
// standout a bit using A_UNDERLINE if available
attron(A_UNDERLINE);
break;
return A_UNDERLINE;
default:
break;
return 0;
}
};
while (written < cols) {
@@ -218,36 +247,23 @@ TerminalRenderer::Draw(Editor &ed)
}
// Now render visible spaces
while (next_tab > 0 && written < cols) {
bool in_sel = is_src_in_sel(li, src_i);
bool in_hl = search_mode && is_src_in_hl(src_i);
bool in_cur =
has_current && li == cur_my && src_i >= cur_mx
&& src_i < cur_mend;
// Toggle highlight attributes
int attr = 0;
if (in_hl)
attr |= A_STANDOUT;
if (in_cur)
attr |= A_BOLD;
if ((attr & A_STANDOUT) && !hl_on) {
attron(A_STANDOUT);
hl_on = true;
}
if (!(attr & A_STANDOUT) && hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if ((attr & A_BOLD) && !cur_on) {
attron(A_BOLD);
cur_on = true;
}
if (!(attr & A_BOLD) && cur_on) {
attroff(A_BOLD);
cur_on = false;
}
// Apply syntax attribute only if not in search highlight
if (!in_hl) {
apply_token_attr(token_at(src_i));
&&
src_i < cur_mend;
attr_t a = A_NORMAL;
a |= token_attr(token_at(src_i));
if (in_sel) {
a |= A_REVERSE;
} else {
if (in_hl)
a |= A_STANDOUT;
if (in_cur)
a |= A_BOLD;
}
attrset(a);
addch(' ');
++written;
++render_col;
@@ -281,34 +297,27 @@ TerminalRenderer::Draw(Editor &ed)
break;
}
bool in_sel = from_src && is_src_in_sel(li, src_i);
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
bool in_cur =
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i <
cur_mend;
if (in_hl && !hl_on) {
attron(A_STANDOUT);
hl_on = true;
}
if (!in_hl && hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if (in_cur && !cur_on) {
attron(A_BOLD);
cur_on = true;
}
if (!in_cur && cur_on) {
attroff(A_BOLD);
cur_on = false;
}
if (!in_hl && from_src) {
apply_token_attr(token_at(src_i));
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
src_i < cur_mend;
attr_t a = A_NORMAL;
if (from_src)
a |= token_attr(token_at(src_i));
if (in_sel) {
a |= A_REVERSE;
} else {
if (in_hl)
a |= A_STANDOUT;
if (in_cur)
a |= A_BOLD;
}
attrset(a);
if (from_src) {
cchar_t cch;
wchar_t warr[2] = {wch, L'\0'};
setcchar(&cch, warr, A_NORMAL, 0, nullptr);
setcchar(&cch, warr, 0, 0, nullptr);
add_wch(&cch);
} else {
addch(' ');
@@ -322,14 +331,6 @@ TerminalRenderer::Draw(Editor &ed)
break;
}
}
if (hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if (cur_on) {
attroff(A_BOLD);
cur_on = false;
}
attrset(A_NORMAL);
clrtoeol();
}

View File

@@ -16,6 +16,7 @@ struct UndoNode {
int row{};
int col{};
std::string text;
UndoNode *child = nullptr; // next in current timeline
UndoNode *next = nullptr; // redo branch
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
UndoNode *child = nullptr; // next in current timeline
UndoNode *next = nullptr; // redo branch
};

View File

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

View File

@@ -11,66 +11,225 @@ 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->parent = nullptr;
tree_.pending->child = nullptr;
tree_.pending->next = nullptr;
pending_mode_ = PendingAppendMode::Append;
}
void
UndoSystem::Append(char ch)
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
(void) ch;
if (!tree_.pending)
return;
if (pending_mode_ == PendingAppendMode::Prepend) {
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
} else {
tree_.pending->text.push_back(ch);
}
}
void
UndoSystem::Append(std::string_view text)
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
(void) text;
if (!tree_.pending)
return;
if (text.empty())
return;
if (pending_mode_ == PendingAppendMode::Prepend) {
tree_.pending->text.insert(0, text.data(), text.size());
} else {
tree_.pending->text.append(text.data(), text.size());
}
}
void
UndoSystem::commit()
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
if (!tree_.pending)
return;
// Drop empty text batches for text-based operations.
if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
|| tree_.pending->type == UndoType::Paste)
&& tree_.pending->text.empty()) {
delete tree_.pending;
tree_.pending = nullptr;
pending_mode_ = PendingAppendMode::Append;
return;
}
if (!tree_.root) {
tree_.root = tree_.pending;
tree_.pending->parent = nullptr;
tree_.current = tree_.pending;
} else if (!tree_.current) {
// We are at the "pre-first-edit" state (undo past the first node).
// In branching history, preserve the existing root chain as an alternate branch.
tree_.pending->parent = nullptr;
tree_.pending->next = tree_.root;
tree_.root = tree_.pending;
tree_.current = tree_.pending;
} else {
// Branching semantics: attach as a new redo branch under current.
// Make the new edit the active child by inserting it at the head.
tree_.pending->parent = tree_.current;
if (!tree_.current->child) {
tree_.current->child = tree_.pending;
} else {
tree_.pending->next = tree_.current->child;
tree_.current->child = tree_.pending;
}
tree_.current = tree_.pending;
}
tree_.pending = nullptr;
pending_mode_ = PendingAppendMode::Append;
update_dirty_flag();
}
void
UndoSystem::undo()
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
// Seal any in-progress typed run before undo.
commit();
if (!tree_.current)
return;
debug_log("undo");
apply(tree_.current, -1);
tree_.current = tree_.current->parent;
update_dirty_flag();
}
void
UndoSystem::redo()
UndoSystem::redo(int branch_index)
{
// STUB: Undo system incomplete - disabled until it can be properly implemented
commit();
UndoNode **head = nullptr;
if (!tree_.current) {
head = &tree_.root;
} else {
head = &tree_.current->child;
}
if (!head || !*head)
return;
if (branch_index < 0)
branch_index = 0;
// Select the Nth sibling from the branch list and make it the active head.
UndoNode *prev = nullptr;
UndoNode *sel = *head;
for (int i = 0; i < branch_index && sel; ++i) {
prev = sel;
sel = sel->next;
}
if (!sel)
return;
if (prev) {
prev->next = sel->next;
sel->next = *head;
*head = sel;
}
debug_log("redo");
apply(*head, +1);
tree_.current = *head;
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 +238,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;
}

View File

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

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

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,158 @@
#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_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_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());
}