[geary] New scrolling system for inline composers



commit c36ff017e62585b63718b0f185c5edabfbde59ca
Author: Robert Schroll <rschroll gmail com>
Date:   Wed Jun 11 13:12:04 2014 -0400

    New scrolling system for inline composers
    
    The editor in the composer no longer shows its own scrollbar.  Instead,
    the conversation view allocates enough space to hold the composer
    without any scrolling.  The composer then positions and scrolls itself
    to create the illusion that the outer scroll bar controls it.
    
    To track the size of the composer, we put all the text in a div and mark
    that as contenteditable.  Then, when the div changes height, we update
    the layout.  We use the user_changed_contents signal as a proxy for
    this.
    
    https://bugzilla.gnome.org/show_bug.cgi?id=730955

 src/client/composer/composer-embed.vala            |  170 +++++++++++++++++++-
 src/client/composer/composer-widget.vala           |   34 +++--
 src/client/composer/scrollable-overlay.vala        |    3 +-
 .../conversation-viewer/conversation-web-view.vala |    3 +-
 src/client/util/util-webkit.vala                   |   10 +-
 theming/message-viewer.css                         |    2 +-
 6 files changed, 197 insertions(+), 25 deletions(-)
---
diff --git a/src/client/composer/composer-embed.vala b/src/client/composer/composer-embed.vala
index e195d4d..3bcc270 100644
--- a/src/client/composer/composer-embed.vala
+++ b/src/client/composer/composer-embed.vala
@@ -6,10 +6,17 @@
 
 public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
     
+    private const int MIN_EDITOR_HEIGHT = 200;
+    
     private ComposerWidget composer;
     private ConversationViewer conversation_viewer;
     private Gee.Set<Geary.App.Conversation>? prev_selection = null;
     private string embed_id;
+    private bool setting_inner_scroll;
+    private bool scrolled_to_bottom = false;
+    private double inner_scroll_adj_value;
+    private int inner_view_height;
+    private int min_height = MIN_EDITOR_HEIGHT;
     
     public Gtk.Window top_window {
         get { return (Gtk.Window) get_toplevel(); }
@@ -49,14 +56,64 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
         }
         
         add(composer);
-        realize.connect(update_style);
+        realize.connect(on_realize);
         composer.editor.focus_in_event.connect(on_focus_in);
         composer.editor.focus_out_event.connect(on_focus_out);
+        composer.editor.document_load_finished.connect(on_loaded);
         conversation_viewer.compose_overlay.add_overlay(this);
         show();
         present();
     }
     
+    private void on_realize() {
+        update_style();
+        
+        if (composer.state != ComposerWidget.ComposerState.INLINE_NEW) {
+            Gtk.ScrolledWindow win = (Gtk.ScrolledWindow) composer.editor.parent;
+            win.get_vscrollbar().hide();
+            
+            composer.editor.vadjustment.value_changed.connect(on_inner_scroll);
+            composer.editor.vadjustment.changed.connect(on_adjust_changed);
+            composer.editor.user_changed_contents.connect(on_inner_size_changed);
+            
+            reroute_scroll_handling(this);
+        }
+    }
+    
+    private void on_loaded() {
+        if (composer.state != ComposerWidget.ComposerState.INLINE_NEW) {
+            try {
+               composer.editor.get_dom_document().body.get_class_list().add("embedded");
+            } catch (Error error) {
+                debug("Error setting class of editor: %s", error.message);
+            }
+            Idle.add(() => {
+                recalc_height();
+                conversation_viewer.compose_overlay.queue_resize();
+                return false;
+            });
+        }
+    }
+    
+    private void reroute_scroll_handling(Gtk.Widget widget) {
+        widget.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK);
+        widget.scroll_event.connect(on_inner_scroll_event);
+        Gtk.Container? container = widget as Gtk.Container;
+        if (container != null) {
+            foreach (Gtk.Widget child in container.get_children())
+                reroute_scroll_handling(child);
+        }
+    }
+    
+    private void disable_scroll_reroute(Gtk.Widget widget) {
+        widget.scroll_event.disconnect(on_inner_scroll_event);
+        Gtk.Container? container = widget as Gtk.Container;
+        if (container != null) {
+            foreach (Gtk.Widget child in container.get_children())
+                disable_scroll_reroute(child);
+        }
+    }
+    
     private void update_style() {
         Gdk.RGBA window_background = top_window.get_style_context()
             .get_background_color(Gtk.StateFlags.NORMAL);
@@ -76,8 +133,19 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
             on_focus_out();
         composer.editor.focus_in_event.disconnect(on_focus_in);
         composer.editor.focus_out_event.disconnect(on_focus_out);
+        composer.editor.vadjustment.value_changed.disconnect(on_inner_scroll);
+        composer.editor.user_changed_contents.disconnect(on_inner_size_changed);
+        disable_scroll_reroute(this);
+        Gtk.ScrolledWindow win = (Gtk.ScrolledWindow) composer.editor.parent;
+        win.get_vscrollbar().show();
         Gtk.Widget focus = top_window.get_focus();
         
+        try {
+            composer.editor.get_dom_document().body.get_class_list().remove("embedded");
+        } catch (Error error) {
+            debug("Error setting class of editor: %s", error.message);
+        }
+        
         remove(composer);
         ComposerWindow window = new ComposerWindow(composer);
         if (focus != null) {
@@ -90,15 +158,50 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
         close_container();
     }
     
-    public bool set_position(ref Gdk.Rectangle allocation, double hscroll, double vscroll) {
+    public bool set_position(ref Gdk.Rectangle allocation, double hscroll, double vscroll,
+        int view_height) {
         WebKit.DOM.Element embed = 
conversation_viewer.web_view.get_dom_document().get_element_by_id(embed_id);
         if (embed == null)
             return false;
         
+        int div_height = (int) embed.client_height;
+        int y_top = (int) (embed.offset_top + embed.client_top) - (int) vscroll;
+        int available_height = int.min(y_top + div_height, view_height) - int.max(y_top, 0);
+        
+        if (available_height < 0 || available_height == div_height ||
+            composer.state == ComposerWidget.ComposerState.INLINE_NEW) {
+            // It fits in the available space, or it doesn't fit at all
+            allocation.y = y_top;
+            // When offscreen, make it very small to ensure scrolling during any edit
+            allocation.height = (available_height < 0) ? 1 : div_height;
+        } else if (available_height > min_height) {
+            // There's enough room, so make sure we get the whole widget in
+            allocation.y = int.max(y_top, 0);
+            allocation.height = available_height;
+        } else {
+            // Minimum height widget, placed so as much as possible is visible
+            allocation.y = int.max(y_top, int.min(y_top + div_height - min_height, 0));
+            allocation.height = min_height;
+        }
         allocation.x = (int) (embed.offset_left + embed.client_left) - (int) hscroll;
-        allocation.y = (int) (embed.offset_top + embed.client_top) - (int) vscroll;
         allocation.width = (int) embed.client_width;
-        allocation.height = (int) embed.client_height;
+        
+        // INLINE_NEW handles its own scrolling.
+        if (composer.state == ComposerWidget.ComposerState.INLINE_NEW)
+            return true;
+        
+        // Work out adjustment of composer web view
+        setting_inner_scroll = true;
+        composer.editor.vadjustment.set_value(allocation.y - y_top);
+        setting_inner_scroll = false;
+        // This sets the scroll before the widget gets resized.  Although the adjustment
+        // may be scrolled to the bottom right now, the current value may not do that
+        // once the widget is shrunk; for example, while scrolling down the page past
+        // the bottom of the editor.  So if we're at the bottom, record that fact.  When
+        // the limits of the adjustment are changed (watched by on_adjust_changed), we
+        // can keep it at the bottom.
+        scrolled_to_bottom = (y_top <= 0 && available_height < view_height);
+        
         return true;
     }
     
@@ -112,9 +215,66 @@ public class ComposerEmbed : Gtk.EventBox, ComposerContainer {
         return false;
     }
     
+    private void on_inner_scroll(Gtk.Adjustment adj) {
+        double delta = adj.value - inner_scroll_adj_value;
+        inner_scroll_adj_value = adj.value;
+        if (delta != 0 && !setting_inner_scroll) {
+            Gtk.Adjustment outer_adj = conversation_viewer.web_view.vadjustment;
+            outer_adj.set_value(outer_adj.value + delta);
+        }
+    }
+    
+    private void on_adjust_changed(Gtk.Adjustment adj) {
+        if (scrolled_to_bottom) {
+            setting_inner_scroll = true;
+            adj.set_value(adj.upper);
+            setting_inner_scroll = false;
+        }
+    }
+    
+    private void on_inner_size_changed() {
+        scrolled_to_bottom = false;  // The inserted character may cause a desired scroll
+        Idle.add(recalc_height);  // So that this runs after the character has been inserted
+    }
+    
+    private bool recalc_height() {
+        int view_height,
+            base_height = get_allocated_height() - composer.editor.get_allocated_height();
+        try {
+            view_height = (int) composer.editor.get_dom_document()
+                .query_selector("#message-body").offset_height;
+        } catch (Error error) {
+            debug("Error getting height of editor: %s", error.message);
+            return false;
+        }
+        
+        if (view_height != inner_view_height || min_height != base_height + MIN_EDITOR_HEIGHT) {
+            inner_view_height = view_height;
+            min_height = base_height + MIN_EDITOR_HEIGHT;
+            // Calculate height widget should be to avoid scrolling in editor
+            int widget_height = int.max(view_height + base_height - 2, min_height); //? about 2
+            WebKit.DOM.Element embed = conversation_viewer.web_view
+                .get_dom_document().get_element_by_id(embed_id);
+            if (embed != null) {
+                try {
+                    embed.style.set_property("height", @"$widget_height", "");
+                } catch (Error error) {
+                    debug("Error setting height of composer widget");
+                }
+            }
+        }
+        return false;
+    }
+    
+    private bool on_inner_scroll_event(Gdk.EventScroll event) {
+        conversation_viewer.web_view.scroll_event(event);
+        return true;
+    }
+    
     public void present() {
         top_window.present();
-        conversation_viewer.web_view.get_dom_document().get_element_by_id(embed_id).scroll_into_view(true);
+        conversation_viewer.web_view.get_dom_document().get_element_by_id(embed_id)
+            .scroll_into_view_if_needed(false);
     }
     
     public unowned Gtk.Widget get_focus() {
diff --git a/src/client/composer/composer-widget.vala b/src/client/composer/composer-widget.vala
index 2ef4f25..7ab2b5c 100644
--- a/src/client/composer/composer-widget.vala
+++ b/src/client/composer/composer-widget.vala
@@ -65,7 +65,7 @@ public class ComposerWidget : Gtk.EventBox {
         <html><head><title></title>
         <style>
         body {
-            margin: 10px !important;
+            margin: 0px !important;
             padding: 0 !important;
             background-color: white !important;
             font-size: medium !important;
@@ -81,6 +81,15 @@ public class ComposerWidget : Gtk.EventBox {
         body.plain a {
             cursor: text;
         }
+        #message-body {
+            box-sizing: border-box;
+            padding: 10px;
+            outline: 0px solid transparent;
+            min-height: 100%;
+        }
+        .embedded #message-body {
+            min-height: 200px;
+        }
         blockquote {
             margin-top: 0px;
             margin-bottom: 0px;
@@ -97,7 +106,9 @@ public class ComposerWidget : Gtk.EventBox {
             margin: 0;
         }
         </style>
-        </head><body id="message-body"></body></html>""";
+        </head><body>
+        <div id="message-body" contenteditable="true"></div>
+        </body></html>""";
     
     private const int DRAFT_TIMEOUT_MSEC = 2000; // 2 seconds
     
@@ -262,12 +273,6 @@ public class ComposerWidget : Gtk.EventBox {
                 });
         }
         notify["state"].connect((s, p) => { update_from_field(); });
-        // Set the visibilities later, after show_all is called on the widget.
-        Idle.add(() => {
-            state = state;  // Triggers visibilities
-            show_attachments();
-            return false;
-        });
         
         from_label = (Gtk.Label) builder.get_object("from label");
         from_single = (Gtk.Label) builder.get_object("from_single");
@@ -444,7 +449,6 @@ public class ComposerWidget : Gtk.EventBox {
         editor = new StylishWebView();
         edit_fixer = new WebViewEditFixer(editor);
 
-        editor.editable = true;
         editor.load_finished.connect(on_load_finished);
         editor.hovering_over_link.connect(on_hovering_over_link);
         editor.context_menu.connect(on_context_menu);
@@ -595,10 +599,11 @@ public class ComposerWidget : Gtk.EventBox {
                 debug("Failed to load prefilled body: %s", e.message);
             }
         }
+        body.focus();  // Focus within the HTML document
 
         protect_blockquote_styles();
         
-        set_focus();
+        set_focus();  // Focus in the GTK widget hierarchy
         
         // Ensure the editor is in correct mode re HTML
         on_compose_as_html();
@@ -731,7 +736,10 @@ public class ComposerWidget : Gtk.EventBox {
     
     public override void show_all() {
         base.show_all();
+        // Now, hide elements that we don't want shown
         update_from_field();
+        state = state;  // Triggers visibilities
+        show_attachments();
     }
     
     public void change_compose_type(ComposeType new_type) {
@@ -1554,11 +1562,13 @@ public class ComposerWidget : Gtk.EventBox {
     }
     
     private string get_html() {
-        return editor.get_dom_document().get_body().get_inner_html();
+        return ((WebKit.DOM.HTMLElement) editor.get_dom_document().get_element_by_id(BODY_ID))
+            .get_inner_html();
     }
     
     private string get_text() {
-        return html_to_flowed_text(editor.get_dom_document());
+        return html_to_flowed_text((WebKit.DOM.HTMLElement) editor.get_dom_document()
+            .get_element_by_id(BODY_ID));
     }
     
     private bool on_navigation_policy_decision_requested(WebKit.WebFrame frame,
diff --git a/src/client/composer/scrollable-overlay.vala b/src/client/composer/scrollable-overlay.vala
index 188eeff..65398fb 100644
--- a/src/client/composer/scrollable-overlay.vala
+++ b/src/client/composer/scrollable-overlay.vala
@@ -37,7 +37,8 @@ public class ScrollableOverlay : Gtk.Overlay, Gtk.Scrollable {
     }
     
     private bool on_child_position(Gtk.Widget widget, Gdk.Rectangle allocation) {
-         return ((ComposerEmbed) widget).set_position(ref allocation, hadjustment.value, vadjustment.value);
+        return ((ComposerEmbed) widget).set_position(ref allocation, hadjustment.value,
+            vadjustment.value, get_allocated_height());
     }
     
     private void on_scroll() {
diff --git a/src/client/conversation-viewer/conversation-web-view.vala 
b/src/client/conversation-viewer/conversation-web-view.vala
index 96d1808..4dc60a7 100644
--- a/src/client/conversation-viewer/conversation-web-view.vala
+++ b/src/client/conversation-viewer/conversation-web-view.vala
@@ -43,6 +43,7 @@ public class ConversationWebView : StylishWebView {
         new_window_policy_decision_requested.connect(on_navigation_policy_decision_requested);
         web_inspector.inspect_web_view.connect(activate_inspector);
         document_font_changed.connect(on_document_font_changed);
+        scroll_event.connect(on_scroll_event);
         
         // Load the HTML into WebKit.
         // Note: load_finished signal MUST be hooked up before this call.
@@ -55,7 +56,7 @@ public class ConversationWebView : StylishWebView {
         return false;
     }
     
-    public override bool scroll_event(Gdk.EventScroll event) {
+    private bool on_scroll_event(Gdk.EventScroll event) {
         if ((event.state & Gdk.ModifierType.CONTROL_MASK) != 0) {
             double dir = 0;
             if (event.direction == Gdk.ScrollDirection.UP)
diff --git a/src/client/util/util-webkit.vala b/src/client/util/util-webkit.vala
index d5eb48e..e998bd4 100644
--- a/src/client/util/util-webkit.vala
+++ b/src/client/util/util-webkit.vala
@@ -293,11 +293,11 @@ public string decorate_quotes(string text) throws Error {
 }
 
 // This will modify/reset the DOM
-public string html_to_flowed_text(WebKit.DOM.Document doc) {
-    string saved_doc = doc.get_body().get_inner_html();
+public string html_to_flowed_text(WebKit.DOM.HTMLElement el) {
+    string saved_doc = el.get_inner_html();
     WebKit.DOM.NodeList blockquotes;
     try {
-        blockquotes = doc.query_selector_all("blockquote");
+        blockquotes = el.query_selector_all("blockquote");
     } catch (Error error) {
         debug("Error selecting blockquotes: %s", error.message);
         return "";
@@ -326,11 +326,11 @@ public string html_to_flowed_text(WebKit.DOM.Document doc) {
     }
     
     // Reassemble plain text out of parts, replace non-breaking space with regular space
-    string doctext = resolve_nesting(doc.get_body().get_inner_text(), bqtexts).replace("\xc2\xa0", " ");
+    string doctext = resolve_nesting(el.get_inner_text(), bqtexts).replace("\xc2\xa0", " ");
     
     // Reassemble DOM
     try {
-        doc.get_body().set_inner_html(saved_doc);
+        el.set_inner_html(saved_doc);
     } catch (Error error) {
         debug("Error resetting DOM: %s", error.message);
     }
diff --git a/theming/message-viewer.css b/theming/message-viewer.css
index fef37b0..1dc65ab 100644
--- a/theming/message-viewer.css
+++ b/theming/message-viewer.css
@@ -120,7 +120,7 @@ hr {
     left: auto;
     right: auto;
     width: 100%;
-    height: 600px;
+    height: 300px;
 }
 
 .email.sent {


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