geary/src/client/conversation-list/conversation-list-view.vala
2020-08-17 10:33:33 +10:00

613 lines
23 KiB
Vala

/* Copyright 2016 Software Freedom Conservancy Inc.
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
public class ConversationListView : Gtk.TreeView, Geary.BaseInterface {
const int LOAD_MORE_HEIGHT = 100;
private Application.Configuration config;
private bool enable_load_more = true;
private bool reset_adjustment = false;
private Gee.Set<Geary.App.Conversation>? current_visible_conversations = null;
private Geary.Scheduler.Scheduled? scheduled_update_visible_conversations = null;
private Gee.Set<Geary.App.Conversation> selected = new Gee.HashSet<Geary.App.Conversation>();
private Geary.IdleManager selection_update;
// Determines if the next folder scan should avoid selecting a
// conversation when autoselect is enabled
private bool should_inhibit_autoselect = false;
public signal void conversations_selected(Gee.Set<Geary.App.Conversation> selected);
// Signal for when a conversation has been double-clicked, or selected and enter is pressed.
public signal void conversation_activated(Geary.App.Conversation activated);
public virtual signal void load_more() {
enable_load_more = false;
}
public signal void mark_conversations(Gee.Collection<Geary.App.Conversation> conversations,
Geary.NamedFlag flag);
public signal void visible_conversations_changed(Gee.Set<Geary.App.Conversation> visible);
public ConversationListView(Application.Configuration config) {
base_ref();
set_show_expanders(false);
set_headers_visible(false);
this.config = config;
append_column(create_column(ConversationListStore.Column.CONVERSATION_DATA,
new ConversationListCellRenderer(), ConversationListStore.Column.CONVERSATION_DATA.to_string(),
0));
Gtk.TreeSelection selection = get_selection();
selection.set_mode(Gtk.SelectionMode.MULTIPLE);
style_updated.connect(on_style_changed);
row_activated.connect(on_row_activated);
notify["vadjustment"].connect(on_vadjustment_changed);
button_press_event.connect(on_button_press);
// Set up drag and drop.
Gtk.drag_source_set(this, Gdk.ModifierType.BUTTON1_MASK, FolderList.Tree.TARGET_ENTRY_LIST,
Gdk.DragAction.COPY | Gdk.DragAction.MOVE);
this.config.settings.changed[
Application.Configuration.DISPLAY_PREVIEW_KEY
].connect(on_display_preview_changed);
// Watch for mouse events.
motion_notify_event.connect(on_motion_notify_event);
leave_notify_event.connect(on_leave_notify_event);
// GtkTreeView binds Ctrl+N to "move cursor to next". Not so interested in that, so we'll
// remove it.
unowned Gtk.BindingSet? binding_set = Gtk.BindingSet.find("GtkTreeView");
assert(binding_set != null);
Gtk.BindingEntry.remove(binding_set, Gdk.Key.N, Gdk.ModifierType.CONTROL_MASK);
this.selection_update = new Geary.IdleManager(do_selection_changed);
this.selection_update.priority = Geary.IdleManager.Priority.LOW;
this.visible = true;
}
~ConversationListView() {
base_unref();
}
public override void destroy() {
this.selection_update.reset();
base.destroy();
}
public new ConversationListStore? get_model() {
return base.get_model() as ConversationListStore;
}
public new void set_model(ConversationListStore? new_store) {
ConversationListStore? old_store = get_model();
if (old_store != null) {
old_store.conversations.scan_started.disconnect(on_scan_started);
old_store.conversations.scan_completed.disconnect(on_scan_completed);
old_store.conversations_added.disconnect(on_conversations_added);
old_store.conversations_removed.disconnect(on_conversations_removed);
old_store.row_inserted.disconnect(on_rows_changed);
old_store.rows_reordered.disconnect(on_rows_changed);
old_store.row_changed.disconnect(on_rows_changed);
old_store.row_deleted.disconnect(on_rows_changed);
old_store.destroy();
}
if (new_store != null) {
new_store.conversations.scan_started.connect(on_scan_started);
new_store.conversations.scan_completed.connect(on_scan_completed);
new_store.row_inserted.connect(on_rows_changed);
new_store.rows_reordered.connect(on_rows_changed);
new_store.row_changed.connect(on_rows_changed);
new_store.row_deleted.connect(on_rows_changed);
new_store.conversations_removed.connect(on_conversations_removed);
new_store.conversations_added.connect(on_conversations_added);
}
// Disconnect the selection handler since we don't want to
// fire selection signals while changing the model.
Gtk.TreeSelection selection = get_selection();
selection.changed.disconnect(on_selection_changed);
base.set_model(new_store);
this.selected.clear();
selection.changed.connect(on_selection_changed);
}
/** Returns a read-only iteration of the current selection. */
public Gee.Set<Geary.App.Conversation> get_selected() {
return this.selected.read_only_view;
}
/** Returns a copy of the current selection. */
public Gee.Set<Geary.App.Conversation> copy_selected() {
var copy = new Gee.HashSet<Geary.App.Conversation>();
copy.add_all(this.selected);
return copy;
}
public void inhibit_next_autoselect() {
this.should_inhibit_autoselect = true;
}
public void scroll(Gtk.ScrollType where) {
Gtk.TreeSelection selection = get_selection();
weak Gtk.TreeModel model;
GLib.List<Gtk.TreePath> selected = selection.get_selected_rows(out model);
Gtk.TreePath? target_path = null;
Gtk.TreeIter? target_iter = null;
if (selected.length() > 0) {
switch (where) {
case STEP_UP:
target_path = selected.first().data;
model.get_iter(out target_iter, target_path);
if (model.iter_previous(ref target_iter)) {
target_path = model.get_path(target_iter);
} else {
this.get_window().beep();
}
break;
case STEP_DOWN:
target_path = selected.last().data;
model.get_iter(out target_iter, target_path);
if (model.iter_next(ref target_iter)) {
target_path = model.get_path(target_iter);
} else {
this.get_window().beep();
}
break;
default:
// no-op
break;
}
set_cursor(target_path, null, false);
}
}
private void check_load_more() {
ConversationListStore? model = get_model();
Geary.App.ConversationMonitor? conversations = (model != null)
? model.conversations
: null;
if (conversations != null) {
// Check if we're at the very bottom of the list. If we
// are, it's time to issue a load_more signal.
Gtk.Adjustment adjustment = ((Gtk.Scrollable) this).get_vadjustment();
double upper = adjustment.get_upper();
double threshold = upper - adjustment.page_size - LOAD_MORE_HEIGHT;
if (this.is_visible() &&
conversations.can_load_more &&
adjustment.get_value() >= threshold) {
load_more();
}
schedule_visible_conversations_changed();
}
}
private void on_scan_started() {
this.enable_load_more = false;
}
private void on_scan_completed() {
this.enable_load_more = true;
check_load_more();
// Select the first conversation, if autoselect is enabled,
// nothing has been selected yet and we're not showing a
// composer.
if (this.config.autoselect &&
!this.should_inhibit_autoselect &&
get_selection().count_selected_rows() == 0) {
var parent = get_toplevel() as Application.MainWindow;
if (parent != null && !parent.has_composer) {
set_cursor(new Gtk.TreePath.from_indices(0, -1), null, false);
}
}
this.should_inhibit_autoselect = false;
}
private void on_conversations_added(bool start) {
Gtk.Adjustment? adjustment = get_adjustment();
if (start) {
// If we were at the top, we want to stay there after
// conversations are added.
this.reset_adjustment = adjustment != null && adjustment.get_value() == 0;
} else if (this.reset_adjustment && adjustment != null) {
// Pump the loop to make sure the new conversations are
// taking up space in the window. Without this, setting
// the adjustment here is a no-op because as far as it's
// concerned, it's already at the top.
while (Gtk.events_pending())
Gtk.main_iteration();
adjustment.set_value(0);
}
this.reset_adjustment = false;
}
private void on_conversations_removed(bool start) {
if (!this.config.autoselect) {
Gtk.SelectionMode mode = start
// Stop GtkTreeView from automatically selecting the
// next row after the removed rows
? Gtk.SelectionMode.NONE
// Allow the user to make selections again
: Gtk.SelectionMode.MULTIPLE;
get_selection().set_mode(mode);
}
}
private Gtk.Adjustment? get_adjustment() {
Gtk.ScrolledWindow? parent = get_parent() as Gtk.ScrolledWindow;
if (parent == null) {
debug("Parent was not scrolled window");
return null;
}
return parent.get_vadjustment();
}
private bool on_button_press(Gdk.EventButton event) {
// Get the coordinates on the cell as well as the clicked path.
int cell_x;
int cell_y;
Gtk.TreePath? path;
get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y);
// If the user clicked in an empty area, do nothing.
if (path == null)
return false;
// Handle clicks to toggle read and starred status.
if ((event.state & Gdk.ModifierType.SHIFT_MASK) == 0 &&
(event.state & Gdk.ModifierType.CONTROL_MASK) == 0 &&
event.type == Gdk.EventType.BUTTON_PRESS) {
// Click positions depend on whether the preview is enabled.
bool read_clicked = false;
bool star_clicked = false;
if (this.config.display_preview) {
read_clicked = cell_x < 25 && cell_y >= 14 && cell_y <= 30;
star_clicked = cell_x < 25 && cell_y >= 40 && cell_y <= 62;
} else {
read_clicked = cell_x < 25 && cell_y >= 8 && cell_y <= 22;
star_clicked = cell_x < 25 && cell_y >= 28 && cell_y <= 43;
}
// Get the current conversation. If it's selected, we'll apply the mark operation to
// all selected conversations; otherwise, it just applies to this one.
Geary.App.Conversation conversation = get_model().get_conversation_at_path(path);
Gee.Collection<Geary.App.Conversation> to_mark = (
this.selected.contains(conversation)
? copy_selected()
: Geary.Collection.single(conversation)
);
if (read_clicked) {
mark_conversations(to_mark, Geary.EmailFlags.UNREAD);
return true;
} else if (star_clicked) {
mark_conversations(to_mark, Geary.EmailFlags.FLAGGED);
return true;
}
}
// Check if changing the selection will require any composers
// to be closed, but only on the first click of a
// double/triple click, so that double-clicking a draft
// doesn't attempt to load it then close it straight away.
if (event.type == Gdk.EventType.BUTTON_PRESS &&
!get_selection().path_is_selected(path)) {
var parent = get_toplevel() as Application.MainWindow;
if (parent != null && !parent.close_composer(false)) {
return true;
}
}
if (event.button == 3 && event.type == Gdk.EventType.BUTTON_PRESS) {
Geary.App.Conversation conversation = get_model().get_conversation_at_path(path);
GLib.Menu context_menu_model = new GLib.Menu();
var main = get_toplevel() as Application.MainWindow;
if (main != null) {
if (!main.is_shift_down) {
context_menu_model.append(
/// Translators: Context menu item
ngettext(
"Move conversation to _Trash",
"Move conversations to _Trash",
this.selected.size
),
Action.Window.prefix(
Application.MainWindow.ACTION_TRASH_CONVERSATION
)
);
} else {
context_menu_model.append(
/// Translators: Context menu item
ngettext(
"_Delete conversation",
"_Delete conversations",
this.selected.size
),
Action.Window.prefix(
Application.MainWindow.ACTION_DELETE_CONVERSATION
)
);
}
}
if (conversation.is_unread())
context_menu_model.append(
_("Mark as _Read"),
Action.Window.prefix(
Application.MainWindow.ACTION_MARK_AS_READ
)
);
if (conversation.has_any_read_message())
context_menu_model.append(
_("Mark as _Unread"),
Action.Window.prefix(
Application.MainWindow.ACTION_MARK_AS_UNREAD
)
);
if (conversation.is_flagged()) {
context_menu_model.append(
_("U_nstar"),
Action.Window.prefix(
Application.MainWindow.ACTION_MARK_AS_UNSTARRED
)
);
} else {
context_menu_model.append(
_("_Star"),
Action.Window.prefix(
Application.MainWindow.ACTION_MARK_AS_STARRED
)
);
}
Menu actions_section = new Menu();
actions_section.append(
_("_Reply"),
Action.Window.prefix(
Application.MainWindow.ACTION_REPLY_CONVERSATION
)
);
actions_section.append(
_("R_eply All"),
Action.Window.prefix(
Application.MainWindow.ACTION_REPLY_ALL_CONVERSATION
)
);
actions_section.append(
_("_Forward"),
Action.Window.prefix(
Application.MainWindow.ACTION_FORWARD_CONVERSATION
)
);
context_menu_model.append_section(null, actions_section);
// Use a popover rather than a regular context menu since
// the latter grabs the event queue, so the MainWindow
// will not receive events if the user releases Shift,
// making the trash/delete header bar state wrong.
Gtk.Popover context_menu = new Gtk.Popover.from_model(
this, context_menu_model
);
Gdk.Rectangle dest = Gdk.Rectangle();
dest.x = (int) event.x;
dest.y = (int) event.y;
context_menu.set_pointing_to(dest);
context_menu.popup();
// When the conversation under the mouse is selected, stop event propagation
return get_selection().path_is_selected(path);
}
return false;
}
private void on_style_changed() {
// Recalculate dimensions of child cells.
ConversationListCellRenderer.style_changed(this);
schedule_visible_conversations_changed();
}
private void on_value_changed() {
if (this.enable_load_more) {
check_load_more();
}
}
private static Gtk.TreeViewColumn create_column(ConversationListStore.Column column,
Gtk.CellRenderer renderer, string attr, int width = 0) {
Gtk.TreeViewColumn view_column = new Gtk.TreeViewColumn.with_attributes(column.to_string(),
renderer, attr, column);
view_column.set_resizable(true);
if (width != 0) {
view_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED);
view_column.set_fixed_width(width);
}
return view_column;
}
private List<Gtk.TreePath> get_all_selected_paths() {
Gtk.TreeModel model;
return get_selection().get_selected_rows(out model);
}
private void on_selection_changed() {
// Schedule processing selection changes at low idle for
// two reasons: (a) if a lot of changes come in
// back-to-back, this allows for all that activity to
// settle before updating state and firing signals (which
// results in a lot of I/O), and (b) it means the
// ConversationMonitor's signals may be processed in any
// order by this class and the ConversationListView and
// not result in a lot of screen flashing and (again)
// unnecessary I/O as both classes update selection state.
this.selection_update.schedule();
}
// Gtk.TreeSelection can fire its "changed" signal even when
// nothing's changed, so look for that to avoid subscribers from
// doing the same things (in particular, I/O) multiple times
private void do_selection_changed() {
Gee.HashSet<Geary.App.Conversation> new_selection =
new Gee.HashSet<Geary.App.Conversation>();
List<Gtk.TreePath> paths = get_all_selected_paths();
if (paths.length() != 0) {
// Conversations are selected, so collect them and
// signal if different
foreach (Gtk.TreePath path in paths) {
Geary.App.Conversation? conversation =
get_model().get_conversation_at_path(path);
if (conversation != null)
new_selection.add(conversation);
}
}
// only notify if different than what was previously reported
if (this.selected.size != new_selection.size ||
!this.selected.contains_all(new_selection)) {
this.selected = new_selection;
conversations_selected(this.selected.read_only_view);
}
}
public Gee.Set<Geary.App.Conversation> get_visible_conversations() {
Gee.HashSet<Geary.App.Conversation> visible_conversations = new Gee.HashSet<Geary.App.Conversation>();
Gtk.TreePath start_path;
Gtk.TreePath end_path;
if (!get_visible_range(out start_path, out end_path))
return visible_conversations;
while (start_path.compare(end_path) <= 0) {
Geary.App.Conversation? conversation = get_model().get_conversation_at_path(start_path);
if (conversation != null)
visible_conversations.add(conversation);
start_path.next();
}
return visible_conversations;
}
// Always returns false, so it can be used as a one-time SourceFunc
private bool update_visible_conversations() {
bool changed = false;
Gee.Set<Geary.App.Conversation> visible_conversations = get_visible_conversations();
if (this.current_visible_conversations == null ||
this.current_visible_conversations.size != visible_conversations.size ||
!this.current_visible_conversations.contains_all(visible_conversations)) {
this.current_visible_conversations = visible_conversations;
visible_conversations_changed(
this.current_visible_conversations.read_only_view
);
changed = true;
}
return changed;
}
private void schedule_visible_conversations_changed() {
scheduled_update_visible_conversations = Geary.Scheduler.on_idle(update_visible_conversations);
}
public void select_conversations(Gee.Collection<Geary.App.Conversation> new_selection) {
if (this.selected.size != new_selection.size ||
!this.selected.contains_all(new_selection)) {
var selection = get_selection();
selection.unselect_all();
var model = get_model();
if (model != null) {
foreach (var conversation in new_selection) {
var path = model.get_path_for_conversation(conversation);
if (path != null) {
selection.select_path(path);
}
}
}
}
}
private void on_rows_changed() {
schedule_visible_conversations_changed();
}
private void on_display_preview_changed() {
style_updated();
model.foreach(refresh_path);
schedule_visible_conversations_changed();
}
private bool refresh_path(Gtk.TreeModel model, Gtk.TreePath path, Gtk.TreeIter iter) {
model.row_changed(path, iter);
return false;
}
private void on_row_activated(Gtk.TreePath path) {
Geary.App.Conversation? c = get_model().get_conversation_at_path(path);
if (c != null)
conversation_activated(c);
}
// Enable/disable hover effect on all selected cells.
private void set_hover_selected(bool hover) {
ConversationListCellRenderer.set_hover_selected(hover);
queue_draw();
}
private bool on_motion_notify_event(Gdk.EventMotion event) {
if (get_selection().count_selected_rows() > 0) {
Gtk.TreePath? path = null;
int cell_x, cell_y;
get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y);
set_hover_selected(path != null && get_selection().path_is_selected(path));
}
return Gdk.EVENT_PROPAGATE;
}
private bool on_leave_notify_event() {
if (get_selection().count_selected_rows() > 0) {
set_hover_selected(false);
}
return Gdk.EVENT_PROPAGATE;
}
private void on_vadjustment_changed() {
this.vadjustment.value_changed.connect(on_value_changed);
}
}