geary/src/client/components/components-validator.vala

543 lines
18 KiB
Vala
Raw Normal View History

/*
* 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.
*/
/**
2025-08-08 09:44:29 +02:00
* Validates the contents of a Gtk Editable 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 {
/**
2025-08-08 09:44:29 +02:00
* The state of the editable monitored by this validator.
*
* Only {@link VALID} can be considered strictly valid, all other
* states should be treated as being invalid.
*/
public enum Validity {
2025-08-08 09:44:29 +02:00
/** The contents of the editable have not been validated. */
INDETERMINATE,
2025-08-08 09:44:29 +02:00
/** The contents of the editable is valid. */
VALID,
/**
2025-08-08 09:44:29 +02:00
* The contents of the editable is being checked.
*
* See {@link validate} for the use of this value.
*/
IN_PROGRESS,
2025-08-08 09:44:29 +02:00
/** The contents of the editable is required but not present. */
EMPTY,
2025-08-08 09:44:29 +02:00
/** The contents of the editable is not valid. */
INVALID;
}
/** The cause of a validity check being required. */
public enum Trigger {
/** A manual validation was requested via {@link validate}. */
MANUAL,
2025-08-08 09:44:29 +02:00
/** The editable's contents changed. */
CHANGED,
2025-08-08 09:44:29 +02:00
/** The editable lost the keyboard focus. */
LOST_FOCUS,
2025-08-08 09:44:29 +02:00
/** The user activated the editable. */
ACTIVATED;
}
/** Defines the UI state for a specific validity. */
protected struct UiState {
public string? icon_name;
public string? icon_tooltip_text;
}
2025-08-08 09:44:29 +02:00
/** The editable being monitored */
public Gtk.Editable target {
get { return this._target; }
construct set {
this._target = value;
if (value is Gtk.Entry) {
this.target_helper = new EntryHelper((Gtk.Entry) value);
} else if (value is Adw.EntryRow) {
this.target_helper = new EntryRowHelper((Adw.EntryRow) value);
} else {
critical("Validator for type '%s' unsupported", value.get_type().name());
}
}
}
private Gtk.Editable _target;
protected EditableHelper target_helper;
private Gtk.EventControllerFocus target_focus_controller = new Gtk.EventControllerFocus();
2025-08-08 09:44:29 +02:00
/** Determines if the current state indicates the editable is valid. */
public bool is_valid {
get { return (this.state == Validity.VALID); }
}
/**
* Determines how empty entries are treated.
*
2025-08-08 09:44:29 +02:00
* If true, an empty editable 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; }
2025-08-08 09:44:29 +02:00
/** The current validation state of the editable. */
public Validity state {
get; private set; default = Validity.INDETERMINATE;
}
// Determines if the value has changed since last validation
private bool target_changed = false;
private Geary.TimeoutManager ui_update_timer;
/** Fired when the validation state changes. */
public signal void state_changed(Trigger reason, Validity prev_state);
/** Fired when validation completes after the target has changed. */
public signal void changed();
/** Fired when validation completes after the target was activated. */
public signal void activated();
/** Fired when validation completes after the target lost focus. */
public signal void focus_lost();
2025-08-08 09:44:29 +02:00
construct {
this.ui_update_timer = new Geary.TimeoutManager.seconds(
2025-08-08 09:44:29 +02:00
1, on_update_ui
);
2025-08-08 09:44:29 +02:00
this.target_helper.activated.connect(on_activate);
this.target.changed.connect(on_changed);
2025-08-08 09:44:29 +02:00
this.target_focus_controller.leave.connect(on_focus_out);
this.target.add_controller(this.target_focus_controller);
}
public Validator(Gtk.Editable target) {
GLib.Object(target: target);
}
~Validator() {
this.ui_update_timer.reset();
}
/**
2025-08-08 09:44:29 +02:00
* Triggers a validation of the editable.
*
* In the case of an asynchronous validation implementations,
* result of the validation will be known sometime after this call
* has completed.
*/
public void validate() {
validate_entry(MANUAL);
}
/**
2025-08-08 09:44:29 +02:00
* Called to validate the target editable's value.
*
* This method will be called repeatedly as the user edits the
2025-08-08 09:44:29 +02:00
* value of the target editable to set the new validation {@link
* state} given the updated value. It will *not* be called if the
2025-08-08 09:44:29 +02:00
* editable 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
2025-08-08 09:44:29 +02:00
* the editable'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 do_validate(string value, Trigger reason) {
return Validity.VALID;
}
/**
2025-08-08 09:44:29 +02:00
* Updates the current validation state and the editable'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, Trigger reason) {
if (this.state != new_state) {
Validity old_state = this.state;
// Fire the signal after updating the state but before
// updating the UI so listeners can update UI settings
// first if needed.
this.state = new_state;
notify_property("is-valid");
state_changed(reason, old_state);
if (new_state == Validity.VALID || reason != Trigger.CHANGED) {
// Update the UI straight away when going valid or
// when editing is complete 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();
}
}
if (new_state != Validity.IN_PROGRESS) {
this.target_changed = false;
switch (reason) {
case Trigger.CHANGED:
changed();
break;
case Trigger.ACTIVATED:
activated();
break;
case Trigger.LOST_FOCUS:
focus_lost();
break;
case Trigger.MANUAL:
// no-op
break;
}
}
}
private void validate_entry(Trigger reason) {
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.VALID;
} else {
new_state = do_validate(value, reason);
}
update_state(new_state, reason);
}
private void update_ui(Validity state) {
this.ui_update_timer.reset();
2025-08-08 09:44:29 +02:00
this.target_helper.update_ui(state);
}
private void on_activate() {
if (this.target_changed) {
2025-08-08 09:44:29 +02:00
validate_entry(Trigger.ACTIVATED);
} else {
activated();
}
}
private void on_update_ui() {
update_ui(this.state);
}
private void on_changed() {
this.target_changed = true;
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();
}
2025-08-08 09:44:29 +02:00
private void on_focus_out(Gtk.EventControllerFocus controller) {
if (this.target_changed) {
// 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.
2025-08-08 09:44:29 +02:00
if (!this.target.is_focus()) {
validate_entry(Trigger.LOST_FOCUS);
}
} else {
focus_lost();
}
}
}
2025-08-08 09:44:29 +02:00
/**
* A helper class to set an icon, abstracting away the underlying API of the
* Gtk.Editable implementation.
*/
protected abstract class Components.EditableHelper : Object {
/** Tooltip text in case the validator returns an invalid state */
public string? invalid_tooltip_text { get; set; default = null; }
/** Tooltip text in case the validator returns an empty state */
public string? empty_tooltip_text { get; set; default = null; }
/** Emitted if the editable has been activated */
public signal void activated();
/** Sets an error icon with the given name and tooltip */
public abstract void update_ui(Validator.Validity state);
}
private class Components.EntryHelper : EditableHelper {
private unowned Gtk.Entry entry;
public EntryHelper(Gtk.Entry entry) {
this.entry = entry;
this.entry.activate.connect((e) => this.activated());
}
public override void update_ui(Validator.Validity state) {
this.entry.remove_css_class("error");
this.entry.remove_css_class("warning");
switch (state) {
case Validator.Validity.INDETERMINATE:
case Validator.Validity.VALID:
// Reset
this.entry.secondary_icon_name = "";
this.entry.secondary_icon_tooltip_text = "";
break;
case Validator.Validity.IN_PROGRESS:
this.entry.secondary_icon_paintable = new Adw.SpinnerPaintable(this.entry);
this.entry.secondary_icon_tooltip_text = _("Validating");
break;
case Validator.Validity.EMPTY:
this.entry.add_css_class("warning");
this.entry.secondary_icon_name = "dialog-warning-symbolic";
this.entry.secondary_icon_tooltip_text = this.empty_tooltip_text ?? "";
break;
case Validator.Validity.INVALID:
this.entry.add_css_class("error");
this.entry.secondary_icon_name = "dialog-error-symbolic";
this.entry.secondary_icon_tooltip_text = this.invalid_tooltip_text ?? "";
break;
}
}
}
private class Components.EntryRowHelper : EditableHelper {
private unowned Adw.EntryRow row;
private unowned Adw.Spinner? spinner = null;
private unowned Gtk.Image? error_image = null;
public EntryRowHelper(Adw.EntryRow row) {
this.row = row;
this.row.entry_activated.connect((e) => this.activated());
}
public override void update_ui(Validator.Validity state) {
reset();
this.row.remove_css_class("error");
this.row.remove_css_class("warning");
switch (state) {
case Validator.Validity.INDETERMINATE:
case Validator.Validity.VALID:
break;
case Validator.Validity.IN_PROGRESS:
var spinner = new Adw.Spinner();
spinner.tooltip_text = _("Validating");
this.row.add_suffix(spinner);
this.spinner = spinner;
break;
case Validator.Validity.EMPTY:
this.row.add_css_class("warning");
var img = new Gtk.Image.from_icon_name("dialog-warning-symbolic");
img.tooltip_text = this.empty_tooltip_text ?? "";
this.row.add_suffix(img);
this.error_image = img;
break;
case Validator.Validity.INVALID:
this.row.add_css_class("error");
var img = new Gtk.Image.from_icon_name("dialog-error-symbolic");
img.tooltip_text = this.invalid_tooltip_text ?? "";
this.row.add_suffix(img);
this.error_image = img;
break;
}
}
private void reset() {
if (this.spinner != null) {
this.row.remove(this.spinner);
this.spinner = null;
}
if (this.error_image != null) {
this.row.remove(this.error_image);
this.error_image = null;
}
}
}
/**
2025-08-08 09:44:29 +02:00
* A validator for GTK Editable widgets that contain an email address.
*/
public class Components.EmailValidator : Validator {
2025-08-08 09:44:29 +02:00
construct {
// Translators: Tooltip used when an editable requires a valid
// email address to be entered, but one is not provided.
2025-08-08 09:44:29 +02:00
this.target_helper.empty_tooltip_text = _("An email address is required");
2025-08-08 09:44:29 +02:00
// Translators: Tooltip used when an editablerequires a valid
// email address to be entered, but the address is invalid.
2025-08-08 09:44:29 +02:00
this.target_helper.invalid_tooltip_text = _("Not a valid email address");
}
2025-08-08 09:44:29 +02:00
public EmailValidator(Gtk.Editable target) {
GLib.Object(target: target);
}
protected override Validator.Validity do_validate(string value,
Validator.Trigger reason) {
return Geary.RFC822.MailboxAddress.is_valid_address(value)
? Validator.Validity.VALID : Validator.Validity.INVALID;
}
}
/**
2025-08-08 09:44:29 +02:00
* A validator for Gtk.Editable widgets that contain a network address.
*
2025-08-08 09:44:29 +02:00
* This attempts parse the editable value as a host name or IP address
* with an optional port, then resolve the host name if
* needed. Parsing is performed by {@link GLib.NetworkAddress.parse}
* to parse the user input, hence it may be specified in any form
* supported by that method.
*/
public class Components.NetworkAddressValidator : Validator {
/** The validated network address, if any. */
public GLib.NetworkAddress? validated_address {
get; private set; default = null;
}
/** The default port used when parsing the address. */
2025-08-08 09:44:29 +02:00
public uint16 default_port { get; construct set; }
private GLib.Resolver resolver;
private GLib.Cancellable? cancellable = null;
2025-08-08 09:44:29 +02:00
construct {
this.resolver = GLib.Resolver.get_default();
2025-08-08 09:44:29 +02:00
// Translators: Tooltip used when an editable requires a valid,
// resolvable server name to be entered, but one is not
// provided.
2025-08-08 09:44:29 +02:00
this.target_helper.empty_tooltip_text = _("A server name is required");
2025-08-08 09:44:29 +02:00
// Translators: Tooltip used when an editable requires a valid
// server name to be entered, but it was unable to be
// looked-up in the DNS.
2025-08-08 09:44:29 +02:00
this.target_helper.invalid_tooltip_text = _("Could not look up server name");
}
public NetworkAddressValidator(Gtk.Editable target, uint16 default_port = 0) {
GLib.Object(
target: target,
default_port: default_port
);
}
public override Validator.Validity do_validate(string value,
Validator.Trigger reason) {
if (this.cancellable != null) {
this.cancellable.cancel();
}
Validator.Validity ret = this.state;
GLib.NetworkAddress? address = null;
try {
address = GLib.NetworkAddress.parse(
value.strip(), this.default_port
);
} catch (GLib.Error err) {
this.validated_address = null;
ret = Validator.Validity.INVALID;
debug("Error parsing host name \"%s\": %s", value, err.message);
}
if (address != null) {
// Re-validate if previously invalid or the host has
// changed
if (this.validated_address == null ||
this.validated_address.hostname != address.hostname) {
this.cancellable = new GLib.Cancellable();
this.resolver.lookup_by_name_async.begin(
address.hostname, this.cancellable,
(obj, res) => {
try {
this.resolver.lookup_by_name_async.end(res);
this.validated_address = address;
update_state(Validator.Validity.VALID, reason);
} catch (GLib.IOError.CANCELLED err) {
this.validated_address = null;
} catch (GLib.Error err) {
this.validated_address = null;
update_state(Validator.Validity.INVALID, reason);
}
this.cancellable = null;
}
);
ret = Validator.Validity.IN_PROGRESS;
} else {
// Update the validated address in case the port
// number is being edited and has changed
this.validated_address = address;
ret = Validator.Validity.VALID;
}
}
return ret;
}
}