1/* Copyright 2016 Software Freedom Conservancy Inc. 2 * 3 * This software is licensed under the GNU Lesser General Public License 4 * (version 2.1 or later). See the COPYING file in this distribution. 5 */ 6 7public class ConversationListView : Gtk.TreeView, Geary.BaseInterface { 8 const int LOAD_MORE_HEIGHT = 100; 9 10 11 private Application.Configuration config; 12 13 private bool enable_load_more = true; 14 15 private bool reset_adjustment = false; 16 private Gee.Set<Geary.App.Conversation>? current_visible_conversations = null; 17 private Geary.Scheduler.Scheduled? scheduled_update_visible_conversations = null; 18 private Gee.Set<Geary.App.Conversation> selected = new Gee.HashSet<Geary.App.Conversation>(); 19 private Geary.IdleManager selection_update; 20 private Gtk.GestureMultiPress gesture; 21 22 // Determines if the next folder scan should avoid selecting a 23 // conversation when autoselect is enabled 24 private bool should_inhibit_autoselect = false; 25 26 27 public signal void conversations_selected(Gee.Set<Geary.App.Conversation> selected); 28 29 // Signal for when a conversation has been double-clicked, or selected and enter is pressed. 30 public signal void conversation_activated(Geary.App.Conversation activated, bool single = false); 31 32 public virtual signal void load_more() { 33 enable_load_more = false; 34 } 35 36 public signal void mark_conversations(Gee.Collection<Geary.App.Conversation> conversations, 37 Geary.NamedFlag flag); 38 39 public signal void visible_conversations_changed(Gee.Set<Geary.App.Conversation> visible); 40 41 42 public ConversationListView(Application.Configuration config) { 43 base_ref(); 44 set_show_expanders(false); 45 set_headers_visible(false); 46 set_grid_lines(Gtk.TreeViewGridLines.HORIZONTAL); 47 48 this.config = config; 49 50 append_column(create_column(ConversationListStore.Column.CONVERSATION_DATA, 51 new ConversationListCellRenderer(), ConversationListStore.Column.CONVERSATION_DATA.to_string(), 52 0)); 53 54 Gtk.TreeSelection selection = get_selection(); 55 selection.set_mode(Gtk.SelectionMode.MULTIPLE); 56 style_updated.connect(on_style_changed); 57 58 notify["vadjustment"].connect(on_vadjustment_changed); 59 60 key_press_event.connect(on_key_press); 61 button_press_event.connect(on_button_press); 62 gesture = new Gtk.GestureMultiPress(this); 63 gesture.pressed.connect(on_gesture_pressed); 64 65 // Set up drag and drop. 66 Gtk.drag_source_set(this, Gdk.ModifierType.BUTTON1_MASK, FolderList.Tree.TARGET_ENTRY_LIST, 67 Gdk.DragAction.COPY | Gdk.DragAction.MOVE); 68 69 this.config.settings.changed[ 70 Application.Configuration.DISPLAY_PREVIEW_KEY 71 ].connect(on_display_preview_changed); 72 73 // Watch for mouse events. 74 motion_notify_event.connect(on_motion_notify_event); 75 leave_notify_event.connect(on_leave_notify_event); 76 77 // GtkTreeView binds Ctrl+N to "move cursor to next". Not so interested in that, so we'll 78 // remove it. 79 unowned Gtk.BindingSet? binding_set = Gtk.BindingSet.find("GtkTreeView"); 80 assert(binding_set != null); 81 Gtk.BindingEntry.remove(binding_set, Gdk.Key.N, Gdk.ModifierType.CONTROL_MASK); 82 83 this.selection_update = new Geary.IdleManager(do_selection_changed); 84 this.selection_update.priority = Geary.IdleManager.Priority.LOW; 85 86 this.visible = true; 87 } 88 89 ~ConversationListView() { 90 base_unref(); 91 } 92 93 public override void destroy() { 94 this.selection_update.reset(); 95 base.destroy(); 96 } 97 98 public new ConversationListStore? get_model() { 99 return base.get_model() as ConversationListStore; 100 } 101 102 public new void set_model(ConversationListStore? new_store) { 103 ConversationListStore? old_store = get_model(); 104 if (old_store != null) { 105 old_store.conversations.scan_started.disconnect(on_scan_started); 106 old_store.conversations.scan_completed.disconnect(on_scan_completed); 107 108 old_store.conversations_added.disconnect(on_conversations_added); 109 old_store.conversations_removed.disconnect(on_conversations_removed); 110 old_store.row_inserted.disconnect(on_rows_changed); 111 old_store.rows_reordered.disconnect(on_rows_changed); 112 old_store.row_changed.disconnect(on_rows_changed); 113 old_store.row_deleted.disconnect(on_rows_changed); 114 old_store.destroy(); 115 } 116 117 if (new_store != null) { 118 new_store.conversations.scan_started.connect(on_scan_started); 119 new_store.conversations.scan_completed.connect(on_scan_completed); 120 121 new_store.row_inserted.connect(on_rows_changed); 122 new_store.rows_reordered.connect(on_rows_changed); 123 new_store.row_changed.connect(on_rows_changed); 124 new_store.row_deleted.connect(on_rows_changed); 125 new_store.conversations_removed.connect(on_conversations_removed); 126 new_store.conversations_added.connect(on_conversations_added); 127 } 128 129 // Disconnect the selection handler since we don't want to 130 // fire selection signals while changing the model. 131 Gtk.TreeSelection selection = get_selection(); 132 selection.changed.disconnect(on_selection_changed); 133 base.set_model(new_store); 134 this.selected.clear(); 135 selection.changed.connect(on_selection_changed); 136 } 137 138 /** Returns a read-only iteration of the current selection. */ 139 public Gee.Set<Geary.App.Conversation> get_selected() { 140 return this.selected.read_only_view; 141 } 142 143 /** Returns a copy of the current selection. */ 144 public Gee.Set<Geary.App.Conversation> copy_selected() { 145 var copy = new Gee.HashSet<Geary.App.Conversation>(); 146 copy.add_all(this.selected); 147 return copy; 148 } 149 150 public void inhibit_next_autoselect() { 151 this.should_inhibit_autoselect = true; 152 } 153 154 public void scroll(Gtk.ScrollType where) { 155 Gtk.TreeSelection selection = get_selection(); 156 weak Gtk.TreeModel model; 157 GLib.List<Gtk.TreePath> selected = selection.get_selected_rows(out model); 158 Gtk.TreePath? target_path = null; 159 Gtk.TreeIter? target_iter = null; 160 if (selected.length() > 0) { 161 switch (where) { 162 case STEP_UP: 163 target_path = selected.first().data; 164 model.get_iter(out target_iter, target_path); 165 if (model.iter_previous(ref target_iter)) { 166 target_path = model.get_path(target_iter); 167 } else { 168 this.get_window().beep(); 169 } 170 break; 171 172 case STEP_DOWN: 173 target_path = selected.last().data; 174 model.get_iter(out target_iter, target_path); 175 if (model.iter_next(ref target_iter)) { 176 target_path = model.get_path(target_iter); 177 } else { 178 this.get_window().beep(); 179 } 180 break; 181 182 default: 183 // no-op 184 break; 185 } 186 187 set_cursor(target_path, null, false); 188 } 189 } 190 191 private void check_load_more() { 192 ConversationListStore? model = get_model(); 193 Geary.App.ConversationMonitor? conversations = (model != null) 194 ? model.conversations 195 : null; 196 if (conversations != null) { 197 // Check if we're at the very bottom of the list. If we 198 // are, it's time to issue a load_more signal. 199 Gtk.Adjustment adjustment = ((Gtk.Scrollable) this).get_vadjustment(); 200 double upper = adjustment.get_upper(); 201 double threshold = upper - adjustment.page_size - LOAD_MORE_HEIGHT; 202 if (this.is_visible() && 203 conversations.can_load_more && 204 adjustment.get_value() >= threshold) { 205 load_more(); 206 } 207 208 schedule_visible_conversations_changed(); 209 } 210 } 211 212 private void on_scan_started() { 213 this.enable_load_more = false; 214 } 215 216 private void on_scan_completed() { 217 this.enable_load_more = true; 218 check_load_more(); 219 220 // Select the first conversation, if autoselect is enabled, 221 // nothing has been selected yet and we're not showing a 222 // composer. 223 if (this.config.autoselect && 224 !this.should_inhibit_autoselect && 225 get_selection().count_selected_rows() == 0) { 226 var parent = get_toplevel() as Application.MainWindow; 227 if (parent != null && !parent.has_composer) { 228 set_cursor(new Gtk.TreePath.from_indices(0, -1), null, false); 229 } 230 } 231 232 this.should_inhibit_autoselect = false; 233 } 234 235 private void on_conversations_added(bool start) { 236 Gtk.Adjustment? adjustment = get_adjustment(); 237 if (start) { 238 // If we were at the top, we want to stay there after 239 // conversations are added. 240 this.reset_adjustment = adjustment != null && adjustment.get_value() == 0; 241 } else if (this.reset_adjustment && adjustment != null) { 242 // Pump the loop to make sure the new conversations are 243 // taking up space in the window. Without this, setting 244 // the adjustment here is a no-op because as far as it's 245 // concerned, it's already at the top. 246 while (Gtk.events_pending()) 247 Gtk.main_iteration(); 248 249 adjustment.set_value(0); 250 } 251 this.reset_adjustment = false; 252 } 253 254 private void on_conversations_removed(bool start) { 255 if (!this.config.autoselect) { 256 Gtk.SelectionMode mode = start 257 // Stop GtkTreeView from automatically selecting the 258 // next row after the removed rows 259 ? Gtk.SelectionMode.NONE 260 // Allow the user to make selections again 261 : Gtk.SelectionMode.MULTIPLE; 262 get_selection().set_mode(mode); 263 } 264 } 265 266 private Gtk.Adjustment? get_adjustment() { 267 Gtk.ScrolledWindow? parent = get_parent() as Gtk.ScrolledWindow; 268 if (parent == null) { 269 debug("Parent was not scrolled window"); 270 return null; 271 } 272 273 return parent.get_vadjustment(); 274 } 275 276 private void on_gesture_pressed(int n_press, double x, double y) { 277 if (gesture.get_current_button() != Gdk.BUTTON_PRIMARY) 278 return; 279 280 Gtk.TreePath? path; 281 get_path_at_pos((int) x, (int) y, out path, null, null, null); 282 283 // If the user clicked in an empty area, do nothing. 284 if (path == null) 285 return; 286 287 Geary.App.Conversation? c = get_model().get_conversation_at_path(path); 288 if (c == null) 289 return; 290 291 Gdk.Event event = gesture.get_last_event(gesture.get_current_sequence()); 292 Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask(); 293 294 Gdk.ModifierType state_mask; 295 event.get_state(out state_mask); 296 297 if ((state_mask & modifiers) == 0 && n_press == 1) { 298 conversation_activated(c, true); 299 } else if ((state_mask & modifiers) == Gdk.ModifierType.SHIFT_MASK && n_press == 2) { 300 conversation_activated(c); 301 } 302 } 303 304 private bool on_key_press(Gdk.EventKey event) { 305 if (this.selected.size != 1) 306 return false; 307 308 Geary.App.Conversation? c = this.selected.to_array()[0]; 309 if (c == null) 310 return false; 311 312 Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask(); 313 314 if (event.keyval == Gdk.Key.Return || 315 event.keyval == Gdk.Key.ISO_Enter || 316 event.keyval == Gdk.Key.KP_Enter || 317 event.keyval == Gdk.Key.space || 318 event.keyval == Gdk.Key.KP_Space) 319 conversation_activated(c, !((event.state & modifiers) == Gdk.ModifierType.SHIFT_MASK)); 320 return false; 321 } 322 323 private bool on_button_press(Gdk.EventButton event) { 324 // Get the coordinates on the cell as well as the clicked path. 325 int cell_x; 326 int cell_y; 327 Gtk.TreePath? path; 328 get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y); 329 330 // If the user clicked in an empty area, do nothing. 331 if (path == null) 332 return false; 333 334 // Handle clicks to toggle read and starred status. 335 if ((event.state & Gdk.ModifierType.SHIFT_MASK) == 0 && 336 (event.state & Gdk.ModifierType.CONTROL_MASK) == 0 && 337 event.type == Gdk.EventType.BUTTON_PRESS) { 338 339 // Click positions depend on whether the preview is enabled. 340 bool read_clicked = false; 341 bool star_clicked = false; 342 if (this.config.display_preview) { 343 read_clicked = cell_x < 25 && cell_y >= 14 && cell_y <= 30; 344 star_clicked = cell_x < 25 && cell_y >= 40 && cell_y <= 62; 345 } else { 346 read_clicked = cell_x < 25 && cell_y >= 8 && cell_y <= 22; 347 star_clicked = cell_x < 25 && cell_y >= 28 && cell_y <= 43; 348 } 349 350 // Get the current conversation. If it's selected, we'll apply the mark operation to 351 // all selected conversations; otherwise, it just applies to this one. 352 Geary.App.Conversation conversation = get_model().get_conversation_at_path(path); 353 Gee.Collection<Geary.App.Conversation> to_mark = ( 354 this.selected.contains(conversation) 355 ? copy_selected() 356 : Geary.Collection.single(conversation) 357 ); 358 359 if (read_clicked) { 360 mark_conversations(to_mark, Geary.EmailFlags.UNREAD); 361 return true; 362 } else if (star_clicked) { 363 mark_conversations(to_mark, Geary.EmailFlags.FLAGGED); 364 return true; 365 } 366 } 367 368 // Check if changing the selection will require any composers 369 // to be closed, but only on the first click of a 370 // double/triple click, so that double-clicking a draft 371 // doesn't attempt to load it then close it straight away. 372 if (event.type == Gdk.EventType.BUTTON_PRESS && 373 !get_selection().path_is_selected(path)) { 374 var parent = get_toplevel() as Application.MainWindow; 375 if (parent != null && !parent.close_composer(false)) { 376 return true; 377 } 378 } 379 380 if (event.button == 3 && event.type == Gdk.EventType.BUTTON_PRESS) { 381 Geary.App.Conversation conversation = get_model().get_conversation_at_path(path); 382 383 GLib.Menu context_menu_model = new GLib.Menu(); 384 var main = get_toplevel() as Application.MainWindow; 385 if (main != null) { 386 if (!main.is_shift_down) { 387 context_menu_model.append( 388 /// Translators: Context menu item 389 ngettext( 390 "Move conversation to _Trash", 391 "Move conversations to _Trash", 392 this.selected.size 393 ), 394 Action.Window.prefix( 395 Application.MainWindow.ACTION_TRASH_CONVERSATION 396 ) 397 ); 398 } else { 399 context_menu_model.append( 400 /// Translators: Context menu item 401 ngettext( 402 "_Delete conversation", 403 "_Delete conversations", 404 this.selected.size 405 ), 406 Action.Window.prefix( 407 Application.MainWindow.ACTION_DELETE_CONVERSATION 408 ) 409 ); 410 } 411 } 412 413 if (conversation.is_unread()) 414 context_menu_model.append( 415 _("Mark as _Read"), 416 Action.Window.prefix( 417 Application.MainWindow.ACTION_MARK_AS_READ 418 ) 419 ); 420 421 if (conversation.has_any_read_message()) 422 context_menu_model.append( 423 _("Mark as _Unread"), 424 Action.Window.prefix( 425 Application.MainWindow.ACTION_MARK_AS_UNREAD 426 ) 427 ); 428 429 if (conversation.is_flagged()) { 430 context_menu_model.append( 431 _("U_nstar"), 432 Action.Window.prefix( 433 Application.MainWindow.ACTION_MARK_AS_UNSTARRED 434 ) 435 ); 436 } else { 437 context_menu_model.append( 438 _("_Star"), 439 Action.Window.prefix( 440 Application.MainWindow.ACTION_MARK_AS_STARRED 441 ) 442 ); 443 } 444 if ((conversation.base_folder.used_as != ARCHIVE) && (conversation.base_folder.used_as != ALL_MAIL)) { 445 context_menu_model.append( 446 _("Archive conversation"), 447 Action.Window.prefix( 448 Application.MainWindow.ACTION_ARCHIVE_CONVERSATION 449 ) 450 ); 451 } 452 453 Menu actions_section = new Menu(); 454 actions_section.append( 455 _("_Reply"), 456 Action.Window.prefix( 457 Application.MainWindow.ACTION_REPLY_CONVERSATION 458 ) 459 ); 460 actions_section.append( 461 _("R_eply All"), 462 Action.Window.prefix( 463 Application.MainWindow.ACTION_REPLY_ALL_CONVERSATION 464 ) 465 ); 466 actions_section.append( 467 _("_Forward"), 468 Action.Window.prefix( 469 Application.MainWindow.ACTION_FORWARD_CONVERSATION 470 ) 471 ); 472 context_menu_model.append_section(null, actions_section); 473 474 // Use a popover rather than a regular context menu since 475 // the latter grabs the event queue, so the MainWindow 476 // will not receive events if the user releases Shift, 477 // making the trash/delete header bar state wrong. 478 Gtk.Popover context_menu = new Gtk.Popover.from_model( 479 this, context_menu_model 480 ); 481 Gdk.Rectangle dest = Gdk.Rectangle(); 482 dest.x = (int) event.x; 483 dest.y = (int) event.y; 484 context_menu.set_pointing_to(dest); 485 context_menu.popup(); 486 487 // When the conversation under the mouse is selected, stop event propagation 488 return get_selection().path_is_selected(path); 489 } 490 491 return false; 492 } 493 494 private void on_style_changed() { 495 // Recalculate dimensions of child cells. 496 ConversationListCellRenderer.style_changed(this); 497 498 schedule_visible_conversations_changed(); 499 } 500 501 private void on_value_changed() { 502 if (this.enable_load_more) { 503 check_load_more(); 504 } 505 } 506 507 private static Gtk.TreeViewColumn create_column(ConversationListStore.Column column, 508 Gtk.CellRenderer renderer, string attr, int width = 0) { 509 Gtk.TreeViewColumn view_column = new Gtk.TreeViewColumn.with_attributes(column.to_string(), 510 renderer, attr, column); 511 view_column.set_resizable(true); 512 513 if (width != 0) { 514 view_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED); 515 view_column.set_fixed_width(width); 516 } 517 518 return view_column; 519 } 520 521 private List<Gtk.TreePath> get_all_selected_paths() { 522 Gtk.TreeModel model; 523 return get_selection().get_selected_rows(out model); 524 } 525 526 private void on_selection_changed() { 527 // Schedule processing selection changes at low idle for 528 // two reasons: (a) if a lot of changes come in 529 // back-to-back, this allows for all that activity to 530 // settle before updating state and firing signals (which 531 // results in a lot of I/O), and (b) it means the 532 // ConversationMonitor's signals may be processed in any 533 // order by this class and the ConversationListView and 534 // not result in a lot of screen flashing and (again) 535 // unnecessary I/O as both classes update selection state. 536 this.selection_update.schedule(); 537 } 538 539 // Gtk.TreeSelection can fire its "changed" signal even when 540 // nothing's changed, so look for that to avoid subscribers from 541 // doing the same things (in particular, I/O) multiple times 542 private void do_selection_changed() { 543 Gee.HashSet<Geary.App.Conversation> new_selection = 544 new Gee.HashSet<Geary.App.Conversation>(); 545 List<Gtk.TreePath> paths = get_all_selected_paths(); 546 if (paths.length() != 0) { 547 // Conversations are selected, so collect them and 548 // signal if different 549 foreach (Gtk.TreePath path in paths) { 550 Geary.App.Conversation? conversation = 551 get_model().get_conversation_at_path(path); 552 if (conversation != null) 553 new_selection.add(conversation); 554 } 555 } 556 557 // only notify if different than what was previously reported 558 if (this.selected.size != new_selection.size || 559 !this.selected.contains_all(new_selection)) { 560 this.selected = new_selection; 561 conversations_selected(this.selected.read_only_view); 562 } 563 } 564 565 public Gee.Set<Geary.App.Conversation> get_visible_conversations() { 566 Gee.HashSet<Geary.App.Conversation> visible_conversations = new Gee.HashSet<Geary.App.Conversation>(); 567 568 Gtk.TreePath start_path; 569 Gtk.TreePath end_path; 570 if (!get_visible_range(out start_path, out end_path)) 571 return visible_conversations; 572 573 while (start_path.compare(end_path) <= 0) { 574 Geary.App.Conversation? conversation = get_model().get_conversation_at_path(start_path); 575 if (conversation != null) 576 visible_conversations.add(conversation); 577 578 start_path.next(); 579 } 580 581 return visible_conversations; 582 } 583 584 // Always returns false, so it can be used as a one-time SourceFunc 585 private bool update_visible_conversations() { 586 bool changed = false; 587 Gee.Set<Geary.App.Conversation> visible_conversations = get_visible_conversations(); 588 if (this.current_visible_conversations == null || 589 this.current_visible_conversations.size != visible_conversations.size || 590 !this.current_visible_conversations.contains_all(visible_conversations)) { 591 this.current_visible_conversations = visible_conversations; 592 visible_conversations_changed( 593 this.current_visible_conversations.read_only_view 594 ); 595 changed = true; 596 } 597 return changed; 598 } 599 600 private void schedule_visible_conversations_changed() { 601 scheduled_update_visible_conversations = Geary.Scheduler.on_idle(update_visible_conversations); 602 } 603 604 public void select_conversations(Gee.Collection<Geary.App.Conversation> new_selection) { 605 if (this.selected.size != new_selection.size || 606 !this.selected.contains_all(new_selection)) { 607 var selection = get_selection(); 608 selection.unselect_all(); 609 var model = get_model(); 610 if (model != null) { 611 foreach (var conversation in new_selection) { 612 var path = model.get_path_for_conversation(conversation); 613 if (path != null) { 614 selection.select_path(path); 615 } 616 } 617 } 618 } 619 } 620 621 private void on_rows_changed() { 622 schedule_visible_conversations_changed(); 623 } 624 625 private void on_display_preview_changed() { 626 style_updated(); 627 model.foreach(refresh_path); 628 629 schedule_visible_conversations_changed(); 630 } 631 632 private bool refresh_path(Gtk.TreeModel model, Gtk.TreePath path, Gtk.TreeIter iter) { 633 model.row_changed(path, iter); 634 return false; 635 } 636 637 // Enable/disable hover effect on all selected cells. 638 private void set_hover_selected(bool hover) { 639 ConversationListCellRenderer.set_hover_selected(hover); 640 queue_draw(); 641 } 642 643 private bool on_motion_notify_event(Gdk.EventMotion event) { 644 if (get_selection().count_selected_rows() > 0) { 645 Gtk.TreePath? path = null; 646 int cell_x, cell_y; 647 get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y); 648 649 set_hover_selected(path != null && get_selection().path_is_selected(path)); 650 } 651 return Gdk.EVENT_PROPAGATE; 652 } 653 654 private bool on_leave_notify_event() { 655 if (get_selection().count_selected_rows() > 0) { 656 set_hover_selected(false); 657 } 658 return Gdk.EVENT_PROPAGATE; 659 660 } 661 662 private void on_vadjustment_changed() { 663 this.vadjustment.value_changed.connect(on_value_changed); 664 } 665 666} 667