[geary/wip/714104-refine-account-dialog: 33/69] Introduce a validator class for checking values entered into Gtk.Entry



commit b86f817763f72e025f15e3d6e8661b610e1f2848
Author: Michael James Gratton <mike vee net>
Date:   Mon Jul 23 14:49:39 2018 +1000

    Introduce a validator class for checking values entered into Gtk.Entry
    
    Use it for validating account email addresses in the accounts editor.

 po/POTFILES.in                                     |   1 +
 src/client/accounts/accounts-editor-edit-pane.vala |  38 +--
 src/client/components/components-validator.vala    | 310 +++++++++++++++++++++
 src/client/meson.build                             |   1 +
 4 files changed, 317 insertions(+), 33 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 563bf032..6de67a72 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -38,6 +38,7 @@ src/client/application/goa-mediator.vala
 src/client/application/main.vala
 src/client/application/secret-mediator.vala
 src/client/components/client-web-view.vala
+src/client/components/components-validator.vala
 src/client/components/count-badge.vala
 src/client/components/empty-placeholder.vala
 src/client/components/folder-popover.vala
diff --git a/src/client/accounts/accounts-editor-edit-pane.vala 
b/src/client/accounts/accounts-editor-edit-pane.vala
index 7d7758df..7095de14 100644
--- a/src/client/accounts/accounts-editor-edit-pane.vala
+++ b/src/client/accounts/accounts-editor-edit-pane.vala
@@ -366,10 +366,8 @@ internal class Accounts.MailboxEditorPopover : EditorPopover {
 
     private Gtk.Entry name_entry = new Gtk.Entry();
     private Gtk.Entry address_entry = new Gtk.Entry();
+    private Components.EmailValidator address_validator;
     private Gtk.Button remove_button;
-    private bool is_valid = true;
-    private Geary.TimeoutManager validation_timeout;
-
 
     public signal void activated();
     public signal void remove_clicked();
@@ -381,10 +379,6 @@ internal class Accounts.MailboxEditorPopover : EditorPopover {
         this.display_name = display_name;
         this.address = address;
 
-        this.validation_timeout = new Geary.TimeoutManager.milliseconds(
-            150, () => { validate(); }
-        );
-
         this.name_entry.set_text(display_name ?? "");
         this.name_entry.set_placeholder_text(
             // Translators: This is used as a placeholder for the
@@ -410,6 +404,9 @@ internal class Accounts.MailboxEditorPopover : EditorPopover {
         this.address_entry.activate.connect(on_activate);
         this.address_entry.show();
 
+        this.address_validator =
+            new Components.EmailValidator(this.address_entry);
+
         this.remove_button = new Gtk.Button.with_label(_("Remove"));
         this.remove_button.halign = Gtk.Align.END;
         this.remove_button.get_style_context().add_class(
@@ -444,8 +441,6 @@ internal class Accounts.MailboxEditorPopover : EditorPopover {
     }
 
     ~MailboxEditorPopover() {
-        this.validation_timeout.reset();
-
         this.name_entry.changed.disconnect(on_name_changed);
         this.name_entry.activate.disconnect(on_activate);
 
@@ -455,35 +450,12 @@ internal class Accounts.MailboxEditorPopover : EditorPopover {
         this.remove_button.clicked.disconnect(on_remove_clicked);
     }
 
-    private void validate() {
-        Gtk.Entry entry = this.address_entry;
-        this.is_valid = Geary.RFC822.MailboxAddress.is_valid_address(
-            this.address
-        );
-        Gtk.StyleContext style = entry.get_style_context();
-        Gtk.EntryIconPosition pos = Gtk.EntryIconPosition.SECONDARY;
-        if (!this.is_valid) {
-            style.add_class(Gtk.STYLE_CLASS_ERROR);
-            entry.set_icon_from_icon_name(
-                pos, "dialog-error-symbolic"
-            );
-            entry.set_tooltip_text(
-                _("Email address is not valid, e.g. person example com")
-            );
-        } else {
-            style.remove_class(Gtk.STYLE_CLASS_ERROR);
-            entry.set_icon_from_icon_name(pos, null);
-            entry.set_tooltip_text("");
-        }
-    }
-
     private void on_name_changed() {
         this.display_name = this.name_entry.get_text().strip();
     }
 
     private void on_address_changed() {
         this.address = this.address_entry.get_text().strip();
-        this.validation_timeout.start();
     }
 
     private void on_remove_clicked() {
@@ -491,7 +463,7 @@ internal class Accounts.MailboxEditorPopover : EditorPopover {
     }
 
     private void on_activate() {
-        if (this.address != "" && this.is_valid) {
+        if (this.address != "" && this.address_validator.is_valid) {
             activated();
         }
     }
diff --git a/src/client/components/components-validator.vala b/src/client/components/components-validator.vala
new file mode 100644
index 00000000..be23e122
--- /dev/null
+++ b/src/client/components/components-validator.vala
@@ -0,0 +1,310 @@
+/*
+ * Copyright 2018 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.
+ */
+
+/**
+ * Validates the contents of a Gtk Entry as they are entered.
+ *
+ * This class may be used to validate required, but otherwise free
+ * form entries. Subclasses may perform more complex and task-specific
+ * validation.
+ */
+public class Components.Validator : GLib.Object {
+
+
+    private const Gtk.EntryIconPosition ICON_POS =
+        Gtk.EntryIconPosition.SECONDARY;
+
+
+    /**
+     * The state of the entry monitored by this validator.
+     *
+     * Only {@link VALID} can be considered strictly valid, all other
+     * states should be treated as being invalid.
+     */
+    public enum Validity {
+        /** The contents of the entry have not been validated. */
+        INDETERMINATE,
+
+        /** The contents of the entry is valid. */
+        VALID,
+
+        /**
+         * The contents of the entry is being checked.
+         *
+         * See {@link validate} for the use of this value.
+         */
+        IN_PROGRESS,
+
+        /** The contents of the entry is required but not present. */
+        EMPTY,
+
+        /** The contents of the entry is not valid. */
+        INVALID;
+    }
+
+    /** The cause of a validity check being required. */
+    protected enum Trigger {
+        /** The entry's contents changed */
+        CHANGED,
+        /** The user performed an action indicating they are done. */
+        COMPLETE;
+    }
+
+    /** Defines the UI state for a specific validity. */
+    protected struct UiState {
+        public string? icon_name;
+        public string? icon_tooltip_text;
+    }
+
+    /** The entry being monitored */
+    public Gtk.Entry target { get; private set; }
+
+    /** Determines if the current state indicates the entry is valid. */
+    public bool is_valid {
+        get { return (this.state == Validity.VALID); }
+    }
+
+    /**
+     * Determines how empty entries are treated.
+     *
+     * If true, an empty entry is considered {@link Validity.EMPTY}
+     * (i.e. invalid) else it is considered to be {@link
+     * Validity.INDETERMINATE}.
+     */
+    public bool is_required { get; set; default = true; }
+
+    /** The current validation state of the entry. */
+    public Validity state {
+        get; private set; default = Validity.INDETERMINATE;
+    }
+
+    /** The UI state to use when indeterminate. */
+    public UiState indeterminate_state;
+
+    /** The UI state to use when valid. */
+    public UiState valid_state;
+
+    /** The UI state to use when in progress. */
+    public UiState in_progress_state;
+
+    /** The UI state to use when empty. */
+    public UiState empty_state;
+
+    /** The UI state to use when invalid. */
+    public UiState invalid_state;
+
+    private Geary.TimeoutManager ui_update_timer;
+
+
+    public Validator(Gtk.Entry target) {
+        this.target = target;
+
+        this.ui_update_timer = new Geary.TimeoutManager.seconds(
+            2, on_update_ui
+        );
+
+        this.indeterminate_state = {
+            target.get_icon_name(ICON_POS),
+            target.get_icon_tooltip_text(ICON_POS)
+        };
+        this.valid_state = {
+            target.get_icon_name(ICON_POS),
+            target.get_icon_tooltip_text(ICON_POS)
+        };
+        this.in_progress_state = { "process-working-symbolic", null};
+        this.empty_state = { "dialog-warning-symbolic", null };
+        this.invalid_state = { "dialog-error-symbolic", null };
+
+        this.target.add_events(Gdk.EventMask.FOCUS_CHANGE_MASK);
+        this.target.activate.connect(on_activate);
+        this.target.changed.connect(on_changed);
+        this.target.focus_out_event.connect(on_focus_out);
+    }
+
+    ~Validator() {
+        this.target.focus_out_event.disconnect(on_focus_out);
+        this.target.changed.disconnect(on_changed);
+        this.target.activate.disconnect(on_activate);
+        this.ui_update_timer.reset();
+    }
+
+    /**
+     * Called to validate the target entry's value.
+     *
+     * This method will be called repeatedly as the user edits the
+     * value of the target entry to set the new validation {@link
+     * state} given the updated value. It will *not* be called if the
+     * entry is changed to be empty, instead the validity state will
+     * be set based on {@link is_required}.
+     *
+     * Subclasses may override this method to implement custom
+     * validation routines. Since this call is made repeatedly as the
+     * user is typing, it should not perform a CPU-intensive or
+     * long-running routine. Subclasses that do perform such
+     * validation should launch the routine in the background (either
+     * asynchronously or in a thread) and return {@link
+     * Validity.IN_PROGRESS} as soon as possible. Then, when it is
+     * complete, call {@link update_state} to update the validity state
+     * with the actual result.
+     *
+     * The given reason specifies which user action was taken to cause
+     * the entry's value to be validated.
+     *
+     * By default, this always returns {@link Validity.VALID}, making
+     * it useful for required, but otherwise free-form fields only.
+     */
+    protected virtual Validity validate(string value, Trigger cause) {
+        return Validity.VALID;
+    }
+
+    /**
+     * Updates the current validation state and the entry's UI.
+     *
+     * This should only be called by subclasses that implement a
+     * CPU-intensive or long-running validation routine and it has
+     * completed validating a value. See {@link validate} for details.
+     */
+    protected void update_state(Validity new_state) {
+        if (this.state != new_state) {
+            Validity old_state = this.state;
+
+            this.state = new_state;
+            if (new_state == Validity.VALID) {
+                // Update the UI straight away when going valid to
+                // provide instant feedback
+                update_ui(new_state);
+            } else {
+                if (old_state == Validity.EMPTY) {
+                    // Technically this is a lie, but when going from
+                    // empty to non-empty we also want to provide
+                    // instant feedback, and going to indeterminate
+                    // when the user is in the middle of editing is
+                    // better than going to invalid.
+                    update_ui(Validity.INDETERMINATE);
+                }
+                // Start the a timer running to update the UI to give
+                // the timer running since they might still be editing
+                // it.
+                this.ui_update_timer.start();
+            }
+        }
+    }
+
+    private void validate_entry(Trigger cause) {
+        string value = this.target.get_text();
+        Validity new_state = this.state;
+        if (Geary.String.is_empty_or_whitespace(value)) {
+            new_state = this.is_required
+                ? Validity.EMPTY : Validity.INDETERMINATE;
+        } else {
+            new_state = validate(value, cause);
+        }
+
+        update_state(new_state);
+
+        if (cause == Trigger.COMPLETE) {
+            // Update the UI instantly since we know the user is done
+            // editing it an will want instant feedback.
+            update_ui(this.state);
+        }
+    }
+
+    private void update_ui(Validity state) {
+        this.ui_update_timer.reset();
+
+        Gtk.StyleContext style = this.target.get_style_context();
+        style.remove_class(Gtk.STYLE_CLASS_ERROR);
+        style.remove_class(Gtk.STYLE_CLASS_WARNING);
+
+        UiState ui = { null, null };
+        switch (state) {
+        case Validity.INDETERMINATE:
+            ui = this.indeterminate_state;
+            break;
+
+        case Validity.VALID:
+            ui = this.valid_state;
+            break;
+
+        case Validity.IN_PROGRESS:
+            ui = this.in_progress_state;
+            break;
+
+        case Validity.EMPTY:
+            style.add_class(Gtk.STYLE_CLASS_WARNING);
+            ui = this.empty_state;
+            break;
+
+        case Validity.INVALID:
+            style.add_class(Gtk.STYLE_CLASS_ERROR);
+            ui = this.invalid_state;
+            break;
+        }
+
+        this.target.set_icon_from_icon_name(ICON_POS, ui.icon_name);
+        this.target.set_icon_tooltip_text(
+            ICON_POS,
+            // Setting the tooltip to null or the empty string can
+            // cause GTK+ to setfult. See GTK+ issue #1160.
+            Geary.String.is_empty(ui.icon_tooltip_text)
+                ? " " : ui.icon_tooltip_text
+        );
+    }
+
+    private void on_activate() {
+        validate_entry(Trigger.COMPLETE);
+    }
+
+    private void on_update_ui() {
+        update_ui(this.state);
+    }
+
+    private void on_changed() {
+        validate_entry(Trigger.CHANGED);
+        // Restart the UI timer if running to give the user some
+        // breathing room while they are still editing.
+        this.ui_update_timer.start();
+    }
+
+    private bool on_focus_out() {
+        // Only update if the widget has lost focus due to not being
+        // the focused widget any more, rather than the whole window
+        // having lost focus.
+        if (!this.target.is_focus) {
+            validate_entry(Trigger.COMPLETE);
+        }
+        return Gdk.EVENT_PROPAGATE;
+    }
+
+}
+
+
+/**
+ * A validator for GTK Entry widgets that contain an email address.
+ */
+public class Components.EmailValidator : Validator {
+
+    public EmailValidator(Gtk.Entry target) {
+        base(target);
+
+        // Translators: Tooltip used when an entry requires a valid
+        // email address to be entered, but one is not provided.
+        this.empty_state.icon_tooltip_text = _("An email address is required");
+
+        // Translators: Tooltip used when an entry requires a valid
+        // email address to be entered, but the address is invalid.
+        this.invalid_state.icon_tooltip_text = _("Not a valid email address");
+    }
+
+
+    protected override Validator.Validity validate(string value,
+                                                   Validator.Trigger cause) {
+        return Geary.RFC822.MailboxAddress.is_valid_address(value)
+            ? Validator.Validity.VALID : Validator.Validity.INVALID;
+    }
+
+}
diff --git a/src/client/meson.build b/src/client/meson.build
index 9e45b4e4..baede7ac 100644
--- a/src/client/meson.build
+++ b/src/client/meson.build
@@ -32,6 +32,7 @@ geary_client_vala_sources = files(
   'accounts/login-dialog.vala',
 
   'components/client-web-view.vala',
+  'components/components-validator.vala',
   'components/count-badge.vala',
   'components/empty-placeholder.vala',
   'components/folder-popover.vala',


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