esc

Externally Scriptable Editor

git clone git://mccd.space/esc

commit 39948dc11e80109d111907a157901f39e6814cb7
parent e6222930773400360585979333368915ce793502
Author: Marc Coquand <marc@coquand.email>
Date:   Tue, 24 Feb 2026 11:21:12 +0100

Add undo/redo history

Diffstat:
Meditor.c | 286++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Meditor.h | 18++++++++++++++++++
Mfuse_ipc.c | 9+--------
Mmain.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) {