[geary/wip/730682-refine-convo-list] Add basic HIG selection mode pattern support to ConversationList.



commit cf4372ceadcb624788fd38a77d588ff9825ea1e5
Author: Michael James Gratton <mike vee net>
Date:   Sun Dec 24 18:37:08 2017 +1030

    Add basic HIG selection mode pattern support to ConversationList.
    
    * src/client/conversation-list/conversation-list.vala (ConversationListBox):
      Add support for selection mode.
    
    * src/client/conversation-list/conversation-list-item.vala
      (ConversationListItem): Allow items to be marked, and display a
      selected icon when they are.

 .../conversation-list/conversation-list-item.vala  |   21 +++
 .../conversation-list/conversation-list.vala       |  162 ++++++++++++++++++--
 ui/conversation-list-item.ui                       |   55 ++++++--
 ui/geary.css                                       |   30 +++--
 4 files changed, 237 insertions(+), 31 deletions(-)
---
diff --git a/src/client/conversation-list/conversation-list-item.vala 
b/src/client/conversation-list/conversation-list-item.vala
index 91f545a..49f4c0f 100644
--- a/src/client/conversation-list/conversation-list-item.vala
+++ b/src/client/conversation-list/conversation-list-item.vala
@@ -76,6 +76,9 @@ public class ConversationListItem : Gtk.ListBoxRow {
     /** The conversation displayed by this item */
     public Geary.App.Conversation conversation { get; private set; }
 
+    /** Determines if this row is marked for selection mode */
+    public bool is_marked { get; private set; default = false; }
+
 
     [GtkChild]
     private Gtk.Button star_button;
@@ -98,12 +101,20 @@ public class ConversationListItem : Gtk.ListBoxRow {
     [GtkChild]
     private Gtk.Label count;
 
+    [GtkChild]
+    private Gtk.Revealer mark_revealer;
+
     private Gee.List<Geary.RFC822.MailboxAddress> account_addresses;
     private bool use_to;
     private PreviewLoader previews;
     private Cancellable preview_cancellable = new Cancellable();
     private Configuration config;
 
+
+    /** Fired when this row is marked for selection mode. */
+    public signal void item_marked(bool marked);
+
+
     public ConversationListItem(Geary.App.Conversation conversation,
                                 Gee.List<Geary.RFC822.MailboxAddress> account_addresses,
                                 bool use_to,
@@ -137,6 +148,16 @@ public class ConversationListItem : Gtk.ListBoxRow {
         base.destroy();
     }
 
+    internal void toggle_marked() {
+        set_marked(!this.is_marked);
+    }
+
+    internal void set_marked(bool marked) {
+        this.is_marked = marked;
+        this.mark_revealer.set_reveal_child(marked);
+        item_marked(marked);
+    }
+
     private void update() {
         Gtk.StyleContext style = get_style_context();
 
diff --git a/src/client/conversation-list/conversation-list.vala 
b/src/client/conversation-list/conversation-list.vala
index caf8d16..dd4b4b2 100644
--- a/src/client/conversation-list/conversation-list.vala
+++ b/src/client/conversation-list/conversation-list.vala
@@ -7,7 +7,12 @@
  */
 
 /**
- * A Gtk.ListBox that displays a list of conversations.
+ * A GtkListBox that displays a list of conversations.
+ *
+ * This class uses the GtkListBox's selection system for selecting and
+ * displaying individual conversations, and supports the GNOME3 HIG
+ * selection mode pattern for to allow multiple conversations to be
+ * marked, independent of the list's selection.
  */
 public class ConversationList : Gtk.ListBox {
 
@@ -21,15 +26,19 @@ public class ConversationList : Gtk.ListBox {
     /**
      * The conversation highlighted as selected, if any.
      *
-     * This is distinct to the conversations chosen via selection
+     * This is distinct to the conversations marked via selection
      * mode, which are checked and might not be highlighted.
      */
     public Geary.App.Conversation? selected { get; private set; default = null; }
 
+    /** Determines if selection mode is enabled for the list. */
+    public bool is_selection_mode_enabled { get; private set; default = false; }
 
     private Configuration config;
     private int selected_index = -1;
     private bool selection_frozen = false;
+    private Gee.Map<Geary.App.Conversation,ConversationListItem> marked =
+        new Gee.HashMap<Geary.App.Conversation,ConversationListItem>();
     private Gee.Set<Geary.App.Conversation>? visible_conversations = null;
     private Geary.Scheduler.Scheduled? update_visible_scheduled = null;
     private bool enable_load_more = true;
@@ -51,6 +60,20 @@ public class ConversationList : Gtk.ListBox {
         this.enable_load_more = false;
     }
 
+    /**
+     * Fired when a list item was targeted with a selection gesture.
+     *
+     * Selection gestures include Ctrl-click or Shift-click on the
+     * list row.
+     */
+    public signal void selection_mode_enabled();
+
+    /**
+     * Fired when a list item was marked as selected in selection mode.
+     */
+    public signal void item_marked(ConversationListItem item, bool marked);
+
+
     public ConversationList(Configuration config) {
         this.config = config;
         get_style_context().add_class(LIST_CLASS);
@@ -67,6 +90,16 @@ public class ConversationList : Gtk.ListBox {
         this.show.connect(on_show);
     }
 
+    /**
+     * Returns a read-only collection of currently marked items.
+     *
+     * This is distinct to the conversations marked via the list's
+     * selection, which are highlighted as selected.
+     */
+    public Gee.Collection<Geary.App.Conversation> get_marked_items() {
+        return this.marked.keys.read_only_view;
+    }
+
     public new void bind_model(Geary.App.ConversationMonitor monitor) {
         Geary.Folder displayed = monitor.base_folder;
         Geary.App.EmailStore store = new Geary.App.EmailStore(displayed.account);
@@ -77,6 +110,7 @@ public class ConversationList : Gtk.ListBox {
         monitor.scan_completed.connect(() => {
                 loader.load_remote();
             });
+        monitor.conversations_removed.connect(on_conversations_removed);
 
         this.model = new ConversationListModel(monitor, loader);
         this.model.items_changed.connect(on_model_items_changed);
@@ -84,15 +118,21 @@ public class ConversationList : Gtk.ListBox {
         // Clear these since they will belong to the old model
         this.selected = null;
         this.selected_index = -1;
+        this.marked.clear();
+        this.visible_conversations = null;
 
         Gee.List<Geary.RFC822.MailboxAddress> account_addresses = 
displayed.account.information.get_all_mailboxes();
         bool use_to = displayed.special_folder_type.is_outgoing();
         base.bind_model(this.model, (convo) => {
-                return new ConversationListItem(convo as Geary.App.Conversation,
-                                                account_addresses,
-                                                use_to,
-                                                loader,
-                                                this.config);
+                ConversationListItem item = new ConversationListItem(
+                    convo as Geary.App.Conversation,
+                    account_addresses,
+                    use_to,
+                    loader,
+                    this.config
+                );
+                item.item_marked.connect(on_item_marked);
+                return item;
             }
         );
     }
@@ -119,12 +159,97 @@ public class ConversationList : Gtk.ListBox {
         }
     }
 
+    public override bool button_press_event(Gdk.EventButton event) {
+        if (event.button == 1) {
+            if ((event.state & Gdk.ModifierType.SHIFT_MASK) == 0) {
+                // Shift isn't down
+                if ((event.state & Gdk.ModifierType.CONTROL_MASK) != 0 &&
+                    !this.is_selection_mode_enabled) {
+                    // Not currently in selection mode, but Ctrl is
+                    // down, so enable it
+                    set_selection_mode_enabled(true);
+                    selection_mode_enabled();
+                }
+                if (this.is_selection_mode_enabled) {
+                    // Are (now) currently in selection mode, so
+                    // toggle the row
+                    ConversationListItem? row =
+                        get_row_at_y((int) event.y) as ConversationListItem;
+                    if (row != null) {
+                        row.toggle_marked();
+                    }
+                }
+            } else if ((event.state & Gdk.ModifierType.SHIFT_MASK) != 0) {
+                // Shift is down, so emulate Gtk.TreeView-like
+                // contiguous selection behaviour
+                if (!this.is_selection_mode_enabled) {
+                    set_selection_mode_enabled(true);
+                    selection_mode_enabled();
+                }
+                ConversationListItem? clicked =
+                    get_row_at_y((int) event.y)  as ConversationListItem;
+                if (clicked != null) {
+                    ConversationListItem? selected =
+                        get_selected_row() as ConversationListItem;
+
+                    if (selected == null) {
+                        selected = get_item_at_index(0);
+                    }
+
+                    int index = int.min(clicked.get_index(), selected.get_index());
+                    int end = index + (clicked.get_index() - selected.get_index()).abs();
+                    while (index <= end) {
+                        ConversationListItem? row = get_item_at_index(index++);
+                        if (row != null) {
+                            row.set_marked(true);
+                        }
+                    }
+                }
+            }
+        }
+        return base.button_press_event(event);
+    }
+
+    public override bool key_press_event(Gdk.EventKey event) {
+        if (event.keyval == Gdk.Key.Return ||
+            event.keyval == Gdk.Key.KP_Enter) {
+            if ((event.state & Gdk.ModifierType.CONTROL_MASK) != 0 &&
+                !this.is_selection_mode_enabled) {
+                set_selection_mode_enabled(true);
+                selection_mode_enabled();
+            }
+            if (this.is_selection_mode_enabled) {
+                // Are (now) currently in selection mode, so
+                // toggle the row
+                ConversationListItem? row =
+                    get_selected_row() as ConversationListItem;
+                if (row != null) {
+                    row.toggle_marked();
+                }
+            }
+        }
+        return base.key_press_event(event);
+    }
+
     internal Gee.Set<Geary.App.Conversation> get_visible_conversations() {
         Gee.HashSet<Geary.App.Conversation> visible = new Gee.HashSet<Geary.App.Conversation>();
         // XXX Implement me
         return visible;
     }
 
+    internal void set_selection_mode_enabled(bool enabled) {
+        if (!enabled) {
+            // Call to_array here to get a copy of the value
+            // collection, since unmarking the items will cause the
+            // underlying map to be modified
+            foreach (ConversationListItem item in this.marked.values.to_array()) {
+                item.set_marked(false);
+            }
+            this.marked.clear();
+        }
+        this.is_selection_mode_enabled = enabled;
+    }
+
     private inline ConversationListItem? get_item_at_index(int index) {
         return get_row_at_index(index) as ConversationListItem;
     }
@@ -183,10 +308,10 @@ public class ConversationList : Gtk.ListBox {
                 this.selected_index = -1;
             }
 
-            debug("Selection changed to: %s",
-                  selected != null ? selected.to_string() : null
-            );
             if (this.selected != selected) {
+                debug("Selection changed to: %s",
+                      selected != null ? selected.to_string() : null
+                );
                 this.selected = selected;
                 this.conversation_selection_changed(selected);
             }
@@ -276,4 +401,21 @@ public class ConversationList : Gtk.ListBox {
         }
     }
 
+    private void on_conversations_removed(Gee.Collection<Geary.App.Conversation> removed) {
+        if (this.is_selection_mode_enabled) {
+            foreach (Geary.App.Conversation convo in removed) {
+                this.marked.remove(convo);
+            }
+        }
+    }
+
+    private void on_item_marked(ConversationListItem item, bool marked) {
+        if (marked) {
+            this.marked.set(item.conversation, item);
+        } else {
+            this.marked.remove(item.conversation);
+        }
+        item_marked(item, marked);
+    }
+
 }
diff --git a/ui/conversation-list-item.ui b/ui/conversation-list-item.ui
index c6b2e87..c8182ee 100644
--- a/ui/conversation-list-item.ui
+++ b/ui/conversation-list-item.ui
@@ -5,6 +5,7 @@
   <template class="ConversationListItem" parent="GtkListBoxRow">
     <property name="visible">True</property>
     <property name="can_focus">True</property>
+    <property name="events">GDK_BUTTON_PRESS_MASK | GDK_KEY_PRESS_MASK | GDK_STRUCTURE_MASK</property>
     <child>
       <object class="GtkGrid">
         <property name="visible">True</property>
@@ -15,7 +16,6 @@
             <property name="can_focus">False</property>
             <property name="halign">start</property>
             <property name="valign">start</property>
-            <property name="margin_bottom">2</property>
             <property name="hexpand">True</property>
             <property name="label" translatable="yes">Participants</property>
             <property name="ellipsize">end</property>
@@ -23,7 +23,7 @@
             <property name="xalign">0</property>
           </object>
           <packing>
-            <property name="left_attach">1</property>
+            <property name="left_attach">2</property>
             <property name="top_attach">0</property>
           </packing>
         </child>
@@ -39,7 +39,7 @@
             <property name="xalign">0</property>
           </object>
           <packing>
-            <property name="left_attach">1</property>
+            <property name="left_attach">2</property>
             <property name="top_attach">1</property>
             <property name="width">2</property>
           </packing>
@@ -100,7 +100,7 @@
             </child>
           </object>
           <packing>
-            <property name="left_attach">0</property>
+            <property name="left_attach">1</property>
             <property name="top_attach">0</property>
             <property name="height">3</property>
           </packing>
@@ -120,7 +120,7 @@
             </style>
           </object>
           <packing>
-            <property name="left_attach">1</property>
+            <property name="left_attach">2</property>
             <property name="top_attach">2</property>
             <property name="width">2</property>
           </packing>
@@ -142,7 +142,7 @@
             </style>
           </object>
           <packing>
-            <property name="left_attach">3</property>
+            <property name="left_attach">4</property>
             <property name="top_attach">1</property>
             <property name="height">2</property>
           </packing>
@@ -159,15 +159,50 @@
             </style>
           </object>
           <packing>
-            <property name="left_attach">2</property>
+            <property name="left_attach">3</property>
             <property name="top_attach">0</property>
             <property name="width">2</property>
           </packing>
         </child>
-        <style>
-          <class name="geary-conversation-list-item"/>
-        </style>
+        <child>
+          <object class="GtkGrid" id="mark_container">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="halign">start</property>
+            <property name="valign">center</property>
+            <property name="vexpand">True</property>
+            <child>
+              <object class="GtkRevealer" id="mark_revealer">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="transition_type">slide-right</property>
+                <child>
+                  <object class="GtkImage">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="icon_name">object-select-symbolic</property>
+                  </object>
+                </child>
+              </object>
+              <packing>
+                <property name="left_attach">0</property>
+                <property name="top_attach">0</property>
+              </packing>
+            </child>
+            <style>
+              <class name="geary-mark-container"/>
+            </style>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">0</property>
+            <property name="height">3</property>
+          </packing>
+        </child>
       </object>
     </child>
+    <style>
+      <class name="geary-conversation-list-item"/>
+    </style>
   </template>
 </interface>
diff --git a/ui/geary.css b/ui/geary.css
index f87e091..408c6f5 100644
--- a/ui/geary.css
+++ b/ui/geary.css
@@ -1,6 +1,6 @@
 /*
  * Copyright 2016 Software Freedom Conservancy Inc.
- * Copyright 2016, 2017 Michael Gratton <mike vee net>
+ * Copyright 2016-2017 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.
@@ -61,17 +61,17 @@ row.geary-folder-popover-list-row > label {
   color: @theme_text_color;
 }
 
+/* ConversationListItem */
+
 /* ConversationList */
 
-list.geary-conversation-list > row {
+.geary-conversation-list-item {
   margin: 0;
   border: 0;
   padding: 0;
 }
 
-/* ConversationListItem */
-
-grid.geary-conversation-list-item {
+.geary-conversation-list-item > grid {
   border: 4px solid transparent;
   border-top-width: 0px;
   border-bottom-width: 0px;
@@ -79,29 +79,29 @@ grid.geary-conversation-list-item {
   padding: 12px;
   transition: border 4s;
 }
-grid.geary-conversation-list-item:dir(ltr) {
+.geary-conversation-list-item  > grid:dir(ltr) {
   border-right-width: 0px;
   padding-left: 0;
 }
-grid.geary-conversation-list-item:dir(rtl) {
+.geary-conversation-list-item > grid:dir(rtl) {
   border-left-width: 0px;
   padding-right: 0;
 }
 
-grid.geary-conversation-list-item.geary-unread {
+.geary-conversation-list-item.geary-unread > grid {
   border-color: @theme_selected_bg_color;
   transition: border 0.25s;
 }
 
-grid.geary-conversation-list-item label.geary-date:dir(ltr) {
+.geary-conversation-list-item label.geary-date:dir(ltr) {
   margin-right: 6px;
 }
 
-grid.geary-conversation-list-item label.geary-date:dir(rtl) {
+.geary-conversation-list-item label.geary-date:dir(rtl) {
   margin-left: 6px;
 }
 
-grid.geary-conversation-list-item label.geary-count {
+.geary-conversation-list-item label.geary-count {
   padding: 2px 6px;
   border-radius: 2px;
   font-size: small;
@@ -109,6 +109,14 @@ grid.geary-conversation-list-item label.geary-count {
   background: @theme_selected_bg_color;
 }
 
+.geary-conversation-list-item .geary-mark-container image:dir(ltr) {
+  padding-left: 6px;
+}
+
+.geary-conversation-list-item .geary-mark-container image:dir(rtl) {
+  padding-right: 6px;
+}
+
 /* ConversationListBox */
 
 .conversation-listbox {


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