esc
Externally Scriptable Editor
git clone git://mccd.space/esc
| Log | Files | Refs | README |
commit 39948dc11e80109d111907a157901f39e6814cb7 parent e6222930773400360585979333368915ce793502 Author: Marc Coquand <marc@coquand.email> Date: Tue, 24 Feb 2026 11:21:12 +0100 Add undo/redo history Diffstat:
| M | editor.c | | | 286 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------- |
| M | editor.h | | | 18 | ++++++++++++++++++ |
| M | fuse_ipc.c | | | 9 | +-------- |
| M | main.c | | | 8 | ++++++++ |
4 files changed, 273 insertions(+), 48 deletions(-)
diff --git a/editor.c b/editor.c
@@ -6,6 +6,109 @@
#include <stdlib.h>
#include <string.h>
+/* ---------- undo helpers (called with lock held) ----------------------- */
+
+static void push_undo_delta(Editor *ed, UndoDelta d) {
+ /* Any new edit clears the redo stack. */
+ for (int i = 0; i < ed->redo_len; i++)
+ free(ed->redo_stack[i].reinsert);
+ ed->redo_len = 0;
+
+ /* Drop oldest entry if full. */
+ if (ed->undo_len == UNDO_MAX) {
+ free(ed->undo_stack[0].reinsert);
+ memmove(ed->undo_stack, ed->undo_stack + 1,
+ (UNDO_MAX - 1) * sizeof(UndoDelta));
+ ed->undo_len--;
+ }
+ ed->undo_stack[ed->undo_len++] = d;
+}
+
+/* Raw delete: no delta recorded, no lock acquired. */
+static void delete_range_raw(Editor *ed, int start, int end) {
+ if (start == end)
+ return;
+ if (start > end) {
+ int tmp = start;
+ start = end;
+ end = tmp;
+ }
+ int len = end - start;
+ size_t new_count = 0;
+ for (size_t i = 0; i < ed->ranges_count; i++) {
+ EditorRange r = ed->ranges[i];
+ if (r.start_byte >= start && r.end_byte <= end)
+ continue;
+ if (r.start_byte >= end) {
+ r.start_byte -= len;
+ r.end_byte -= len;
+ } else {
+ if (r.start_byte > start && r.start_byte < end)
+ r.start_byte = start;
+ if (r.end_byte > start && r.end_byte < end)
+ r.end_byte = start;
+ if (start > r.start_byte && end < r.end_byte)
+ r.end_byte -= len;
+ }
+ if (r.end_byte > r.start_byte)
+ ed->ranges[new_count++] = r;
+ }
+ ed->ranges_count = new_count;
+ strbuf_delete(&ed->text, start, len);
+ ed->cursor_idx = start;
+ ed->selection_anchor = start;
+}
+
+/*
+ * Apply a delta: delete [pos, pos+delete_len) then insert reinsert at pos.
+ * Populate *rev with the inverse delta (for pushing onto the opposite stack).
+ * Called with lock held; does not record new undo entries.
+ */
+static void apply_delta(Editor *ed, const UndoDelta *d, UndoDelta *rev) {
+ /* Capture bytes that will be deleted, for the reverse. */
+ char *captured = NULL;
+ if (d->delete_len > 0) {
+ captured = malloc(d->delete_len + 1);
+ if (captured) {
+ memcpy(captured, ed->text.data + d->pos, d->delete_len);
+ captured[d->delete_len] = '\0';
+ }
+ }
+
+ rev->pos = d->pos;
+ rev->reinsert = captured;
+ rev->reinsert_len = d->delete_len;
+ rev->delete_len = d->reinsert_len;
+ rev->cursor_before = ed->cursor_idx;
+ rev->anchor_before = ed->selection_anchor;
+
+ /* Apply: delete first, then insert. */
+ if (d->delete_len > 0)
+ delete_range_raw(ed, d->pos, d->pos + (int)d->delete_len);
+
+ if (d->reinsert_len > 0) {
+ /* Shift ranges for the insertion. */
+ for (size_t i = 0; i < ed->ranges_count; i++) {
+ if (ed->ranges[i].start_byte >= d->pos) {
+ ed->ranges[i].start_byte += (int)d->reinsert_len;
+ ed->ranges[i].end_byte += (int)d->reinsert_len;
+ } else if (d->pos > ed->ranges[i].start_byte &&
+ d->pos < ed->ranges[i].end_byte) {
+ ed->ranges[i].end_byte += (int)d->reinsert_len;
+ }
+ }
+ strbuf_insert(&ed->text, d->pos, d->reinsert, d->reinsert_len);
+ }
+
+ /* Restore cursor, clamped to buffer size. */
+ int tlen = (int)ed->text.len;
+ ed->cursor_idx = d->cursor_before > tlen ? tlen : d->cursor_before;
+ ed->selection_anchor =
+ d->anchor_before > tlen ? tlen : d->anchor_before;
+}
+
+/* ---------- lifecycle -------------------------------------------------- */
+
Editor *editor_create(int char_width, float line_height) {
Editor *ed = malloc(sizeof(Editor));
if (!ed)
@@ -27,6 +130,11 @@ Editor *editor_create(int char_width, float line_height) {
ed->ranges_capacity = 0;
ed->filename = NULL;
+ ed->undo_stack = malloc(UNDO_MAX * sizeof(UndoDelta));
+ ed->undo_len = 0;
+ ed->redo_stack = malloc(UNDO_MAX * sizeof(UndoDelta));
+ ed->redo_len = 0;
+
ed->char_width = char_width;
ed->line_height = line_height;
return ed;
@@ -38,6 +146,12 @@ void editor_destroy(Editor *ed) {
strbuf_free(&ed->text);
if (ed->mutex)
SDL_DestroyMutex(ed->mutex);
+ for (int i = 0; i < ed->undo_len; i++)
+ free(ed->undo_stack[i].reinsert);
+ free(ed->undo_stack);
+ for (int i = 0; i < ed->redo_len; i++)
+ free(ed->redo_stack[i].reinsert);
+ free(ed->redo_stack);
free(ed);
}
@@ -60,14 +174,23 @@ void editor_clear_ranges(Editor *ed) {
bool editor_load_file(Editor *ed, const char *filename) {
editor_lock(ed);
- if (!strbuf_read_file(&ed->text, filename))
+ if (!strbuf_read_file(&ed->text, filename)) {
+ editor_unlock(ed);
return false;
+ }
if (ed->filename)
free(ed->filename);
ed->filename = strdup(filename);
ed->cursor_idx = 0;
ed->selection_anchor = 0;
- editor_clear_ranges(ed);
+ ed->ranges_count = 0;
+
+ for (int i = 0; i < ed->undo_len; i++)
+ free(ed->undo_stack[i].reinsert);
+ ed->undo_len = 0;
+ for (int i = 0; i < ed->redo_len; i++)
+ free(ed->redo_stack[i].reinsert);
+ ed->redo_len = 0;
editor_unlock(ed);
return true;
@@ -229,12 +352,48 @@ void editor_select_all(Editor *ed) {
void editor_insert_text(Editor *ed, const char *text, bool replace) {
editor_lock(ed);
+
+ /* Capture pre-edit cursor state for the undo delta. */
+ int cursor_before = ed->cursor_idx;
+ int anchor_before = ed->selection_anchor;
+
+ /* If replacing a selection, compute the insertion point and save the
+ * displaced bytes so the combined undo delta can restore them. */
+ int ins_pos = ed->cursor_idx;
+ char *saved = NULL;
+ size_t saved_len = 0;
+
if (replace && ed->cursor_idx != ed->selection_anchor) {
- editor_delete_range(ed, ed->cursor_idx, ed->selection_anchor);
+ int sel_start = ed->cursor_idx < ed->selection_anchor
+ ? ed->cursor_idx
+ : ed->selection_anchor;
+ int sel_end = ed->cursor_idx > ed->selection_anchor
+ ? ed->cursor_idx
+ : ed->selection_anchor;
+ ins_pos = sel_start;
+ saved_len = (size_t)(sel_end - sel_start);
+ saved = malloc(saved_len + 1);
+ if (saved) {
+ memcpy(saved, ed->text.data + sel_start, saved_len);
+ saved[saved_len] = '\0';
+ }
+ delete_range_raw(ed, sel_start, sel_end);
+ /* delete_range_raw sets cursor_idx = ins_pos. */
}
size_t input_len = strlen(text);
+ /* One combined undo delta for the whole replace-and-insert. */
+ UndoDelta d = {
+ .pos = ins_pos,
+ .reinsert = saved,
+ .reinsert_len = saved_len,
+ .delete_len = input_len,
+ .cursor_before = cursor_before,
+ .anchor_before = anchor_before,
+ };
+ push_undo_delta(ed, d);
+
// Shift ranges affected by insertion
for (size_t i = 0; i < ed->ranges_count; i++) {
if (ed->ranges[i].start_byte >= ed->cursor_idx) {
@@ -258,50 +417,34 @@ void editor_newline(Editor *ed) { editor_insert_text(ed, "\n", true); }
void editor_delete_range(Editor *ed, int start, int end) {
editor_lock(ed);
- if (start == end)
+ if (start == end) {
+ editor_unlock(ed);
return;
+ }
if (start > end) {
int tmp = start;
start = end;
end = tmp;
}
- // Shift/Delete ranges affected by deletion
- int len = end - start;
- size_t new_count = 0;
- for (size_t i = 0; i < ed->ranges_count; i++) {
- EditorRange r = ed->ranges[i];
-
- // Drop range completely contained within the deleted region
- if (r.start_byte >= start && r.end_byte <= end) {
- continue;
- }
-
- // Shift ranges entirely after the deleted section
- if (r.start_byte >= end) {
- r.start_byte -= len;
- r.end_byte -= len;
- } else {
- // Handle partial overlaps bounds
- if (r.start_byte > start && r.start_byte < end)
- r.start_byte = start;
- if (r.end_byte > start && r.end_byte < end)
- r.end_byte = start;
-
- // If deletion happened entirely inside a specific range
- if (start > r.start_byte && end < r.end_byte) {
- r.end_byte -= len;
- }
- }
-
- if (r.end_byte > r.start_byte) {
- ed->ranges[new_count++] = r;
- }
- }
- ed->ranges_count = new_count;
- strbuf_delete(&ed->text, start, len);
- ed->cursor_idx = start;
- ed->selection_anchor = start;
+ /* Capture displaced bytes and cursor state for undo. */
+ int len = end - start;
+ char *saved = malloc(len + 1);
+ if (saved) {
+ memcpy(saved, ed->text.data + start, len);
+ saved[len] = '\0';
+ }
+ UndoDelta d = {
+ .pos = start,
+ .reinsert = saved,
+ .reinsert_len = (size_t)len,
+ .delete_len = 0,
+ .cursor_before = ed->cursor_idx,
+ .anchor_before = ed->selection_anchor,
+ };
+ push_undo_delta(ed, d);
+
+ delete_range_raw(ed, start, end);
editor_unlock(ed);
}
@@ -691,6 +834,69 @@ static int byte_to_utf8_char_idx(const char *text, int byte_limit) {
return char_idx;
}
+void editor_undo(Editor *ed) {
+ editor_lock(ed);
+ if (ed->undo_len == 0) {
+ editor_unlock(ed);
+ return;
+ }
+ UndoDelta d = ed->undo_stack[--ed->undo_len];
+ UndoDelta rev = {0};
+ apply_delta(ed, &d, &rev);
+ free(d.reinsert);
+ if (ed->redo_len < UNDO_MAX)
+ ed->redo_stack[ed->redo_len++] = rev;
+ else
+ free(rev.reinsert);
+ editor_unlock(ed);
+}
+
+void editor_redo(Editor *ed) {
+ editor_lock(ed);
+ if (ed->redo_len == 0) {
+ editor_unlock(ed);
+ return;
+ }
+ UndoDelta d = ed->redo_stack[--ed->redo_len];
+ UndoDelta rev = {0};
+ apply_delta(ed, &d, &rev);
+ free(d.reinsert);
+ if (ed->undo_len < UNDO_MAX)
+ ed->undo_stack[ed->undo_len++] = rev;
+ else
+ free(rev.reinsert);
+ editor_unlock(ed);
+}
+
+void editor_replace_body(Editor *ed, const char *data, size_t len) {
+ editor_lock(ed);
+
+ size_t old_len = ed->text.len;
+ char *saved = malloc(old_len + 1);
+ if (saved) {
+ memcpy(saved, ed->text.data, old_len);
+ saved[old_len] = '\0';
+ }
+ UndoDelta d = {
+ .pos = 0,
+ .reinsert = saved,
+ .reinsert_len = old_len,
+ .delete_len = len,
+ .cursor_before = ed->cursor_idx,
+ .anchor_before = ed->selection_anchor,
+ };
+ push_undo_delta(ed, d);
+
+ ed->ranges_count = 0;
+ strbuf_delete(&ed->text, 0, ed->text.len);
+ strbuf_insert(&ed->text, 0, data ? data : "", len);
+ ed->cursor_idx = 0;
+ ed->selection_anchor = 0;
+
+ editor_unlock(ed);
+ editor_parse_ansi_codes(ed);
+}
+
void editor_parse_ansi_codes(Editor *ed) {
editor_lock(ed);
ed->ranges_count = 0;
diff --git a/editor.h b/editor.h
@@ -1,11 +1,21 @@
#ifndef EDITOR_H
#define EDITOR_H
#define TAB_SIZE 8
+#define UNDO_MAX 100
#include "strbuf.h"
#include <SDL3/SDL.h>
#include <stdbool.h>
+typedef struct {
+ int pos;
+ char *reinsert;
+ size_t reinsert_len;
+ size_t delete_len;
+ int cursor_before;
+ int anchor_before;
+} UndoDelta;
+
typedef enum { RANGE_REPLACEMENT, RANGE_FORMAT } EditorRangeType;
typedef struct {
@@ -39,6 +49,11 @@ typedef struct {
bool dirty;
+ UndoDelta *undo_stack;
+ int undo_len;
+ UndoDelta *redo_stack;
+ int redo_len;
+
// Metrics for coordinate calculations
int char_width;
float line_height;
@@ -61,6 +76,9 @@ void editor_clear_selection(Editor *ed);
void editor_delete_range(Editor *ed, int start, int end);
bool editor_load_file(Editor *ed, const char *filename);
bool editor_write_file(Editor *ed, const char *filename);
+void editor_replace_body(Editor *ed, const char *data, size_t len);
+void editor_undo(Editor *ed);
+void editor_redo(Editor *ed);
void editor_add_formatting_range(Editor *ed, int start, int end, RangeFormat format);
void editor_add_replace_range(Editor *ed, int start, int end, int visual_cols);
EditorRange* editor_get_replacement_at(Editor *ed, int byte_idx);
diff --git a/fuse_ipc.c b/fuse_ipc.c
@@ -441,14 +441,7 @@ static int op_ftruncate(const char *path, off_t size,
/* Apply content to the editor and wake the render loop. Caller holds no lock.
*/
static void apply_body(Editor *ed, const char *data, size_t len) {
- editor_lock(ed);
- ed->ranges_count = 0;
- strbuf_delete(&ed->text, 0, ed->text.len);
- strbuf_insert(&ed->text, 0, data ? data : "", len);
- ed->cursor_idx = 0;
- ed->selection_anchor = 0;
- editor_unlock(ed);
- editor_parse_ansi_codes(ed);
+ editor_replace_body(ed, data, len);
wake_render();
}
diff --git a/main.c b/main.c
@@ -139,6 +139,14 @@ void handle_events(Editor *ed, SDL_Renderer *renderer, float *scroll_x,
editor_write_file(ed, NULL);
}
break;
+ case SDLK_Z:
+ if (!(event.key.mod & SDL_KMOD_ALT))
+ break;
+ if (event.key.mod & SDL_KMOD_SHIFT)
+ editor_redo(ed);
+ else
+ editor_undo(ed);
+ break;
}
case SDL_EVENT_MOUSE_BUTTON_DOWN:
if (event.button.button == SDL_BUTTON_LEFT) {