[geary/mjog/233-entry-undo: 1/5] Add Components.EntryUndo class to manage undo for GTK Entries



commit ce1630e09d2c25be1c61a7373491605a3b045318
Author: Michael Gratton <mike vee net>
Date:   Wed Nov 6 11:53:36 2019 +1100

    Add Components.EntryUndo class to manage undo for GTK Entries

 po/POTFILES.in                                   |   1 +
 src/client/application/geary-application.vala    |   1 +
 src/client/components/components-entry-undo.vala | 346 +++++++++++++++++++++++
 src/client/meson.build                           |   1 +
 4 files changed, 349 insertions(+)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 502e679a..2a4b49a4 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -30,6 +30,7 @@ src/client/application/main.vala
 src/client/application/secret-mediator.vala
 src/client/components/client-web-view.vala
 src/client/components/components-attachment-pane.vala
+src/client/components/components-entry-undo.vala
 src/client/components/components-in-app-notification.vala
 src/client/components/components-inspector.vala
 src/client/components/components-placeholder-pane.vala
diff --git a/src/client/application/geary-application.vala b/src/client/application/geary-application.vala
index 4fb97a0e..3b416cd5 100644
--- a/src/client/application/geary-application.vala
+++ b/src/client/application/geary-application.vala
@@ -449,6 +449,7 @@ public class GearyApplication : Gtk.Application {
 
         MainWindow.add_window_accelerators(this);
         ComposerWidget.add_window_accelerators(this);
+        Components.EntryUndo.add_window_accelerators(this);
         Components.Inspector.add_window_accelerators(this);
 
         if (this.is_background_service) {
diff --git a/src/client/components/components-entry-undo.vala 
b/src/client/components/components-entry-undo.vala
new file mode 100644
index 00000000..18382e34
--- /dev/null
+++ b/src/client/components/components-entry-undo.vala
@@ -0,0 +1,346 @@
+/*
+ * Copyright 2019 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later).  See the COPYING file in this distribution.
+ */
+
+/**
+ * Provides per-GTK Entry undo and redo using a command stack.
+ */
+public class Components.EntryUndo : Geary.BaseObject {
+
+
+    private const string ACTION_GROUP = "ceu";
+    private const ActionEntry[] action_entries = {
+        { GearyApplication.ACTION_UNDO, on_undo },
+        { GearyApplication.ACTION_REDO, on_redo },
+    };
+
+
+    private enum EditType { NONE, INSERT, DELETE; }
+
+
+    private class EditCommand : Application.Command {
+
+
+        private weak EntryUndo manager;
+        private EditType edit;
+        private int position;
+        private string text;
+
+
+        public EditCommand(EntryUndo manager,
+                           EditType edit,
+                           int position,
+                           string text) {
+            this.manager = manager;
+            this.edit = edit;
+            this.position = position;
+            this.text = text;
+        }
+
+        public override async void execute(GLib.Cancellable? cancellable)
+            throws GLib.Error {
+            // No-op, has already been executed
+        }
+
+        public override async void undo(GLib.Cancellable? cancellable)
+            throws GLib.Error {
+            EntryUndo? manager = this.manager;
+            if (manager != null) {
+                manager.events_enabled = false;
+                switch (this.edit) {
+                case INSERT:
+                    do_delete(manager.target);
+                    break;
+                case DELETE:
+                    do_insert(manager.target);
+                    break;
+                }
+                manager.events_enabled = true;
+            }
+        }
+
+        public override async void redo(GLib.Cancellable? cancellable)
+            throws GLib.Error {
+            EntryUndo? manager = this.manager;
+            if (manager != null) {
+                manager.events_enabled = false;
+                switch (this.edit) {
+                case INSERT:
+                    do_insert(manager.target);
+                    break;
+                case DELETE:
+                    do_delete(manager.target);
+                    break;
+                }
+                manager.events_enabled = true;
+            }
+        }
+
+        private void do_insert(Gtk.Entry target) {
+            int position = this.position;
+            target.insert_text(this.text, -1, ref position);
+            target.set_position(position);
+        }
+
+        private void do_delete(Gtk.Entry target) {
+            target.delete_text(
+                this.position, this.position + this.text.char_count()
+            );
+        }
+
+    }
+
+
+    public static void add_window_accelerators(GearyApplication application) {
+        application.set_accels_for_action(
+            ACTION_GROUP + "." + GearyApplication.ACTION_UNDO,
+            { "<Ctrl>z" }
+        );
+        application.set_accels_for_action(
+            ACTION_GROUP + "." + GearyApplication.ACTION_REDO,
+            { "<Ctrl><Shift>z" }
+        );
+    }
+
+
+    /** The entry being managed */
+    public Gtk.Entry target { get; private set; }
+
+    private Application.CommandStack commands;
+    private EditType last_edit = NONE;
+    private int edit_start = 0;
+    private int edit_end = 0;
+    private GLib.StringBuilder edit_accumuluator = new GLib.StringBuilder();
+
+    private bool events_enabled = true;
+
+    private GLib.SimpleActionGroup entry_actions = new SimpleActionGroup();
+
+
+    public EntryUndo(Gtk.Entry target) {
+        this.target = target;
+        this.target.insert_action_group(ACTION_GROUP, this.entry_actions);
+        this.target.insert_text.connect(on_inserted);
+        this.target.delete_text.connect(on_deleted);
+
+        this.commands = new Application.CommandStack();
+        this.commands.executed.connect(this.update_command_actions);
+        this.commands.undone.connect(this.update_command_actions);
+        this.commands.redone.connect(this.update_command_actions);
+
+        this.entry_actions.add_action_entries(EntryUndo.action_entries, this);
+    }
+
+    ~EntryUndo() {
+        this.target.insert_text.disconnect(on_inserted);
+        this.target.delete_text.disconnect(on_deleted);
+    }
+
+    /** Resets the editing stack for the target entry. */
+    public void reset() {
+        this.last_edit = NONE;
+        this.commands.clear();
+    }
+
+    private void execute(Application.Command command) {
+        bool complete = false;
+        this.commands.execute.begin(
+            command,
+            null,
+            (obj, res) => {
+                try {
+                    this.commands.execute.end(res);
+                } catch (GLib.Error thrown) {
+                    debug(
+                        "Failed to execute entry edit command: %s",
+                        thrown.message
+                    );
+                }
+                complete = true;
+            }
+        );
+        while (!complete) {
+            Gtk.main_iteration();
+        }
+    }
+
+    private void do_undo() {
+        flush_command();
+        bool complete = false;
+        this.commands.undo.begin(
+            null,
+            (obj, res) => {
+                try {
+                    this.commands.undo.end(res);
+                } catch (GLib.Error thrown) {
+                    debug(
+                        "Failed to undo entry edit command: %s",
+                        thrown.message
+                    );
+                }
+                complete = true;
+            }
+        );
+        while (!complete) {
+            Gtk.main_iteration();
+        }
+    }
+
+    private void do_redo() {
+        flush_command();
+        bool complete = false;
+        this.commands.redo.begin(
+            null,
+            (obj, res) => {
+                try {
+                    this.commands.redo.end(res);
+                } catch (GLib.Error thrown) {
+                    debug(
+                        "Failed to undo entry edit command: %s",
+                        thrown.message
+                    );
+                }
+                complete = true;
+            }
+        );
+        while (!complete) {
+            Gtk.main_iteration();
+        }
+    }
+
+    private void flush_command() {
+        EditCommand? command = extract_command();
+        if (command != null) {
+            execute(command);
+        }
+    }
+
+    private EditCommand? extract_command() {
+        EditCommand? command = null;
+        if (this.last_edit != NONE) {
+            command = new EditCommand(
+                this,
+                this.last_edit,
+                this.edit_start,
+                this.edit_accumuluator.str
+            );
+            this.edit_accumuluator.truncate();
+        }
+        this.last_edit = NONE;
+        return command;
+    }
+
+    private void update_command_actions() {
+        ((GLib.SimpleAction) this.entry_actions.lookup_action(
+            GearyApplication.ACTION_UNDO)).set_enabled(this.commands.can_undo);
+        ((GLib.SimpleAction) this.entry_actions.lookup_action(
+            GearyApplication.ACTION_REDO)).set_enabled(this.commands.can_redo);
+    }
+
+    private void on_inserted(string inserted, int inserted_len, ref int pos) {
+        if (this.events_enabled) {
+            // Normalise to something useful
+            inserted_len = inserted.char_count();
+
+            bool is_non_trivial = inserted_len > 1;
+            bool insert_handled = false;
+            if (this.last_edit == DELETE) {
+                Application.Command? command = extract_command();
+                if (command != null &&
+                    this.edit_start == pos &&
+                    is_non_trivial) {
+                    // Delete followed by a non-trivial insert at the
+                    // same position indicates something was probably
+                    // pasted/spellchecked/completed/etc, so execute
+                    // together as a single command.
+                    this.last_edit = INSERT;
+                    this.edit_start = pos;
+                    this.edit_accumuluator.append(inserted);
+                    command = new Application.CommandSequence({
+                            command, extract_command()
+                    });
+                    insert_handled = true;
+                }
+                if (command != null) {
+                    execute(command);
+                }
+            }
+
+            if (!insert_handled) {
+                bool is_disjoint_edit = (
+                    this.last_edit == INSERT && this.edit_end != pos
+                );
+                bool is_non_alpha_num = (
+                    inserted_len == 1 && !inserted.get_char(0).isalnum()
+                );
+
+                // Flush any existing edits if any of the special
+                // cases hold
+                if (is_disjoint_edit || is_non_alpha_num || is_non_trivial) {
+                    flush_command();
+                }
+
+                if (this.last_edit == NONE) {
+                    this.last_edit = INSERT;
+                    this.edit_start = pos;
+                    this.edit_end = pos;
+                }
+
+                this.edit_end += inserted_len;
+                this.edit_accumuluator.append(inserted);
+
+                // Flush the new edit if we don't want to coalesce
+                // with subsequent inserts
+                if (is_non_alpha_num || is_non_trivial) {
+                    flush_command();
+                }
+            }
+        }
+    }
+
+    private void on_deleted(int start, int end) {
+        if (this.events_enabled) {
+            // Normalise value of end to be something useful if needed
+            string text = this.target.buffer.get_text();
+            if (end < 0) {
+                end = text.char_count();
+            }
+
+            // Don't flush non-trivial deletes since we want to be
+            // able to combine them with non-trivial inserts for
+            // better handling of pasting/spell-checking
+            // replacement/etc.
+            bool is_disjoint_edit = (
+                this.last_edit == DELETE && this.edit_start != end
+            );
+            if (this.last_edit == INSERT || is_disjoint_edit) {
+                flush_command();
+            }
+
+            if (this.last_edit == NONE) {
+                this.last_edit = DELETE;
+                this.edit_end = end;
+            }
+
+            this.edit_start = start;
+            this.edit_accumuluator.prepend(
+                text.slice(
+                    text.index_of_nth_char(start),
+                    text.index_of_nth_char(end)
+                )
+            );
+        }
+    }
+
+    private void on_undo() {
+        do_undo();
+    }
+
+    private void on_redo() {
+        do_redo();
+    }
+
+}
diff --git a/src/client/meson.build b/src/client/meson.build
index cf64427b..47a261f6 100644
--- a/src/client/meson.build
+++ b/src/client/meson.build
@@ -27,6 +27,7 @@ geary_client_vala_sources = files(
 
   'components/client-web-view.vala',
   'components/components-attachment-pane.vala',
+  'components/components-entry-undo.vala',
   'components/components-inspector.vala',
   'components/components-in-app-notification.vala',
   'components/components-inspector-error-view.vala',


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]