[geary/wip/794700-lazy-load-conversations: 12/19] Make loading conversations olive-buttery smooth



commit e9e4e8a277b7b4c96c32c084e09531e1898df2d7
Author: Michael Gratton <mike vee net>
Date:   Sun Jan 20 11:34:24 2019 +1030

    Make loading conversations olive-buttery smooth
    
    Remove the first/last child hacks from ConversationListBox since the
    GTK+ fix for :first-class and :last-class landed in early 3.22.x
    releases. Ensure the first expanded email is properly size-allocated
    before loading others, that it remains unmoving in the list as other
    rows are added, add a loading bar above it when there are more email to
    load below it.

 .../conversation-viewer/conversation-list-box.vala | 311 ++++++++++-----------
 src/client/util/util-gtk.vala                      |  14 +
 ui/client-web-view.js                              |   5 +-
 ui/geary.css                                       |  14 +-
 4 files changed, 173 insertions(+), 171 deletions(-)
---
diff --git a/src/client/conversation-viewer/conversation-list-box.vala 
b/src/client/conversation-viewer/conversation-list-box.vala
index 1972f4e5..519e51a4 100644
--- a/src/client/conversation-viewer/conversation-list-box.vala
+++ b/src/client/conversation-viewer/conversation-list-box.vala
@@ -47,8 +47,7 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
 
 
         protected const string EXPANDED_CLASS = "geary-expanded";
-        private const string FIRST_CLASS = "geary-first";
-        private const string LAST_CLASS = "geary-last";
+
 
         // The email being displayed by this row, if any
         public Geary.Email? email { get; private set; default = null; }
@@ -64,24 +63,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         }
         private bool _is_expanded = false;
 
-        // Designate this row as the first visible row in the
-        // conversation listbox, or not. See Bug 764710 and
-        // ::update_first_last_row() below.
-        internal bool is_first {
-            set {
-                set_style_context_class(FIRST_CLASS, value);
-            }
-        }
-
-        // Designate this row as the last visible row in the
-        // conversation listbox, or not. See Bug 764710 and
-        // ::update_first_last_row() below.
-        internal bool is_last {
-            set {
-                set_style_context_class(LAST_CLASS, value);
-            }
-        }
-
 
         // We can only scroll to a specific row once it has been
         // allocated space. This signal allows the viewer to hook up
@@ -202,6 +183,28 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
     }
 
 
+    // Displays a loading widget in the list box
+    private class LoadingRow : ConversationRow {
+
+
+        protected const string LOADING_CLASS = "geary-loading";
+
+
+        public LoadingRow() {
+            base(null);
+            get_style_context().add_class(LOADING_CLASS);
+
+            Gtk.Spinner spinner = new Gtk.Spinner();
+            spinner.height_request = 16;
+            spinner.width_request = 16;
+            spinner.show();
+            spinner.start();
+            add(spinner);
+        }
+
+    }
+
+
     // Displays a single embedded composer in the list box
     private class ComposerRow : ConversationRow {
 
@@ -290,9 +293,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
     // App config
     private Configuration config;
 
-    // Was this conversation loaded from the drafts folder?
-    private bool is_draft_folder;
-
     // Cancellable for this conversation's data loading.
     private Cancellable cancellable = new Cancellable();
 
@@ -306,10 +306,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
     // The id of the draft referred to by the current composer.
     private Geary.EmailIdentifier? draft_id = null;
 
-    // First and last visible row in the list, if any
-    private ConversationRow? first_row = null;
-    private ConversationRow? last_row = null;
-
     // Cached search terms to apply to new messages
     private Gee.Set<string>? search_terms = null;
 
@@ -387,11 +383,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         this.avatar_store = avatar_store;
         this.config = config;
 
-        this.is_draft_folder = (
-            conversation.base_folder.special_folder_type ==
-            Geary.SpecialFolderType.DRAFTS
-        );
-
         get_style_context().add_class("background");
         get_style_context().add_class("conversation-listbox");
 
@@ -442,116 +433,46 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
 
         // Work out what the first interesting email is, and load it
         // before all of the email before and after that so we can
-        // load them in an optimal order
-        EmailRow? first_interesting = null;
+        // load them in an optimal order.
         Gee.LinkedList<Geary.Email> uninteresting =
             new Gee.LinkedList<Geary.Email>();
+        Geary.Email? first_interesting = null;
+        Gee.LinkedList<Geary.Email> post_interesting =
+            new Gee.LinkedList<Geary.Email>();
         foreach (Geary.Email email in all_email) {
-            if (this.cancellable.is_cancelled()) {
-                throw new GLib.IOError.CANCELLED("Conversation load cancelled");
-            }
-
             if (first_interesting == null) {
                 if (email.is_unread().is_certain() ||
                     email.is_flagged().is_certain()) {
-                    // Found and an interesting email! So load it!
-                    EmailRow row = add_email(email);
-                    first_interesting = row;
-
-                    // Update first/last row after adding the first to
-                    // avoid the UI flashing as the border
-                    // appears/disappears
-                    update_first_last_row();
-
-                    yield row.expand(this.email_store, this.avatar_store);
+                    first_interesting = email;
                 } else {
                     // Inserted reversed so most recent uninteresting
-                    // rows are added first
+                    // rows are added first.
                     uninteresting.insert(0, email);
                 }
             } else {
-                // Already found and loaded an interesting email, so
-                // load the rest now normally.
-                EmailRow row = add_email(email);
-                if (email.is_unread().is_certain() ||
-                    email.is_flagged().is_certain() ||
-                    row.view.is_draft) {
-                    yield row.expand(this.email_store, this.avatar_store);
-                }
+                post_interesting.add(email);
             }
         }
 
         if (first_interesting == null) {
-            // No interesting messages found so none have been
-            // expanded yet, show the last one at least.
-            EmailRow row = add_email(
-                uninteresting.remove_at(0)
-            );
-            // Update first/last row after adding the first to
-            // avoid the UI flashing as the border
-            // appears/disappears
-            update_first_last_row();
-
-            yield row.expand(this.email_store, this.avatar_store);
-            first_interesting = row;
+            // No interesting messages found so use the last one.
+            first_interesting = uninteresting.remove_at(0);
         }
+        EmailRow interesting_row = add_email(first_interesting);
 
-        // Finally, load all of the uninteresting messages
-        if (!uninteresting.is_empty) {
-            bool added = false;
-            Gtk.Adjustment listbox_adj = get_adjustment();
-            foreach (Geary.Email email in uninteresting) {
-                // Give GTK a moment to process newly added rows, so
-                // when updating the adjustment below the values are
-                // valid. Priority must be low otherwise other async
-                // tasks (like cancelling loading if another
-                // conversation is selected) won't get a look in until
-                // this is done.
-                GLib.Idle.add(
-                    this.load_conversation.callback, GLib.Priority.LOW
-                );
-                yield;
-
-                // Check for cancellation after resuming in case the
-                // load was cancelled in the mean time.
-                if (this.cancellable.is_cancelled()) {
-                    throw new GLib.IOError.CANCELLED(
-                        "Conversation load cancelled"
-                    );
-                }
-
-                EmailRow row = add_email(email, false);
-                // Drafts aren't interesting?
-                if (row.view.is_draft) {
-                    yield row.expand(this.email_store, this.avatar_store);
-                }
-
-                if (!added) {
-                    added = true;
-                    update_first_last_row();
-                }
-
-                // Since uninteresting rows are inserted above the
-                // first expanded, adjust the scrollbar as they are
-                // inserted so as to keep the list scrolled to the
-                // same place.
-                row.enable_should_scroll();
-                row.should_scroll.connect(() => {
-                        Gtk.Allocation row_alloc;
-                        row.get_allocation(out row_alloc);
-                        listbox_adj.value += row_alloc.height;
-                    });
-            }
+        // If we have at least one uninteresting and one
+        // post-interesting to load afterwards, show a spinner above
+        // the interesting row to act as a placeholder.
+        if (!uninteresting.is_empty && !post_interesting.is_empty) {
+            insert(new LoadingRow(), 0);
         }
 
-        set_sort_func(on_sort);
-
-        if (query != null) {
-            // XXX this sucks for large conversations because it can take
-            // a long time for the load to complete and hence for
-            // matches to show up.
-            highlight_matching_email(query);
-        }
+        // Load the interesting row completely up front, and load the
+        // remaining in the background so we can return fast.
+        yield interesting_row.expand(this.email_store, this.avatar_store);
+        this.finish_loading.begin(
+            query, uninteresting, post_interesting
+        );
     }
 
     /** Cancels loading the current conversation, if still in progress */
@@ -619,7 +540,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         row.enable_should_scroll();
         row.should_scroll.connect(() => { scroll_to(row); });
         add(row);
-        update_first_last_row();
 
         embed.composer.draft_id_changed.connect((id) => { this.draft_id = id; });
         embed.vanished.connect(() => {
@@ -752,6 +672,81 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
             });
     }
 
+    private async void finish_loading(Geary.SearchQuery? query,
+                                      Gee.LinkedList<Geary.Email> to_insert,
+                                      Gee.LinkedList<Geary.Email> to_append)
+        throws GLib.Error {
+        // Add emails to append first because if the first interesting
+        // message was short, these will show up in the UI under it,
+        // filling the empty space.
+        foreach (Geary.Email email in to_append) {
+            EmailRow row = add_email(email);
+            if (is_interesting(email)) {
+                yield row.expand(this.email_store, this.avatar_store);
+            }
+            yield throttle_loading();
+        }
+
+        // Since first rows may have extra margin, remove that from
+        // the height of rows when adjusting scrolling.
+        Gtk.ListBoxRow initial_row = get_row_at_index(0);
+        int loading_height = 0;
+        if (initial_row is LoadingRow) {
+            loading_height = GtkUtil.get_border_box_height(initial_row);
+            remove(initial_row);
+        }
+
+        // None of these will be interesting, so just add them all,
+        // but keep the scrollbar adjusted so that the first
+        // interesting message remains visible.
+        Gtk.Adjustment listbox_adj = get_adjustment();
+        foreach (Geary.Email email in to_insert) {
+            EmailRow row = add_email(email, false);
+            // Since uninteresting rows are inserted above the
+            // first expanded, adjust the scrollbar as they are
+            // inserted so as to keep the list scrolled to the
+            // same place.
+            row.enable_should_scroll();
+            row.should_scroll.connect(() => {
+                    listbox_adj.value += GtkUtil.get_border_box_height(row);
+                });
+
+            // Only adjust for the loading row going away once
+            loading_height = 0;
+
+            yield throttle_loading();
+        }
+
+        set_sort_func(on_sort);
+
+        if (query != null) {
+            // XXX this sucks for large conversations because it can take
+            // a long time for the load to complete and hence for
+            // matches to show up.
+            yield highlight_matching_email(query);
+        }
+    }
+
+    private inline async void throttle_loading() throws GLib.IOError {
+        // Give GTK a moment to process newly added rows, so when
+        // updating the adjustment below the values are
+        // valid. Priority must be low otherwise other async tasks
+        // (like cancelling loading if another conversation is
+        // selected) won't get a look in until this is done.
+        GLib.Idle.add(
+            this.throttle_loading.callback, GLib.Priority.LOW
+        );
+        yield;
+
+        // Check for cancellation after resuming in case the load was
+        // cancelled in the mean time.
+        if (this.cancellable.is_cancelled()) {
+            throw new GLib.IOError.CANCELLED(
+                "Conversation load cancelled"
+            );
+        }
+    }
+
     // Loads full version of an email, adds it to the listbox
     private async void load_full_email(Geary.EmailIdentifier id)
         throws Error {
@@ -768,8 +763,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
 
         if (!this.cancellable.is_cancelled()) {
             EmailRow row = add_email(full_email);
-            update_first_last_row();
-
             row.view.load_avatar.begin(this.avatar_store);
             yield row.expand(this.email_store, this.avatar_store);
         }
@@ -777,12 +770,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
 
     // Constructs a row and view for an email, adds it to the listbox
     private EmailRow add_email(Geary.Email email, bool append_row = true) {
-        // Should be able to edit draft emails from any
-        // conversation. This test should be more like "is in drafts
-        // folder"
-        bool is_in_folder = this.conversation.is_in_base_folder(email.id);
-        bool is_draft = (this.is_draft_folder && is_in_folder);
-
         bool is_sent = false;
         Geary.Account account = this.conversation.base_folder.account;
         if (email.from != null) {
@@ -799,7 +786,7 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
             account.get_contact_store(),
             this.config,
             is_sent,
-            is_draft,
+            is_draft(email),
             this.cancellable
         );
         view.mark_email.connect(on_mark_email);
@@ -919,38 +906,6 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         }
     }
 
-    // Due to Bug 764710, we can only use the CSS :last-child selector
-    // for GTK themes after 3.20.3, so for now manually maintain a
-    // class on the last box so we can emulate it
-    private void update_first_last_row() {
-        ConversationRow? first = null;
-        ConversationRow? last = null;
-        this.foreach((child) => {
-                if (first == null) {
-                    first = (ConversationRow) child;
-                }
-                last = (ConversationRow) child;
-            });
-
-        if (this.first_row != first) {
-            if (this.first_row != null) {
-                this.first_row.is_first = false;
-            }
-
-            this.first_row = first;
-            this.first_row.is_first = true;
-        }
-
-        if (this.last_row != last) {
-            if (this.last_row != null) {
-                this.last_row.is_last = false;
-            }
-
-            this.last_row = last;
-            this.last_row.is_last = true;
-        }
-    }
-
     private void apply_search_terms(EmailRow row) {
         if (row.view.message_bodies_loaded) {
             this.apply_search_terms_impl.begin(row);
@@ -998,6 +953,30 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
         );
     }
 
+    /** Determines if an email should be expanded by default. */
+    private inline bool is_interesting(Geary.Email email) {
+        return (
+            email.is_unread().is_certain() ||
+            email.is_flagged().is_certain() ||
+            is_draft(email)
+        );
+    }
+
+    /** Determines if an email should be considered to be a draft. */
+    private inline bool is_draft(Geary.Email email) {
+        // XXX should be able to edit draft emails from any
+        // conversation. This test should be more like "is in drafts
+        // folder"
+        Geary.SpecialFolderType type =
+            this.conversation.base_folder.special_folder_type;
+        bool is_in_folder = this.conversation.is_in_base_folder(email.id);
+
+        return (
+            is_in_folder && type == Geary.SpecialFolderType.DRAFTS // ||
+            //email.flags.is_draft()
+        );
+    }
+
     private void on_conversation_appended(Geary.App.Conversation conversation,
                                           Geary.Email email) {
         on_conversation_appended_async.begin(conversation, email);
@@ -1080,7 +1059,7 @@ public class ConversationListBox : Gtk.ListBox, Geary.BaseInterface {
             // be appended last. Finally, don't let rows with active
             // composers be collapsed.
             if (row.is_expanded) {
-                if (row != this.last_row) {
+                if (get_row_at_index(row.get_index() + 1) != null) {
                     row.collapse();
                 }
             } else {
diff --git a/src/client/util/util-gtk.vala b/src/client/util/util-gtk.vala
index f07dff79..bcc6aa33 100644
--- a/src/client/util/util-gtk.vala
+++ b/src/client/util/util-gtk.vala
@@ -65,4 +65,18 @@ void menu_foreach(Menu menu, MenuForeachFunc foreach_func) {
  */
 delegate void MenuForeachFunc(string? label, string? action_name, Variant? target, Menu? section);
 
+/**
+ * Returns the CSS border box height for a widget.
+ *
+ * This adjusts the GTK widget's allocated height to exclude extra
+ * space added by the CSS margin property, if any.
+ */
+public inline int get_border_box_height(Gtk.Widget widget) {
+    Gtk.StyleContext style = widget.get_style_context();
+    Gtk.StateFlags flags = style.get_state();
+    Gtk.Border margin = style.get_margin(flags);
+
+    return widget.get_allocated_height() - margin.top - margin.bottom;
+}
+
 }
diff --git a/ui/client-web-view.js b/ui/client-web-view.js
index 76d4456e..aee2438c 100644
--- a/ui/client-web-view.js
+++ b/ui/client-web-view.js
@@ -52,8 +52,11 @@ PageState.prototype = {
         // Queues an update after the DOM has been initially loaded
         // and had any changes made to it by derived classes.
         document.addEventListener("DOMContentLoaded", function(e) {
+            // Always fire a prefered height update first so that it
+            // will be vaguegly correct when notifying of the HTML
+            // load completing.
+            state.updatePreferredHeight();
             state.loaded();
-            queuePreferredHeightUpdate();
         });
 
         // Queues an update when the complete document is loaded.
diff --git a/ui/geary.css b/ui/geary.css
index e41caa43..665a8947 100644
--- a/ui/geary.css
+++ b/ui/geary.css
@@ -77,7 +77,7 @@ row.geary-folder-popover-list-row > label {
 /* ConversationListBox */
 
 .conversation-listbox {
-  padding: 12px;
+  padding: 0 12px;
 }
 .conversation-listbox > row {
   margin: 0;
@@ -85,7 +85,6 @@ row.geary-folder-popover-list-row > label {
   border-bottom-width: 0;
   padding: 0;
   box-shadow: 0 4px 8px 1px rgba(0,0,0,0.4);
-  transition: margin 0.1s;
 }
 .conversation-listbox > row > box {
   background: @theme_base_color;
@@ -101,9 +100,16 @@ row.geary-folder-popover-list-row > label {
 .conversation-listbox *.geary-matched *.geary-match {
   color: @theme_selected_fg_color;
   background: @theme_selected_bg_color;
+;}
+.conversation-listbox > row.geary-loading {
+  border-top-width: 0px;
+  padding: 6px;
 }
-.conversation-listbox > row.geary-last {
-  margin-bottom: 0;
+.conversation-listbox > row:first-child:not(.geary-loading) {
+  margin-top: 12px;
+}
+.conversation-listbox > row:last-child {
+  margin-bottom: 12px;
 }
 
 /* ConversationEmail */


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