From 304d1727096319cdde41d26c064e37e67eab9246 Mon Sep 17 00:00:00 2001 From: sigmanor Date: Sat, 8 Aug 2026 18:48:19 +0300 Subject: [PATCH 1/3] Add undo history module --- Makefile | 1 + src/undo.c | 176 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/undo.h | 41 +++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 src/undo.c create mode 100644 src/undo.h diff --git a/Makefile b/Makefile index 029ca4c..650b8e0 100644 --- a/Makefile +++ b/Makefile @@ -49,5 +49,6 @@ $(OBJDIR)/editor.o: $(SRCDIR)/editor.h $(SRCDIR)/markdown.h $(SRCDIR)/app.h $(OBJDIR)/markdown.o: $(SRCDIR)/markdown.h $(SRCDIR)/code_highlight.h $(OBJDIR)/code_highlight.o: $(SRCDIR)/code_highlight.h $(OBJDIR)/notes.o: $(SRCDIR)/notes.h +$(OBJDIR)/undo.o: $(SRCDIR)/undo.h $(OBJDIR)/tray.o: $(SRCDIR)/tray.h $(SRCDIR)/app.h $(SRCDIR)/window.h $(SRCDIR)/config.h $(OBJDIR)/config.o: $(SRCDIR)/config.h diff --git a/src/undo.c b/src/undo.c new file mode 100644 index 0000000..42d617b --- /dev/null +++ b/src/undo.c @@ -0,0 +1,176 @@ +#include "undo.h" + +/* Keeping whole snapshots is affordable for notes this size, and it makes an + * undo land on a state the editor has already shown, never on one rebuilt from + * offset arithmetic. */ +#define UNDO_MAX_STEPS 100 + +/* A pause this long ends the current step, so a thought break is undoable. */ +#define UNDO_PAUSE_US G_USEC_PER_SEC + +typedef struct _UndoStep { + gchar *markdown; + gint cursor; +} UndoStep; + +struct _MarkydUndo { + GQueue *undo; /* head is the most recent step */ + GQueue *redo; + + /* State of the step still being filled in. */ + gboolean group_open; + gboolean group_insert; + gint group_edge; /* offset the next edit has to touch to be coalesced */ + gint64 group_time; + gboolean break_after; +}; + +static UndoStep *step_new(const gchar *markdown, gint cursor) { + UndoStep *step = g_new0(UndoStep, 1); + + step->markdown = g_strdup(markdown ? markdown : ""); + step->cursor = cursor; + return step; +} + +static void step_free(gpointer data) { + UndoStep *step = (UndoStep *)data; + + if (!step) { + return; + } + g_free(step->markdown); + g_free(step); +} + +static void close_group(MarkydUndo *self) { + self->group_open = FALSE; + self->group_insert = FALSE; + self->group_edge = 0; + self->group_time = 0; + self->break_after = FALSE; +} + +/* Hand the step's text to the caller and drop the wrapper. */ +static void step_take(UndoStep *step, gchar **out_markdown, gint *out_cursor) { + *out_markdown = step->markdown; + *out_cursor = step->cursor; + g_free(step); +} + +MarkydUndo *markyd_undo_new(void) { + MarkydUndo *self = g_new0(MarkydUndo, 1); + + self->undo = g_queue_new(); + self->redo = g_queue_new(); + close_group(self); + + return self; +} + +void markyd_undo_free(MarkydUndo *self) { + if (!self) { + return; + } + + g_queue_free_full(self->undo, step_free); + g_queue_free_full(self->redo, step_free); + g_free(self); +} + +void markyd_undo_clear(MarkydUndo *self) { + if (!self) { + return; + } + + g_queue_free_full(self->undo, step_free); + g_queue_free_full(self->redo, step_free); + self->undo = g_queue_new(); + self->redo = g_queue_new(); + close_group(self); +} + +gboolean markyd_undo_needs_step(MarkydUndo *self, gboolean insert, gint start, + gint end, gboolean whitespace) { + gint64 now; + gboolean adjacent; + gboolean opens; + + if (!self) { + return FALSE; + } + + now = g_get_monotonic_time(); + + /* + * Typing extends the step forwards, so the next insert has to start where + * the last one ended. Deleting collapses towards its own start, which both + * Backspace (range ends at the edge) and Delete (range starts at it) keep + * touching. + */ + adjacent = insert ? (start == self->group_edge) + : (start == self->group_edge || end == self->group_edge); + + opens = !self->group_open || self->break_after || + self->group_insert != insert || !adjacent || + (now - self->group_time) > UNDO_PAUSE_US; + + self->group_open = TRUE; + self->group_insert = insert; + self->group_edge = insert ? end : start; + self->group_time = now; + self->break_after = whitespace; + + return opens; +} + +void markyd_undo_push(MarkydUndo *self, const gchar *markdown, gint cursor) { + if (!self) { + return; + } + + /* A fresh edit makes anything that was undone unreachable. */ + g_queue_free_full(self->redo, step_free); + self->redo = g_queue_new(); + + g_queue_push_head(self->undo, step_new(markdown, cursor)); + + while (g_queue_get_length(self->undo) > UNDO_MAX_STEPS) { + step_free(g_queue_pop_tail(self->undo)); + } +} + +gboolean markyd_undo_undo(MarkydUndo *self, const gchar *markdown, gint cursor, + gchar **out_markdown, gint *out_cursor) { + UndoStep *step; + + if (!self || !out_markdown || !out_cursor || g_queue_is_empty(self->undo)) { + return FALSE; + } + + step = g_queue_pop_head(self->undo); + g_queue_push_head(self->redo, step_new(markdown, cursor)); + step_take(step, out_markdown, out_cursor); + + /* Whatever gets typed next belongs to a step of its own. */ + close_group(self); + + return TRUE; +} + +gboolean markyd_undo_redo(MarkydUndo *self, const gchar *markdown, gint cursor, + gchar **out_markdown, gint *out_cursor) { + UndoStep *step; + + if (!self || !out_markdown || !out_cursor || g_queue_is_empty(self->redo)) { + return FALSE; + } + + step = g_queue_pop_head(self->redo); + g_queue_push_head(self->undo, step_new(markdown, cursor)); + step_take(step, out_markdown, out_cursor); + + close_group(self); + + return TRUE; +} diff --git a/src/undo.h b/src/undo.h new file mode 100644 index 0000000..554d602 --- /dev/null +++ b/src/undo.h @@ -0,0 +1,41 @@ +#ifndef MARKYD_UNDO_H +#define MARKYD_UNDO_H + +#include + +typedef struct _MarkydUndo MarkydUndo; + +MarkydUndo *markyd_undo_new(void); +void markyd_undo_free(MarkydUndo *undo); + +/* Drop the whole history (note switch, content reload). */ +void markyd_undo_clear(MarkydUndo *undo); + +/* + * Recording an edit takes two calls. markyd_undo_needs_step() updates the + * grouping state and answers whether the edit has to open a new undo step; + * only then does the caller snapshot the note and hand it to + * markyd_undo_push(). Keeping them apart means a typed word costs one + * snapshot instead of one per keystroke. + * + * Both are called before the edit reaches the buffer, so the snapshot is the + * state Ctrl+Z has to come back to. Offsets are character offsets into the + * buffer and only steer the grouping. "whitespace" marks an edit of a single + * space or newline, which ends the step it belongs to, so that typing a + * sentence undoes word by word. + */ +gboolean markyd_undo_needs_step(MarkydUndo *undo, gboolean insert, gint start, + gint end, gboolean whitespace); +void markyd_undo_push(MarkydUndo *undo, const gchar *markdown, gint cursor); + +/* + * Hand over the current state and get the neighbouring one back. Both return + * FALSE when the matching stack is empty, leaving the out parameters + * untouched. On success *out_markdown is owned by the caller. + */ +gboolean markyd_undo_undo(MarkydUndo *undo, const gchar *markdown, gint cursor, + gchar **out_markdown, gint *out_cursor); +gboolean markyd_undo_redo(MarkydUndo *undo, const gchar *markdown, gint cursor, + gchar **out_markdown, gint *out_cursor); + +#endif /* MARKYD_UNDO_H */ From 46728fd89df12d6079db48d56e73916f535b08d8 Mon Sep 17 00:00:00 2001 From: sigmanor Date: Sat, 8 Aug 2026 19:18:55 +0300 Subject: [PATCH 2/3] Add multi-level undo and redo to the editor --- Makefile | 2 +- README.md | 1 + src/editor.c | 369 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/editor.h | 13 ++ 4 files changed, 374 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 650b8e0..f2ddb3c 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ uninstall: $(OBJDIR)/main.o: $(SRCDIR)/app.h $(SRCDIR)/tray.h $(SRCDIR)/window.h $(OBJDIR)/app.o: $(SRCDIR)/app.h $(SRCDIR)/config.h $(SRCDIR)/notes.h $(SRCDIR)/window.h $(SRCDIR)/editor.h $(OBJDIR)/window.o: $(SRCDIR)/window.h $(SRCDIR)/app.h $(SRCDIR)/editor.h $(SRCDIR)/config.h -$(OBJDIR)/editor.o: $(SRCDIR)/editor.h $(SRCDIR)/markdown.h $(SRCDIR)/app.h +$(OBJDIR)/editor.o: $(SRCDIR)/editor.h $(SRCDIR)/markdown.h $(SRCDIR)/app.h $(SRCDIR)/undo.h $(OBJDIR)/markdown.o: $(SRCDIR)/markdown.h $(SRCDIR)/code_highlight.h $(OBJDIR)/code_highlight.o: $(SRCDIR)/code_highlight.h $(OBJDIR)/notes.o: $(SRCDIR)/notes.h diff --git a/README.md b/README.md index c8b62a5..3c1a05d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A lightweight GTK 3 notes application for Linux system trays, supporting live ed - **Minimal UI** - Clean toolbar with new note and navigation buttons - **Lightweight** - Pure C, no web technologies, fast startup - **Hyperlink support** - 'ctrl left click' links to open them +- **Multi-level undo/redo** - 'ctrl+z' to undo, 'ctrl+shift+z' or 'ctrl+y' to redo ## Supported Markdown diff --git a/src/editor.c b/src/editor.c index c9ff9d9..1d10d11 100644 --- a/src/editor.c +++ b/src/editor.c @@ -21,6 +21,12 @@ static gboolean on_leave_notify(GtkWidget *widget, GdkEventCrossing *event, gpointer user_data); static void on_paste_clipboard(GtkTextView *text_view, gpointer user_data); static void on_paste_clipboard_after(GtkTextView *text_view, gpointer user_data); +static void on_insert_text(GtkTextBuffer *buffer, GtkTextIter *location, + gchar *text, gint len, gpointer user_data); +static void on_delete_range(GtkTextBuffer *buffer, GtkTextIter *start, + GtkTextIter *end, gpointer user_data); +static void on_begin_user_action(GtkTextBuffer *buffer, gpointer user_data); +static void on_end_user_action(GtkTextBuffer *buffer, gpointer user_data); static void apply_markdown(MarkydEditor *self); static void schedule_markdown_apply(MarkydEditor *self); @@ -396,10 +402,17 @@ static void apply_markdown(MarkydEditor *self) { return; } + /* + * Rendering rewrites the buffer: list markers are normalised and the child + * anchors carrying horizontal rules are dropped and re-created. None of that + * is a user edit, so keep it out of the history. + */ self->updating_tags = TRUE; + self->undo_suppress = TRUE; normalize_list_markers(self); markdown_apply_tags(self->buffer); render_hrules(self); + self->undo_suppress = FALSE; self->updating_tags = FALSE; } @@ -433,6 +446,8 @@ MarkydEditor *markyd_editor_new(MarkydApp *app) { self->app = app; self->updating_tags = FALSE; self->markdown_idle_id = 0; + self->undo = markyd_undo_new(); + self->undo_suppress = FALSE; self->in_paste = FALSE; self->in_undo = FALSE; self->pending_paste_finalize = FALSE; @@ -464,6 +479,18 @@ MarkydEditor *markyd_editor_new(MarkydApp *app) { g_signal_connect(self->buffer, "changed", G_CALLBACK(on_buffer_changed), self); + /* Record edits for undo. Both run before the buffer is mutated, so the + * snapshot they take is the state to come back to. */ + g_signal_connect(self->buffer, "insert-text", G_CALLBACK(on_insert_text), + self); + g_signal_connect(self->buffer, "delete-range", G_CALLBACK(on_delete_range), + self); + + g_signal_connect(self->buffer, "begin-user-action", + G_CALLBACK(on_begin_user_action), self); + g_signal_connect(self->buffer, "end-user-action", + G_CALLBACK(on_end_user_action), self); + /* Connect to key press for list continuation */ g_signal_connect(self->text_view, "key-press-event", G_CALLBACK(on_key_press), self); @@ -519,13 +546,20 @@ void markyd_editor_free(MarkydEditor *self) { self->markdown_idle_id = 0; } clear_last_paste(self); + markyd_undo_free(self->undo); g_free(self); } void markyd_editor_set_content(MarkydEditor *self, const gchar *content) { gchar *display = markdown_to_display_text(content); + + /* A different note means a different history. */ + markyd_undo_clear(self->undo); + self->updating_tags = TRUE; + self->undo_suppress = TRUE; gtk_text_buffer_set_text(self->buffer, display ? display : "", -1); + self->undo_suppress = FALSE; self->updating_tags = FALSE; g_free(display); @@ -537,6 +571,7 @@ gchar *markyd_editor_get_content(MarkydEditor *self) { GtkTextIter start, end; GString *out; GtkTextIter iter; + GtkTextIter run_start; gchar *raw; gchar *converted; @@ -544,23 +579,34 @@ gchar *markyd_editor_get_content(MarkydEditor *self) { /* * TRUE = include hidden chars (markdown syntax) so they're preserved when - * saving, but skip embedded widget anchors (e.g., horizontal rules). + * saving, but skip embedded widget anchors (e.g., horizontal rules). Copy + * the runs between anchors rather than character by character: the undo + * history reads this on every step, and a per-character copy would show up + * as typing latency on a long note. */ out = g_string_new(NULL); + run_start = start; iter = start; while (!gtk_text_iter_equal(&iter, &end)) { - GtkTextIter next = iter; - gtk_text_iter_forward_char(&next); - if (gtk_text_iter_get_child_anchor(&iter)) { - iter = next; - continue; + gchar *run = + gtk_text_buffer_get_text(self->buffer, &run_start, &iter, TRUE); + g_string_append(out, run); + g_free(run); + + run_start = iter; + gtk_text_iter_forward_char(&run_start); } - gchar *chunk = gtk_text_buffer_get_text(self->buffer, &iter, &next, TRUE); - g_string_append(out, chunk); - g_free(chunk); - iter = next; + if (!gtk_text_iter_forward_char(&iter)) { + break; + } + } + + { + gchar *run = gtk_text_buffer_get_text(self->buffer, &run_start, &end, TRUE); + g_string_append(out, run); + g_free(run); } raw = gstring_steal_compat(out); @@ -588,6 +634,296 @@ void markyd_editor_focus(MarkydEditor *self) { } } +/* + * The buffer carries one invisible child anchor per horizontal rule, the + * markdown text does not. The history is kept in markdown offsets so a step + * survives a re-render moving those anchors around. + */ +static gint editor_markdown_offset(MarkydEditor *self, const GtkTextIter *at) { + GtkTextIter iter; + gint offset = 0; + + gtk_text_buffer_get_start_iter(self->buffer, &iter); + while (gtk_text_iter_compare(&iter, at) < 0) { + if (!gtk_text_iter_get_child_anchor(&iter)) { + offset++; + } + if (!gtk_text_iter_forward_char(&iter)) { + break; + } + } + + return offset; +} + +static void editor_iter_at_markdown_offset(MarkydEditor *self, gint offset, + GtkTextIter *out) { + GtkTextIter iter; + + gtk_text_buffer_get_start_iter(self->buffer, &iter); + while (offset > 0) { + if (!gtk_text_iter_get_child_anchor(&iter)) { + offset--; + } + if (!gtk_text_iter_forward_char(&iter)) { + break; + } + } + + /* Land on the character itself, never between an anchor and its rule. */ + while (gtk_text_iter_get_child_anchor(&iter)) { + if (!gtk_text_iter_forward_char(&iter)) { + break; + } + } + + *out = iter; +} + +static gint editor_cursor_markdown_offset(MarkydEditor *self) { + GtkTextIter cursor; + + gtk_text_buffer_get_iter_at_mark(self->buffer, &cursor, + gtk_text_buffer_get_insert(self->buffer)); + return editor_markdown_offset(self, &cursor); +} + +static void editor_push_undo_step(MarkydEditor *self) { + gchar *markdown = markyd_editor_get_content(self); + + markyd_undo_push(self->undo, markdown, editor_cursor_markdown_offset(self)); + g_free(markdown); +} + +static void editor_record_edit(MarkydEditor *self, gboolean insert, gint start, + gint end, gboolean whitespace) { + gboolean opens; + + if (self->undo_suppress) { + return; + } + + /* Keep the grouping state current even for an edit we won't snapshot, so + * the one after it still sees where the last edit left off. */ + opens = markyd_undo_needs_step(self->undo, insert, start, end, whitespace); + + /* + * Replacing a selection - by typing, pasting or dropping over it - reaches + * the buffer as a delete followed by an insert inside one user action. Only + * the first half may open a step, or Ctrl+Z would stop at the half-finished + * state, with the old text already gone and the new text not yet there. + */ + if (self->user_action_depth > 0) { + if (self->user_action_recorded) { + return; + } + self->user_action_recorded = TRUE; + } + + if (opens) { + editor_push_undo_step(self); + } +} + +static void on_insert_text(GtkTextBuffer *buffer, GtkTextIter *location, + gchar *text, gint len, gpointer user_data) { + MarkydEditor *self = (MarkydEditor *)user_data; + gint start; + gint chars; + gboolean whitespace; + + (void)buffer; + + chars = (text && len > 0) ? (gint)g_utf8_strlen(text, len) : 0; + whitespace = (chars == 1) && g_unichar_isspace(g_utf8_get_char(text)); + start = gtk_text_iter_get_offset(location); + + editor_record_edit(self, TRUE, start, start + chars, whitespace); +} + +static void on_delete_range(GtkTextBuffer *buffer, GtkTextIter *start, + GtkTextIter *end, gpointer user_data) { + MarkydEditor *self = (MarkydEditor *)user_data; + gint start_offset; + gint end_offset; + gboolean whitespace; + + (void)buffer; + + start_offset = gtk_text_iter_get_offset(start); + end_offset = gtk_text_iter_get_offset(end); + whitespace = (end_offset - start_offset == 1) && + g_unichar_isspace(gtk_text_iter_get_char(start)); + + editor_record_edit(self, FALSE, start_offset, end_offset, whitespace); +} + +/* + * GTK nests these: deleting the selection opens a user action of its own + * inside the one wrapping the whole keystroke or paste. Counting the depth + * keeps the replace-selection pair in a single undo step. + */ +static void on_begin_user_action(GtkTextBuffer *buffer, gpointer user_data) { + MarkydEditor *self = (MarkydEditor *)user_data; + + (void)buffer; + + if (self->user_action_depth++ == 0) { + self->user_action_recorded = FALSE; + } +} + +static void on_end_user_action(GtkTextBuffer *buffer, gpointer user_data) { + MarkydEditor *self = (MarkydEditor *)user_data; + + (void)buffer; + + if (self->user_action_depth > 0) { + self->user_action_depth--; + } +} + +/* + * Put the note back to "markdown" by replacing only the run of text that + * actually differs. Rewriting the whole buffer would reset the scroll position + * and strip every tag, which is exactly what an undo must not do. + */ +static void editor_restore(MarkydEditor *self, const gchar *current, + const gchar *markdown, gint cursor) { + gsize current_len = strlen(current); + gsize markdown_len = strlen(markdown); + gsize shortest = MIN(current_len, markdown_len); + gsize head = 0; + gsize tail = 0; + gint replace_start; + gint replace_end; + GtkTextIter start, end; + + while (head < shortest && current[head] == markdown[head]) { + head++; + } + while (tail < shortest - head && + current[current_len - 1 - tail] == markdown[markdown_len - 1 - tail]) { + tail++; + } + + /* Both ends have to sit on a character boundary, never inside a UTF-8 + * sequence. Backing off keeps the two strings in agreement. */ + while (head > 0 && (current[head] & 0xC0) == 0x80) { + head--; + } + while (tail > 0 && (current[current_len - tail] & 0xC0) == 0x80) { + tail--; + } + + replace_start = (gint)g_utf8_strlen(current, (gssize)head); + replace_end = + replace_start + + (gint)g_utf8_strlen(current + head, (gssize)(current_len - tail - head)); + + self->undo_suppress = TRUE; + editor_iter_at_markdown_offset(self, replace_start, &start); + editor_iter_at_markdown_offset(self, replace_end, &end); + gtk_text_buffer_delete(self->buffer, &start, &end); + gtk_text_buffer_insert(self->buffer, &start, markdown + head, + (gint)(markdown_len - tail - head)); + self->undo_suppress = FALSE; + + /* Before re-rendering: the renderer looks at the cursor line to decide + * which horizontal rule to leave in its editable form. */ + editor_iter_at_markdown_offset(self, cursor, &start); + gtk_text_buffer_place_cursor(self->buffer, &start); + + /* Render in this same main loop iteration, so no frame ever shows the raw + * markdown the splice just put in. */ + if (self->markdown_idle_id != 0) { + g_source_remove(self->markdown_idle_id); + self->markdown_idle_id = 0; + } + apply_markdown(self); + + /* Follow the cursor only when it ended up out of sight. */ + gtk_text_view_scroll_mark_onscreen(GTK_TEXT_VIEW(self->text_view), + gtk_text_buffer_get_insert(self->buffer)); +} + +static void editor_history_step(MarkydEditor *self, gboolean redo) { + gchar *current; + gchar *markdown = NULL; + gint cursor = 0; + gboolean moved; + + current = markyd_editor_get_content(self); + moved = redo ? markyd_undo_redo(self->undo, current, + editor_cursor_markdown_offset(self), + &markdown, &cursor) + : markyd_undo_undo(self->undo, current, + editor_cursor_markdown_offset(self), + &markdown, &cursor); + + if (moved) { + editor_restore(self, current, markdown, cursor); + g_free(markdown); + } + + g_free(current); +} + +/* + * Ctrl shortcuts have to match the physical key rather than the character it + * produces: on a Cyrillic layout the Z key reports Cyrillic_ya, and unlike + * Ctrl+C or Ctrl+V there is no GTK default binding to fall back on. Check + * every group the keycode maps to, since the Latin layout is not necessarily + * the first one. + */ +static gboolean key_event_is(GdkEventKey *event, guint latin_keyval) { + GdkKeymap *keymap; + GdkKeymapKey *keys = NULL; + guint *keyvals = NULL; + gint n_entries = 0; + guint latin_upper = gdk_keyval_to_upper(latin_keyval); + gboolean found = FALSE; + + if (event->keyval == latin_keyval || event->keyval == latin_upper) { + return TRUE; + } + + /* + * A Latin letter came out of the layout, just not the one asked for, so the + * layout is answer enough. Looking further would break QWERTZ, where Z and + * Y swap places and scanning every group makes both keys answer to Ctrl+Z. + */ + if (event->keyval < 0x80 && g_ascii_isalpha((gchar)event->keyval)) { + return FALSE; + } + + if (!event->window) { + return FALSE; + } + + keymap = gdk_keymap_get_for_display(gdk_window_get_display(event->window)); + if (!keymap) { + return FALSE; + } + + if (!gdk_keymap_get_entries_for_keycode(keymap, event->hardware_keycode, + &keys, &keyvals, &n_entries)) { + return FALSE; + } + + for (gint i = 0; i < n_entries; i++) { + if (keyvals[i] == latin_keyval || keyvals[i] == latin_upper) { + found = TRUE; + break; + } + } + + g_free(keys); + g_free(keyvals); + + return found; +} + /* Check if line is an empty list item (just the prefix with no content) */ static gboolean is_empty_list_item(const gchar *line) { if (!line || !*line) @@ -737,6 +1073,19 @@ static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, return TRUE; } + /* Ctrl+Z undoes, Ctrl+Shift+Z and Ctrl+Y redo. Alt has to be clear so + * Ctrl+Alt+Z isn't swallowed here. */ + if ((event->state & GDK_CONTROL_MASK) && !(event->state & GDK_MOD1_MASK)) { + if (key_event_is(event, GDK_KEY_z)) { + editor_history_step(self, (event->state & GDK_SHIFT_MASK) != 0); + return TRUE; + } + if (key_event_is(event, GDK_KEY_y)) { + editor_history_step(self, TRUE); + return TRUE; + } + } + /* Ctrl+Z: undo last paste (single level) */ if ((event->state & GDK_CONTROL_MASK) && (event->keyval == GDK_KEY_z || event->keyval == GDK_KEY_Z)) { diff --git a/src/editor.h b/src/editor.h index f5dff94..0ba4324 100644 --- a/src/editor.h +++ b/src/editor.h @@ -1,6 +1,7 @@ #ifndef MARKYD_EDITOR_H #define MARKYD_EDITOR_H +#include "undo.h" #include typedef struct _MarkydApp MarkydApp; @@ -16,6 +17,18 @@ typedef struct _MarkydEditor { /* Coalesce markdown re-rendering to idle to avoid invalidating GTK iterators. */ guint markdown_idle_id; + /* Undo/redo history */ + MarkydUndo *undo; + + /* Set while we rewrite the buffer ourselves (rendering, content load, + * undo/redo) so those edits don't end up in the history. */ + gboolean undo_suppress; + + /* Depth of the GTK user action being applied, and whether it already + * claimed its undo step. */ + gint user_action_depth; + gboolean user_action_recorded; + /* "Undo last paste" support (single-level) */ gboolean in_paste; gboolean in_undo; From 02cbb96833f66f54ba0f1144c76eba6aca67478f Mon Sep 17 00:00:00 2001 From: sigmanor Date: Sat, 8 Aug 2026 19:18:55 +0300 Subject: [PATCH 3/3] Drop the single-level paste undo --- src/editor.c | 275 --------------------------------------------------- src/editor.h | 15 --- 2 files changed, 290 deletions(-) diff --git a/src/editor.c b/src/editor.c index 1d10d11..85fb546 100644 --- a/src/editor.c +++ b/src/editor.c @@ -13,14 +13,10 @@ static void on_text_view_size_allocate(GtkWidget *widget, gpointer user_data); static gboolean on_button_release(GtkWidget *widget, GdkEventButton *event, gpointer user_data); -static gboolean on_button_press(GtkWidget *widget, GdkEventButton *event, - gpointer user_data); static gboolean on_motion_notify(GtkWidget *widget, GdkEventMotion *event, gpointer user_data); static gboolean on_leave_notify(GtkWidget *widget, GdkEventCrossing *event, gpointer user_data); -static void on_paste_clipboard(GtkTextView *text_view, gpointer user_data); -static void on_paste_clipboard_after(GtkTextView *text_view, gpointer user_data); static void on_insert_text(GtkTextBuffer *buffer, GtkTextIter *location, gchar *text, gint len, gpointer user_data); static void on_delete_range(GtkTextBuffer *buffer, GtkTextIter *start, @@ -128,25 +124,6 @@ static gboolean hr_draw(GtkWidget *widget, cairo_t *cr, gpointer user_data) { static const gint HR_WIDGET_HEIGHT_PX = 22; static const gchar *HR_WIDGET_DATA_KEY = "traymd-hr-widget"; -static void clear_last_paste(MarkydEditor *self) { - if (!self) { - return; - } - - if (self->paste_inserted_start) { - gtk_text_buffer_delete_mark(self->buffer, self->paste_inserted_start); - self->paste_inserted_start = NULL; - } - if (self->paste_inserted_end) { - gtk_text_buffer_delete_mark(self->buffer, self->paste_inserted_end); - self->paste_inserted_end = NULL; - } - - g_clear_pointer(&self->paste_replaced_text, g_free); - g_clear_pointer(&self->paste_clipboard_text, g_free); - self->paste_valid = FALSE; -} - static gboolean is_all_ascii_space(const gchar *s) { while (s && *s) { if (!g_ascii_isspace(*s)) { @@ -448,19 +425,6 @@ MarkydEditor *markyd_editor_new(MarkydApp *app) { self->markdown_idle_id = 0; self->undo = markyd_undo_new(); self->undo_suppress = FALSE; - self->in_paste = FALSE; - self->in_undo = FALSE; - self->pending_paste_finalize = FALSE; - self->paste_start_offset = 0; - self->paste_end_offset_before = 0; - self->paste_replaced_text = NULL; - self->paste_clipboard_text = NULL; - self->paste_inserted_start = NULL; - self->paste_inserted_end = NULL; - self->paste_valid = FALSE; - self->paste_had_selection = FALSE; - self->paste_sel_start_offset = 0; - self->paste_sel_end_offset = 0; /* Create text view */ self->text_view = gtk_text_view_new(); @@ -485,7 +449,6 @@ MarkydEditor *markyd_editor_new(MarkydApp *app) { self); g_signal_connect(self->buffer, "delete-range", G_CALLBACK(on_delete_range), self); - g_signal_connect(self->buffer, "begin-user-action", G_CALLBACK(on_begin_user_action), self); g_signal_connect(self->buffer, "end-user-action", @@ -503,8 +466,6 @@ MarkydEditor *markyd_editor_new(MarkydApp *app) { GDK_LEAVE_NOTIFY_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK); - g_signal_connect(self->text_view, "button-press-event", - G_CALLBACK(on_button_press), self); g_signal_connect(self->text_view, "button-release-event", G_CALLBACK(on_button_release), self); g_signal_connect(self->text_view, "motion-notify-event", @@ -512,12 +473,6 @@ MarkydEditor *markyd_editor_new(MarkydApp *app) { g_signal_connect(self->text_view, "leave-notify-event", G_CALLBACK(on_leave_notify), self); - /* Track pastes so we can undo the last one with Ctrl+Z */ - g_signal_connect(self->text_view, "paste-clipboard", - G_CALLBACK(on_paste_clipboard), self); - g_signal_connect_after(self->text_view, "paste-clipboard", - G_CALLBACK(on_paste_clipboard_after), self); - /* Set initial cursor to text (I-beam) */ { GdkWindow *win = gtk_text_view_get_window(GTK_TEXT_VIEW(self->text_view), @@ -545,7 +500,6 @@ void markyd_editor_free(MarkydEditor *self) { g_source_remove(self->markdown_idle_id); self->markdown_idle_id = 0; } - clear_last_paste(self); markyd_undo_free(self->undo); g_free(self); } @@ -1017,62 +971,6 @@ static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, } } - /* Ctrl+V: tracked paste so Ctrl+Z can undo it (single level). */ - if ((event->state & GDK_CONTROL_MASK) && - (event->keyval == GDK_KEY_v || event->keyval == GDK_KEY_V)) { - GtkClipboard *cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - gchar *clip = gtk_clipboard_wait_for_text(cb); - GtkTextIter sel_start, sel_end; - gboolean had_selection = - gtk_text_buffer_get_selection_bounds(buffer, &sel_start, &sel_end); - - if (!clip) { - return FALSE; - } - - self->in_paste = TRUE; - clear_last_paste(self); - - self->paste_had_selection = had_selection; - if (had_selection) { - self->paste_sel_start_offset = gtk_text_iter_get_offset(&sel_start); - self->paste_sel_end_offset = gtk_text_iter_get_offset(&sel_end); - self->paste_replaced_text = - gtk_text_buffer_get_text(buffer, &sel_start, &sel_end, TRUE); - } else { - gtk_text_buffer_get_iter_at_mark(buffer, &cursor, - gtk_text_buffer_get_insert(buffer)); - self->paste_sel_start_offset = gtk_text_iter_get_offset(&cursor); - self->paste_sel_end_offset = self->paste_sel_start_offset; - self->paste_replaced_text = g_strdup(""); - sel_start = cursor; - sel_end = cursor; - } - - /* Mark start of insertion, then delete selection and insert clipboard text. */ - self->paste_inserted_start = - gtk_text_buffer_create_mark(buffer, NULL, &sel_start, TRUE); - - if (had_selection) { - gtk_text_buffer_delete(buffer, &sel_start, &sel_end); - gtk_text_buffer_get_iter_at_mark(buffer, &sel_start, - self->paste_inserted_start); - } - - gtk_text_buffer_insert(buffer, &sel_start, clip, -1); - self->paste_inserted_end = - gtk_text_buffer_create_mark(buffer, NULL, &sel_start, FALSE); - - gtk_text_buffer_place_cursor(buffer, &sel_start); - - self->paste_clipboard_text = clip; /* keep for debug/inspection */ - self->paste_valid = TRUE; - self->in_paste = FALSE; - - schedule_markdown_apply(self); - return TRUE; - } - /* Ctrl+Z undoes, Ctrl+Shift+Z and Ctrl+Y redo. Alt has to be clear so * Ctrl+Alt+Z isn't swallowed here. */ if ((event->state & GDK_CONTROL_MASK) && !(event->state & GDK_MOD1_MASK)) { @@ -1086,50 +984,6 @@ static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, } } - /* Ctrl+Z: undo last paste (single level) */ - if ((event->state & GDK_CONTROL_MASK) && - (event->keyval == GDK_KEY_z || event->keyval == GDK_KEY_Z)) { - if (self->paste_valid && self->paste_inserted_start && - self->paste_inserted_end) { - GtkTextIter start, end; - GtkTextIter restore_start; - - self->in_undo = TRUE; - self->updating_tags = TRUE; - - gtk_text_buffer_get_iter_at_mark(buffer, &start, self->paste_inserted_start); - gtk_text_buffer_get_iter_at_mark(buffer, &end, self->paste_inserted_end); - restore_start = start; - - gtk_text_buffer_delete(buffer, &start, &end); - - if (self->paste_replaced_text && self->paste_replaced_text[0] != '\0') { - gtk_text_buffer_insert(buffer, &restore_start, self->paste_replaced_text, - -1); - } - - /* Restore selection/cursor */ - if (self->paste_had_selection) { - GtkTextIter sel_a, sel_b; - gtk_text_buffer_get_iter_at_offset(buffer, &sel_a, - self->paste_sel_start_offset); - gtk_text_buffer_get_iter_at_offset(buffer, &sel_b, - self->paste_sel_end_offset); - gtk_text_buffer_select_range(buffer, &sel_a, &sel_b); - } else { - gtk_text_buffer_place_cursor(buffer, &restore_start); - } - - self->updating_tags = FALSE; - self->in_undo = FALSE; - - clear_last_paste(self); - schedule_markdown_apply(self); - return TRUE; - } - return FALSE; - } - /* Only handle Return/Enter key */ if (event->keyval != GDK_KEY_Return && event->keyval != GDK_KEY_KP_Enter) { return FALSE; @@ -1243,12 +1097,6 @@ static void on_buffer_changed(GtkTextBuffer *buffer, gpointer user_data) { return; } - /* Any edit after a paste invalidates our one-level "undo paste". */ - if (self->paste_valid && !self->in_paste && !self->in_undo && - !self->pending_paste_finalize) { - clear_last_paste(self); - } - /* Schedule auto-save */ markyd_app_schedule_save(self->app); @@ -1256,74 +1104,6 @@ static void on_buffer_changed(GtkTextBuffer *buffer, gpointer user_data) { schedule_markdown_apply(self); } -static void on_paste_clipboard(GtkTextView *text_view, gpointer user_data) { - MarkydEditor *self = (MarkydEditor *)user_data; - GtkTextBuffer *buffer = self->buffer; - GtkTextIter sel_start, sel_end; - GtkTextIter insert_iter; - - (void)text_view; - - self->in_paste = TRUE; - clear_last_paste(self); - - self->paste_had_selection = - gtk_text_buffer_get_selection_bounds(buffer, &sel_start, &sel_end); - if (self->paste_had_selection) { - self->paste_sel_start_offset = gtk_text_iter_get_offset(&sel_start); - self->paste_sel_end_offset = gtk_text_iter_get_offset(&sel_end); - self->paste_start_offset = self->paste_sel_start_offset; - self->paste_end_offset_before = self->paste_sel_end_offset; - self->paste_replaced_text = - gtk_text_buffer_get_text(buffer, &sel_start, &sel_end, TRUE); - } else { - gtk_text_buffer_get_iter_at_mark(buffer, &insert_iter, - gtk_text_buffer_get_insert(buffer)); - self->paste_start_offset = gtk_text_iter_get_offset(&insert_iter); - self->paste_end_offset_before = self->paste_start_offset; - self->paste_replaced_text = g_strdup(""); - self->paste_sel_start_offset = self->paste_start_offset; - self->paste_sel_end_offset = self->paste_start_offset; - } - - GtkClipboard *cb = gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); - self->paste_clipboard_text = gtk_clipboard_wait_for_text(cb); - self->pending_paste_finalize = TRUE; -} - -static void on_paste_clipboard_after(GtkTextView *text_view, gpointer user_data) { - MarkydEditor *self = (MarkydEditor *)user_data; - GtkTextBuffer *buffer = self->buffer; - gint inserted_chars; - GtkTextIter start_iter, end_iter; - - (void)text_view; - - if (!self->pending_paste_finalize) { - self->in_paste = FALSE; - return; - } - self->pending_paste_finalize = FALSE; - - if (!self->paste_clipboard_text) { - self->in_paste = FALSE; - return; - } - - inserted_chars = g_utf8_strlen(self->paste_clipboard_text, -1); - gtk_text_buffer_get_iter_at_offset(buffer, &start_iter, self->paste_start_offset); - gtk_text_buffer_get_iter_at_offset(buffer, &end_iter, - self->paste_start_offset + inserted_chars); - - self->paste_inserted_start = - gtk_text_buffer_create_mark(buffer, NULL, &start_iter, TRUE); - self->paste_inserted_end = - gtk_text_buffer_create_mark(buffer, NULL, &end_iter, FALSE); - - self->paste_valid = TRUE; - self->in_paste = FALSE; -} - static void on_text_view_size_allocate(GtkWidget *widget, GtkAllocation *allocation, gpointer user_data) { @@ -1396,61 +1176,6 @@ static gboolean on_button_release(GtkWidget *widget, GdkEventButton *event, return TRUE; } -static gboolean on_button_press(GtkWidget *widget, GdkEventButton *event, - gpointer user_data) { - MarkydEditor *self = (MarkydEditor *)user_data; - GtkTextBuffer *buffer = self->buffer; - GtkTextIter iter; - gint bx, by; - - /* Middle click paste (PRIMARY selection) with undo-paste support. */ - if (event->button != 2) { - return FALSE; - } - if (event->state & (GDK_CONTROL_MASK | GDK_SHIFT_MASK | GDK_MOD1_MASK)) { - return FALSE; - } - - GtkClipboard *cb = gtk_clipboard_get(GDK_SELECTION_PRIMARY); - gchar *clip = gtk_clipboard_wait_for_text(cb); - if (!clip) { - return FALSE; /* fall back to default behavior */ - } - - gtk_text_view_window_to_buffer_coords(GTK_TEXT_VIEW(widget), - GTK_TEXT_WINDOW_TEXT, (gint)event->x, - (gint)event->y, &bx, &by); - gtk_text_view_get_iter_at_location(GTK_TEXT_VIEW(widget), &iter, bx, by); - - self->in_paste = TRUE; - clear_last_paste(self); - - /* Middle-click paste typically inserts at pointer; don't replace selection. */ - self->paste_had_selection = FALSE; - self->paste_replaced_text = g_strdup(""); - - gtk_text_buffer_place_cursor(buffer, &iter); - self->paste_sel_start_offset = gtk_text_iter_get_offset(&iter); - self->paste_sel_end_offset = self->paste_sel_start_offset; - - self->paste_inserted_start = - gtk_text_buffer_create_mark(buffer, NULL, &iter, TRUE); - - gtk_text_buffer_insert(buffer, &iter, clip, -1); - self->paste_inserted_end = - gtk_text_buffer_create_mark(buffer, NULL, &iter, FALSE); - - gtk_text_buffer_place_cursor(buffer, &iter); - - g_free(self->paste_clipboard_text); - self->paste_clipboard_text = clip; - self->paste_valid = TRUE; - self->in_paste = FALSE; - - schedule_markdown_apply(self); - return TRUE; -} - static gboolean on_motion_notify(GtkWidget *widget, GdkEventMotion *event, gpointer user_data) { MarkydEditor *self = (MarkydEditor *)user_data; diff --git a/src/editor.h b/src/editor.h index 0ba4324..bd1ccb3 100644 --- a/src/editor.h +++ b/src/editor.h @@ -28,21 +28,6 @@ typedef struct _MarkydEditor { * claimed its undo step. */ gint user_action_depth; gboolean user_action_recorded; - - /* "Undo last paste" support (single-level) */ - gboolean in_paste; - gboolean in_undo; - gboolean pending_paste_finalize; - gint paste_start_offset; - gint paste_end_offset_before; - gchar *paste_replaced_text; - gchar *paste_clipboard_text; - GtkTextMark *paste_inserted_start; - GtkTextMark *paste_inserted_end; - gboolean paste_valid; - gboolean paste_had_selection; - gint paste_sel_start_offset; - gint paste_sel_end_offset; } MarkydEditor; /* Lifecycle */