From b108e5ffa0470e6546cf338273da06e19e8f172a Mon Sep 17 00:00:00 2001 From: AnmiTaliDev Date: Fri, 3 Jul 2026 13:46:21 +0500 Subject: [PATCH] feat: Add undo and redo --- README.md | 1 + src/buffer.c | 57 +++++++++++ src/buffer.h | 5 + src/editor.c | 245 ++++++++++++++++++++++++++++++++++++++++++---- src/editor.h | 19 ++++ src/input.c | 4 + tests/meson.build | 2 +- tests/test_undo.c | 118 ++++++++++++++++++++++ 8 files changed, 429 insertions(+), 22 deletions(-) create mode 100644 tests/test_undo.c diff --git a/README.md b/README.md index c6459b9..790a52f 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ If no file is given, the editor opens an empty, unnamed buffer. | Ctrl-Q | Quit (press again to discard unsaved changes) | | Ctrl-F | Find (Enter confirms, Ctrl-F finds next, Esc cancels) | | Ctrl-G | Jump to a line number (Enter jumps, Esc cancels) | +| Ctrl-Z / Ctrl-Y | Undo / redo | | Backspace / Delete | Delete a character or the selection | | Esc | Clear the selection | diff --git a/src/buffer.c b/src/buffer.c index cd229c9..51bd831 100644 --- a/src/buffer.c +++ b/src/buffer.c @@ -277,3 +277,60 @@ void buffer_join_lines(buffer_t *buf, size_t row) buf->num_lines -= 1; buf->dirty = 1; } + +char *buffer_extract_text(const buffer_t *buf, size_t r1, size_t c1, size_t r2, size_t c2, + size_t *out_len) +{ + if (r1 == r2) { + const line_t *line = &buf->lines[r1]; + size_t len = c2 - c1; + char *text = xmalloc(len > 0 ? len : 1); + memcpy(text, line->chars + c1, len); + *out_len = len; + return text; + } + + size_t total = (buf->lines[r1].len - c1) + 1; + for (size_t row = r1 + 1; row < r2; row++) { + total += buf->lines[row].len + 1; + } + total += c2; + + char *text = xmalloc(total > 0 ? total : 1); + size_t pos = 0; + + memcpy(text + pos, buf->lines[r1].chars + c1, buf->lines[r1].len - c1); + pos += buf->lines[r1].len - c1; + text[pos++] = '\n'; + + for (size_t row = r1 + 1; row < r2; row++) { + memcpy(text + pos, buf->lines[row].chars, buf->lines[row].len); + pos += buf->lines[row].len; + text[pos++] = '\n'; + } + + memcpy(text + pos, buf->lines[r2].chars, c2); + pos += c2; + + *out_len = pos; + return text; +} + +void buffer_insert_text(buffer_t *buf, size_t row, size_t col, const char *text, size_t len) +{ + size_t start = 0; + for (size_t i = 0; i < len; i++) { + if (text[i] != '\n') { + continue; + } + buffer_insert_bytes(buf, row, col, text + start, i - start); + col += i - start; + buffer_split_line(buf, row, col); + row += 1; + col = 0; + start = i + 1; + } + if (start < len) { + buffer_insert_bytes(buf, row, col, text + start, len - start); + } +} diff --git a/src/buffer.h b/src/buffer.h index 2c198d0..c97ec88 100644 --- a/src/buffer.h +++ b/src/buffer.h @@ -40,4 +40,9 @@ void buffer_delete_range(buffer_t *buf, size_t r1, size_t c1, size_t r2, size_t void buffer_split_line(buffer_t *buf, size_t row, size_t col); void buffer_join_lines(buffer_t *buf, size_t row); +char *buffer_extract_text(const buffer_t *buf, size_t r1, size_t c1, size_t r2, size_t c2, + size_t *out_len); + +void buffer_insert_text(buffer_t *buf, size_t row, size_t col, const char *text, size_t len); + #endif /* DAF_BUFFER_H */ diff --git a/src/editor.c b/src/editor.c index f2159d6..aa20cbe 100644 --- a/src/editor.c +++ b/src/editor.c @@ -83,6 +83,86 @@ static size_t first_non_blank(const line_t *line) return i; } +typedef struct { + size_t row; + size_t col; + char *old_text; + size_t old_len; +} edit_capture_t; + +static void free_edit_record(edit_record_t *rec) +{ + free(rec->old_text); + free(rec->new_text); +} + +static void clear_edit_stack(edit_record_t **stack, size_t *count) +{ + for (size_t i = 0; i < *count; i++) { + free_edit_record(&(*stack)[i]); + } + *count = 0; +} + +static void push_edit_record(edit_record_t **stack, size_t *count, size_t *cap, size_t row, + size_t col, const char *old_text, size_t old_len, + const char *new_text, size_t new_len) +{ + if (*count == *cap) { + size_t new_cap = *cap == 0 ? 16 : *cap * 2; + *stack = xrealloc(*stack, new_cap * sizeof(edit_record_t)); + *cap = new_cap; + } + edit_record_t *rec = &(*stack)[*count]; + rec->row = row; + rec->col = col; + rec->old_text = xmalloc(old_len > 0 ? old_len : 1); + memcpy(rec->old_text, old_text, old_len); + rec->old_len = old_len; + rec->new_text = xmalloc(new_len > 0 ? new_len : 1); + memcpy(rec->new_text, new_text, new_len); + rec->new_len = new_len; + (*count)++; +} + +static void capture_before(editor_t *ed, size_t r1, size_t c1, size_t r2, size_t c2, + edit_capture_t *cap) +{ + cap->row = r1; + cap->col = c1; + cap->old_text = buffer_extract_text(&ed->buf, r1, c1, r2, c2, &cap->old_len); +} + +static void commit_edit(editor_t *ed, edit_capture_t *cap, size_t r2_after, size_t c2_after) +{ + size_t new_len; + char *new_text = buffer_extract_text(&ed->buf, cap->row, cap->col, r2_after, c2_after, &new_len); + + if (cap->old_len != new_len || memcmp(cap->old_text, new_text, new_len) != 0) { + push_edit_record(&ed->undo_stack, &ed->undo_count, &ed->undo_cap, cap->row, cap->col, + cap->old_text, cap->old_len, new_text, new_len); + clear_edit_stack(&ed->redo_stack, &ed->redo_count); + } + + free(cap->old_text); + free(new_text); +} + +static void text_end_position(size_t row, size_t col, const char *text, size_t len, + size_t *out_row, size_t *out_col) +{ + for (size_t i = 0; i < len; i++) { + if (text[i] == '\n') { + row++; + col = 0; + } else { + col++; + } + } + *out_row = row; + *out_col = col; +} + size_t editor_cx_to_rx(const line_t *line, size_t cx) { size_t rx = 0; @@ -134,6 +214,10 @@ void editor_init(editor_t *ed, int screen_rows, int screen_cols) void editor_free(editor_t *ed) { + clear_edit_stack(&ed->undo_stack, &ed->undo_count); + clear_edit_stack(&ed->redo_stack, &ed->redo_count); + free(ed->undo_stack); + free(ed->redo_stack); buffer_free(&ed->buf); } @@ -152,6 +236,8 @@ void editor_open(editor_t *ed, const char *filename) ed->rowoff = 0; ed->coloff = 0; ed->sel.active = false; + clear_edit_stack(&ed->undo_stack, &ed->undo_count); + clear_edit_stack(&ed->redo_stack, &ed->redo_count); } void editor_update_screen_size(editor_t *ed, int screen_rows, int screen_cols) @@ -353,6 +439,19 @@ void editor_goto_line(editor_t *ed, long line_number) void editor_insert_codepoint(editor_t *ed, unsigned int codepoint) { + size_t r1; + size_t c1; + size_t r2; + size_t c2; + if (editor_has_selection(ed)) { + editor_selection_range(ed, &r1, &c1, &r2, &c2); + } else { + r1 = r2 = ed->cy; + c1 = c2 = ed->cx; + } + edit_capture_t cap; + capture_before(ed, r1, c1, r2, c2, &cap); + if (editor_has_selection(ed)) { delete_selection(ed); } @@ -361,10 +460,25 @@ void editor_insert_codepoint(editor_t *ed, unsigned int codepoint) buffer_insert_bytes(&ed->buf, ed->cy, ed->cx, bytes, n); ed->cx += n; ed->goal_rx = editor_cx_to_rx(&ed->buf.lines[ed->cy], ed->cx); + + commit_edit(ed, &cap, ed->cy, ed->cx); } void editor_insert_newline(editor_t *ed) { + size_t r1; + size_t c1; + size_t r2; + size_t c2; + if (editor_has_selection(ed)) { + editor_selection_range(ed, &r1, &c1, &r2, &c2); + } else { + r1 = r2 = ed->cy; + c1 = c2 = ed->cx; + } + edit_capture_t cap; + capture_before(ed, r1, c1, r2, c2, &cap); + if (editor_has_selection(ed)) { delete_selection(ed); } @@ -372,41 +486,76 @@ void editor_insert_newline(editor_t *ed) ed->cy += 1; ed->cx = 0; ed->goal_rx = 0; + + commit_edit(ed, &cap, ed->cy, ed->cx); } void editor_backspace(editor_t *ed) { + size_t r1; + size_t c1; + size_t r2; + size_t c2; if (editor_has_selection(ed)) { - delete_selection(ed); - return; - } - if (ed->cx > 0) { - line_t *line = &ed->buf.lines[ed->cy]; - size_t start = prev_char_start(line, ed->cx); - buffer_delete_range(&ed->buf, ed->cy, start, ed->cy, ed->cx); - ed->cx = start; + editor_selection_range(ed, &r1, &c1, &r2, &c2); + } else if (ed->cx > 0) { + r1 = r2 = ed->cy; + c1 = prev_char_start(&ed->buf.lines[ed->cy], ed->cx); + c2 = ed->cx; } else if (ed->cy > 0) { - size_t prev_len = ed->buf.lines[ed->cy - 1].len; - buffer_join_lines(&ed->buf, ed->cy - 1); - ed->cy -= 1; - ed->cx = prev_len; + r1 = ed->cy - 1; + c1 = ed->buf.lines[ed->cy - 1].len; + r2 = ed->cy; + c2 = 0; + } else { + return; } + + edit_capture_t cap; + capture_before(ed, r1, c1, r2, c2, &cap); + + buffer_delete_range(&ed->buf, r1, c1, r2, c2); + ed->cy = r1; + ed->cx = c1; + ed->sel.active = false; ed->goal_rx = editor_cx_to_rx(&ed->buf.lines[ed->cy], ed->cx); + + commit_edit(ed, &cap, ed->cy, ed->cx); } void editor_delete_forward(editor_t *ed) { + size_t r1; + size_t c1; + size_t r2; + size_t c2; if (editor_has_selection(ed)) { - delete_selection(ed); - return; - } - line_t *line = &ed->buf.lines[ed->cy]; - if (ed->cx < line->len) { - size_t end = next_char_start(line, ed->cx); - buffer_delete_range(&ed->buf, ed->cy, ed->cx, ed->cy, end); - } else if (ed->cy + 1 < ed->buf.num_lines) { - buffer_join_lines(&ed->buf, ed->cy); + editor_selection_range(ed, &r1, &c1, &r2, &c2); + } else { + line_t *line = &ed->buf.lines[ed->cy]; + if (ed->cx < line->len) { + r1 = r2 = ed->cy; + c1 = ed->cx; + c2 = next_char_start(line, ed->cx); + } else if (ed->cy + 1 < ed->buf.num_lines) { + r1 = ed->cy; + c1 = line->len; + r2 = ed->cy + 1; + c2 = 0; + } else { + return; + } } + + edit_capture_t cap; + capture_before(ed, r1, c1, r2, c2, &cap); + + buffer_delete_range(&ed->buf, r1, c1, r2, c2); + ed->cy = r1; + ed->cx = c1; + ed->sel.active = false; + + commit_edit(ed, &cap, ed->cy, ed->cx); } static void indent_line(buffer_t *buf, size_t row) @@ -452,6 +601,10 @@ void editor_handle_tab(editor_t *ed, bool shift) editor_selection_range(ed, &r1, &c1, &r2, &c2); (void)c1; (void)c2; + + edit_capture_t cap; + capture_before(ed, r1, 0, r2, ed->buf.lines[r2].len, &cap); + for (size_t row = r1; row <= r2; row++) { long delta; if (shift) { @@ -464,6 +617,8 @@ void editor_handle_tab(editor_t *ed, bool shift) shift_column_on_row(&ed->cx, ed->cy, row, delta, new_len); shift_column_on_row(&ed->sel.anchor_col, ed->sel.anchor_row, row, delta, new_len); } + + commit_edit(ed, &cap, r2, ed->buf.lines[r2].len); } else if (!shift) { editor_insert_codepoint(ed, '\t'); } @@ -545,3 +700,51 @@ void editor_scroll_view(editor_t *ed, int delta_lines) } ed->rowoff = (size_t)new_off; } + +void editor_undo(editor_t *ed) +{ + if (ed->undo_count == 0) { + return; + } + edit_record_t rec = ed->undo_stack[--ed->undo_count]; + + size_t end_row; + size_t end_col; + text_end_position(rec.row, rec.col, rec.new_text, rec.new_len, &end_row, &end_col); + buffer_delete_range(&ed->buf, rec.row, rec.col, end_row, end_col); + buffer_insert_text(&ed->buf, rec.row, rec.col, rec.old_text, rec.old_len); + + text_end_position(rec.row, rec.col, rec.old_text, rec.old_len, &end_row, &end_col); + ed->cy = end_row; + ed->cx = end_col; + ed->sel.active = false; + ed->goal_rx = editor_cx_to_rx(&ed->buf.lines[ed->cy], ed->cx); + + push_edit_record(&ed->redo_stack, &ed->redo_count, &ed->redo_cap, rec.row, rec.col, + rec.old_text, rec.old_len, rec.new_text, rec.new_len); + free_edit_record(&rec); +} + +void editor_redo(editor_t *ed) +{ + if (ed->redo_count == 0) { + return; + } + edit_record_t rec = ed->redo_stack[--ed->redo_count]; + + size_t end_row; + size_t end_col; + text_end_position(rec.row, rec.col, rec.old_text, rec.old_len, &end_row, &end_col); + buffer_delete_range(&ed->buf, rec.row, rec.col, end_row, end_col); + buffer_insert_text(&ed->buf, rec.row, rec.col, rec.new_text, rec.new_len); + + text_end_position(rec.row, rec.col, rec.new_text, rec.new_len, &end_row, &end_col); + ed->cy = end_row; + ed->cx = end_col; + ed->sel.active = false; + ed->goal_rx = editor_cx_to_rx(&ed->buf.lines[ed->cy], ed->cx); + + push_edit_record(&ed->undo_stack, &ed->undo_count, &ed->undo_cap, rec.row, rec.col, + rec.old_text, rec.old_len, rec.new_text, rec.new_len); + free_edit_record(&rec); +} diff --git a/src/editor.h b/src/editor.h index 3247483..e893232 100644 --- a/src/editor.h +++ b/src/editor.h @@ -23,6 +23,15 @@ typedef struct { size_t anchor_col; } selection_t; +typedef struct { + size_t row; + size_t col; + char *old_text; + size_t old_len; + char *new_text; + size_t new_len; +} edit_record_t; + typedef struct { buffer_t buf; @@ -46,6 +55,13 @@ typedef struct { char status_msg[256]; const char *filetype; + edit_record_t *undo_stack; + size_t undo_count; + size_t undo_cap; + edit_record_t *redo_stack; + size_t redo_count; + size_t redo_cap; + bool quit_confirm_pending; bool should_quit; } editor_t; @@ -88,4 +104,7 @@ void editor_mouse_drag(editor_t *ed, int screen_row, int screen_col); void editor_mouse_release(editor_t *ed); void editor_scroll_view(editor_t *ed, int delta_lines); +void editor_undo(editor_t *ed); +void editor_redo(editor_t *ed); + #endif /* DAF_EDITOR_H */ diff --git a/src/input.c b/src/input.c index ea89fa5..e288396 100644 --- a/src/input.c +++ b/src/input.c @@ -215,6 +215,10 @@ void input_process_key(editor_t *ed, key_event_t key) enter_find_mode(ed); } else if (key.codepoint == CTRL_KEY('g')) { enter_goto_mode(ed); + } else if (key.codepoint == CTRL_KEY('z')) { + editor_undo(ed); + } else if (key.codepoint == CTRL_KEY('y')) { + editor_redo(ed); } return; } diff --git a/tests/meson.build b/tests/meson.build index c6c546d..7e9f251 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -1,4 +1,4 @@ -test_names = ['buffer', 'search', 'encoding', 'filetype', 'editor'] +test_names = ['buffer', 'search', 'encoding', 'filetype', 'editor', 'undo'] foreach name : test_names test_exe = executable( diff --git a/tests/test_undo.c b/tests/test_undo.c new file mode 100644 index 0000000..8b12d96 --- /dev/null +++ b/tests/test_undo.c @@ -0,0 +1,118 @@ +/* SPDX-FileCopyrightText: AnmiTaliDev + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include +#include + +#include "editor.h" + +static void set_line(editor_t *ed, size_t row, const char *text) +{ + buffer_insert_bytes(&ed->buf, row, 0, text, strlen(text)); +} + +static void test_undo_redo_typing(void) +{ + editor_t ed; + editor_init(&ed, 24, 80); + + editor_insert_codepoint(&ed, 'a'); + editor_insert_codepoint(&ed, 'b'); + editor_insert_codepoint(&ed, 'c'); + assert(ed.buf.lines[0].len == 3); + assert(memcmp(ed.buf.lines[0].chars, "abc", 3) == 0); + + editor_undo(&ed); + assert(ed.buf.lines[0].len == 2); + assert(memcmp(ed.buf.lines[0].chars, "ab", 2) == 0); + assert(ed.cx == 2); + + editor_undo(&ed); + editor_undo(&ed); + assert(ed.buf.lines[0].len == 0); + + editor_undo(&ed); + assert(ed.buf.lines[0].len == 0); + + editor_redo(&ed); + editor_redo(&ed); + editor_redo(&ed); + assert(ed.buf.lines[0].len == 3); + assert(memcmp(ed.buf.lines[0].chars, "abc", 3) == 0); + assert(ed.cx == 3); + + editor_free(&ed); +} + +static void test_new_edit_clears_redo(void) +{ + editor_t ed; + editor_init(&ed, 24, 80); + + editor_insert_codepoint(&ed, 'a'); + editor_insert_codepoint(&ed, 'b'); + editor_undo(&ed); + assert(ed.redo_count == 1); + + editor_insert_codepoint(&ed, 'x'); + assert(ed.redo_count == 0); + assert(memcmp(ed.buf.lines[0].chars, "ax", 2) == 0); + + editor_free(&ed); +} + +static void test_undo_backspace_and_newline(void) +{ + editor_t ed; + editor_init(&ed, 24, 80); + set_line(&ed, 0, "hello"); + ed.cx = 5; + + editor_backspace(&ed); + assert(ed.buf.lines[0].len == 4); + editor_undo(&ed); + assert(ed.buf.lines[0].len == 5); + assert(memcmp(ed.buf.lines[0].chars, "hello", 5) == 0); + assert(ed.cx == 5); + + editor_insert_newline(&ed); + assert(ed.buf.num_lines == 2); + editor_undo(&ed); + assert(ed.buf.num_lines == 1); + assert(ed.cx == 5); + + editor_free(&ed); +} + +static void test_undo_tab_indent_selection(void) +{ + editor_t ed; + editor_init(&ed, 24, 80); + set_line(&ed, 0, "int x;"); + + ed.cy = 0; + ed.cx = 0; + editor_move_right(&ed, true); + editor_move_right(&ed, true); + editor_move_right(&ed, true); + + editor_handle_tab(&ed, false); + assert(ed.buf.lines[0].len == 7); + assert(ed.buf.lines[0].chars[0] == '\t'); + + editor_undo(&ed); + assert(ed.buf.lines[0].len == 6); + assert(memcmp(ed.buf.lines[0].chars, "int x;", 6) == 0); + + editor_free(&ed); +} + +int main(void) +{ + test_undo_redo_typing(); + test_new_edit_clears_redo(); + test_undo_backspace_and_newline(); + test_undo_tab_indent_selection(); + return 0; +}