summaryrefslogtreecommitdiff
path: root/src/CheckerboardPage.vala
diff options
context:
space:
mode:
Diffstat (limited to 'src/CheckerboardPage.vala')
-rw-r--r--src/CheckerboardPage.vala758
1 files changed, 758 insertions, 0 deletions
diff --git a/src/CheckerboardPage.vala b/src/CheckerboardPage.vala
new file mode 100644
index 0000000..24a252a
--- /dev/null
+++ b/src/CheckerboardPage.vala
@@ -0,0 +1,758 @@
+/* Copyright 2016 Software Freedom Conservancy Inc.
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public abstract class CheckerboardPage : Page {
+ private const int AUTOSCROLL_PIXELS = 50;
+ private const int AUTOSCROLL_TICKS_MSEC = 50;
+
+ private CheckerboardLayout layout;
+ private Gtk.Stack stack;
+ private PageMessagePane message_pane;
+ private string item_context_menu_path = null;
+ private string page_context_menu_path = null;
+ private Gtk.Viewport viewport = new Gtk.Viewport(null, null);
+ protected CheckerboardItem anchor = null;
+ protected CheckerboardItem cursor = null;
+ private CheckerboardItem current_hovered_item = null;
+ private bool autoscroll_scheduled = false;
+ private CheckerboardItem activated_item = null;
+ private Gee.ArrayList<CheckerboardItem> previously_selected = null;
+
+ public enum Activator {
+ KEYBOARD,
+ MOUSE
+ }
+
+ public struct KeyboardModifiers {
+ public KeyboardModifiers(Page page) {
+ ctrl_pressed = page.get_ctrl_pressed();
+ alt_pressed = page.get_alt_pressed();
+ shift_pressed = page.get_shift_pressed();
+ super_pressed = page.get_super_pressed();
+ }
+
+ public bool ctrl_pressed;
+ public bool alt_pressed;
+ public bool shift_pressed;
+ public bool super_pressed;
+ }
+
+ protected CheckerboardPage(string page_name) {
+ base (page_name);
+
+ stack = new Gtk.Stack();
+ message_pane = new PageMessagePane();
+
+ layout = new CheckerboardLayout(get_view());
+ layout.set_name(page_name);
+ stack.add_named (layout, "layout");
+ stack.add_named (message_pane, "message");
+ stack.set_visible_child(layout);
+
+ set_event_source(layout);
+
+ set_border_width(0);
+ set_shadow_type(Gtk.ShadowType.NONE);
+
+ viewport.set_border_width(0);
+ viewport.set_shadow_type(Gtk.ShadowType.NONE);
+
+ viewport.add(stack);
+
+ // want to set_adjustments before adding to ScrolledWindow to let our signal handlers
+ // run first ... otherwise, the thumbnails draw late
+ layout.set_adjustments(get_hadjustment(), get_vadjustment());
+
+ add(viewport);
+
+ // need to monitor items going hidden when dealing with anchor/cursor/highlighted items
+ get_view().items_hidden.connect(on_items_hidden);
+ get_view().contents_altered.connect(on_contents_altered);
+ get_view().items_state_changed.connect(on_items_state_changed);
+ get_view().items_visibility_changed.connect(on_items_visibility_changed);
+
+ // scrollbar policy
+ set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
+ }
+
+ public void init_item_context_menu(string path) {
+ item_context_menu_path = path;
+ }
+
+ public void init_page_context_menu(string path) {
+ page_context_menu_path = path;
+ }
+
+ public Gtk.Menu? get_context_menu() {
+ // show page context menu if nothing is selected
+ return (get_view().get_selected_count() != 0) ? get_item_context_menu() :
+ get_page_context_menu();
+ }
+
+ private Gtk.Menu item_context_menu;
+ public virtual Gtk.Menu? get_item_context_menu() {
+ if (item_context_menu == null) {
+ var model = this.builder.get_object (item_context_menu_path)
+ as GLib.MenuModel;
+ item_context_menu = new Gtk.Menu.from_model (model);
+ item_context_menu.attach_to_widget (this, null);
+ }
+
+ return item_context_menu;
+ }
+
+ private Gtk.Menu page_context_menu;
+ public override Gtk.Menu? get_page_context_menu() {
+ if (page_context_menu_path == null)
+ return null;
+
+ if (page_context_menu == null) {
+ var model = this.builder.get_object (page_context_menu_path)
+ as GLib.MenuModel;
+ page_context_menu = new Gtk.Menu.from_model (model);
+ page_context_menu.attach_to_widget (this, null);
+ }
+
+ return page_context_menu;
+ }
+
+ protected override bool on_context_keypress() {
+ return popup_context_menu(get_context_menu());
+ }
+
+ protected virtual string get_view_empty_icon() {
+ return "image-x-generic-symbolic";
+ }
+
+ protected virtual string get_view_empty_message() {
+ return _("No photos/videos");
+ }
+
+ protected virtual string get_filter_no_match_message() {
+ return _("No photos/videos found which match the current filter");
+ }
+
+ protected virtual void on_item_activated(CheckerboardItem item, Activator activator,
+ KeyboardModifiers modifiers) {
+ }
+
+ public CheckerboardLayout get_checkerboard_layout() {
+ return layout;
+ }
+
+ // Gets the search view filter for this page.
+ public abstract SearchViewFilter get_search_view_filter();
+
+ public virtual Core.ViewTracker? get_view_tracker() {
+ return null;
+ }
+
+ public override void switching_from() {
+ layout.set_in_view(false);
+ get_search_view_filter().refresh.disconnect(on_view_filter_refresh);
+
+ // unselect everything so selection won't persist after page loses focus
+ get_view().unselect_all();
+
+ base.switching_from();
+ }
+
+ public void scroll_to_item(CheckerboardItem item) {
+ Gtk.Adjustment vadj = get_vadjustment();
+ if (!(get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE
+ && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE))) {
+
+ // scroll to see the new item
+ int top = 0;
+ if (item.allocation.y < vadj.get_value()) {
+ top = item.allocation.y;
+ top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2;
+ } else {
+ top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size();
+ top += CheckerboardLayout.ROW_GUTTER_PADDING / 2;
+ }
+
+ vadj.set_value(top);
+
+ }
+ }
+
+ public override void switched_to() {
+ layout.set_in_view(true);
+ get_search_view_filter().refresh.connect(on_view_filter_refresh);
+ on_view_filter_refresh();
+
+ if (get_view().get_selected_count() > 0) {
+ CheckerboardItem? item = (CheckerboardItem?) get_view().get_selected_at(0);
+
+ // if item is in any way out of view, scroll to it
+ scroll_to_item(item);
+ }
+
+ base.switched_to();
+ }
+
+ private void on_view_filter_refresh() {
+ update_view_filter_message();
+ }
+
+ private void on_contents_altered(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ update_view_filter_message();
+ }
+
+ private void on_items_state_changed(Gee.Iterable<DataView> changed) {
+ update_view_filter_message();
+ }
+
+ private void on_items_visibility_changed(Gee.Collection<DataView> changed) {
+ update_view_filter_message();
+ }
+
+ private void update_view_filter_message() {
+ if (get_view().are_items_filtered_out() && get_view().get_count() == 0) {
+ set_page_message(get_filter_no_match_message());
+ } else if (get_view().get_count() == 0) {
+ set_page_message(get_view_empty_message());
+ } else {
+ unset_page_message();
+ }
+ }
+
+ public void set_page_message(string message) {
+ message_pane.label.label = message;
+ try {
+ message_pane.icon_image.icon_name = null;
+ message_pane.icon_image.gicon = Icon.new_for_string (get_view_empty_icon());
+ } catch (Error error) {
+ message_pane.icon_image.gicon = null;
+ message_pane.icon_image.icon_name = "image-x-generic-symbolic";
+ }
+ stack.set_visible_child_name ("message");
+ }
+
+ public void unset_page_message() {
+ stack.set_visible_child (layout);
+ }
+
+ public override void set_page_name(string name) {
+ base.set_page_name(name);
+
+ layout.set_name(name);
+ }
+
+ public CheckerboardItem? get_item_at_pixel(double x, double y) {
+ return layout.get_item_at_pixel(x, y);
+ }
+
+ private void on_items_hidden(Gee.Iterable<DataView> hidden) {
+ foreach (DataView view in hidden) {
+ CheckerboardItem item = (CheckerboardItem) view;
+
+ if (anchor == item)
+ anchor = null;
+
+ if (cursor == item)
+ cursor = null;
+
+ if (current_hovered_item == item)
+ current_hovered_item = null;
+ }
+ }
+
+ protected override bool key_press_event(Gdk.EventKey event) {
+ bool handled = true;
+
+ // mask out the modifiers we're interested in
+ uint state = event.state & Gdk.ModifierType.SHIFT_MASK;
+
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Up":
+ case "KP_Up":
+ move_cursor(CompassPoint.NORTH);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Down":
+ case "KP_Down":
+ move_cursor(CompassPoint.SOUTH);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Left":
+ case "KP_Left":
+ move_cursor(CompassPoint.WEST);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Right":
+ case "KP_Right":
+ move_cursor(CompassPoint.EAST);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Home":
+ case "KP_Home":
+ CheckerboardItem? first = (CheckerboardItem?) get_view().get_first();
+ if (first != null)
+ cursor_to_item(first);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "End":
+ case "KP_End":
+ CheckerboardItem? last = (CheckerboardItem?) get_view().get_last();
+ if (last != null)
+ cursor_to_item(last);
+ select_anchor_to_cursor(state);
+ break;
+
+ case "Return":
+ case "KP_Enter":
+ if (get_view().get_selected_count() == 1)
+ on_item_activated((CheckerboardItem) get_view().get_selected_at(0),
+ Activator.KEYBOARD, KeyboardModifiers(this));
+ else
+ handled = false;
+ break;
+
+ case "space":
+ Marker marker = get_view().mark(layout.get_cursor());
+ get_view().toggle_marked(marker);
+ break;
+
+ default:
+ handled = false;
+ break;
+ }
+
+ if (handled)
+ return true;
+
+ return (base.key_press_event != null) ? base.key_press_event(event) : true;
+ }
+
+ protected override bool on_left_click(Gdk.EventButton event) {
+ // only interested in single-click and double-clicks for now
+ if ((event.type != Gdk.EventType.BUTTON_PRESS) && (event.type != Gdk.EventType.2BUTTON_PRESS))
+ return false;
+
+ // mask out the modifiers we're interested in
+ uint state = event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK);
+
+ // use clicks for multiple selection and activation only; single selects are handled by
+ // button release, to allow for multiple items to be selected then dragged ...
+ CheckerboardItem item = get_item_at_pixel(event.x, event.y);
+ if (item != null) {
+ // ... however, there is no dragging if the user clicks on an interactive part of the
+ // CheckerboardItem (e.g. a tag)
+ if (layout.handle_left_click(item, event.x, event.y, event.state))
+ return true;
+
+ switch (state) {
+ case Gdk.ModifierType.CONTROL_MASK:
+ // with only Ctrl pressed, multiple selections are possible ... chosen item
+ // is toggled
+ Marker marker = get_view().mark(item);
+ get_view().toggle_marked(marker);
+
+ if (item.is_selected()) {
+ anchor = item;
+ cursor = item;
+ }
+ break;
+
+ case Gdk.ModifierType.SHIFT_MASK:
+ get_view().unselect_all();
+
+ if (anchor == null)
+ anchor = item;
+
+ select_between_items(anchor, item);
+
+ cursor = item;
+ break;
+
+ case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK:
+ // Ticket #853 - Make Ctrl + Shift + Mouse Button 1 able to start a new run
+ // of contiguous selected items without unselecting previously-selected items
+ // a la Nautilus.
+ // Same as the case for SHIFT_MASK, but don't unselect anything first.
+ if (anchor == null)
+ anchor = item;
+
+ select_between_items(anchor, item);
+
+ cursor = item;
+ break;
+
+ default:
+ if (event.type == Gdk.EventType.2BUTTON_PRESS) {
+ activated_item = item;
+ } else {
+ // if the user has selected one or more items and is preparing for a drag,
+ // don't want to blindly unselect: if they've clicked on an unselected item
+ // unselect all and select that one; if they've clicked on a previously
+ // selected item, do nothing
+ if (!item.is_selected()) {
+ Marker all = get_view().start_marking();
+ all.mark_many(get_view().get_selected());
+
+ get_view().unselect_and_select_marked(all, get_view().mark(item));
+ }
+ }
+
+ anchor = item;
+ cursor = item;
+ break;
+ }
+ layout.set_cursor(item);
+ } else {
+ // user clicked on "dead" area; only unselect if control is not pressed
+ // do we want similar behavior for shift as well?
+ if (state != Gdk.ModifierType.CONTROL_MASK)
+ get_view().unselect_all();
+
+ // grab previously marked items
+ previously_selected = new Gee.ArrayList<CheckerboardItem>();
+ foreach (DataView view in get_view().get_selected())
+ previously_selected.add((CheckerboardItem) view);
+
+ layout.set_drag_select_origin((int) event.x, (int) event.y);
+
+ return true;
+ }
+
+ // need to determine if the signal should be passed to the DnD handlers
+ // Return true to block the DnD handler, false otherwise
+
+ return get_view().get_selected_count() == 0;
+ }
+
+ protected override bool on_left_released(Gdk.EventButton event) {
+ previously_selected = null;
+
+ // if drag-selecting, stop here and do nothing else
+ if (layout.is_drag_select_active()) {
+ layout.clear_drag_select();
+ anchor = cursor;
+
+ return true;
+ }
+
+ // only interested in non-modified button releases
+ if ((event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) != 0)
+ return false;
+
+ // if the item was activated in the double-click, report it now
+ if (activated_item != null) {
+ on_item_activated(activated_item, Activator.MOUSE, KeyboardModifiers(this));
+ activated_item = null;
+
+ return true;
+ }
+
+ CheckerboardItem item = get_item_at_pixel(event.x, event.y);
+ if (item == null) {
+ // released button on "dead" area
+ return true;
+ }
+
+ if (cursor != item) {
+ // user released mouse button after moving it off the initial item, or moved from dead
+ // space onto one. either way, unselect everything
+ get_view().unselect_all();
+ } else {
+ // the idea is, if a user single-clicks on an item with no modifiers, then all other items
+ // should be deselected, however, if they single-click in order to drag one or more items,
+ // they should remain selected, hence performing this here rather than on_left_click
+ // (item may not be selected if an unimplemented modifier key was used)
+ if (item.is_selected())
+ get_view().unselect_all_but(item);
+ }
+
+ return true;
+ }
+
+ protected override bool on_right_click(Gdk.EventButton event) {
+ // only interested in single-clicks for now
+ if (event.type != Gdk.EventType.BUTTON_PRESS)
+ return false;
+
+ // get what's right-clicked upon
+ CheckerboardItem item = get_item_at_pixel(event.x, event.y);
+ if (item != null) {
+ // mask out the modifiers we're interested in
+ switch (event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) {
+ case Gdk.ModifierType.CONTROL_MASK:
+ // chosen item is toggled
+ Marker marker = get_view().mark(item);
+ get_view().toggle_marked(marker);
+ break;
+
+ case Gdk.ModifierType.SHIFT_MASK:
+ // TODO
+ break;
+
+ case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK:
+ // TODO
+ break;
+
+ default:
+ // if the item is already selected, proceed; if item is not selected, a bare right
+ // click unselects everything else but it
+ if (!item.is_selected()) {
+ Marker all = get_view().start_marking();
+ all.mark_many(get_view().get_selected());
+
+ get_view().unselect_and_select_marked(all, get_view().mark(item));
+ }
+ break;
+ }
+ } else {
+ // clicked in "dead" space, unselect everything
+ get_view().unselect_all();
+ }
+
+ Gtk.Menu context_menu = get_context_menu();
+ return popup_context_menu(context_menu, event);
+ }
+
+ protected virtual bool on_mouse_over(CheckerboardItem? item, int x, int y, Gdk.ModifierType mask) {
+ if (item != null)
+ layout.handle_mouse_motion(item, x, y, mask);
+
+ // if hovering over the last hovered item, or both are null (nothing highlighted and
+ // hovering over empty space), do nothing
+ if (item == current_hovered_item)
+ return true;
+
+ // either something new is highlighted or now hovering over empty space, so dim old item
+ if (current_hovered_item != null) {
+ current_hovered_item.handle_mouse_leave();
+ current_hovered_item = null;
+ }
+
+ // if over empty space, done
+ if (item == null)
+ return true;
+
+ // brighten the new item
+ current_hovered_item = item;
+ current_hovered_item.handle_mouse_enter();
+
+ return true;
+ }
+
+ protected override bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) {
+ // report what item the mouse is hovering over
+ if (!on_mouse_over(get_item_at_pixel(x, y), x, y, mask))
+ return false;
+
+ // go no further if not drag-selecting
+ if (!layout.is_drag_select_active())
+ return false;
+
+ // set the new endpoint of the drag selection
+ layout.set_drag_select_endpoint(x, y);
+
+ updated_selection_band();
+
+ // if out of bounds, schedule a check to auto-scroll the viewport
+ if (!autoscroll_scheduled
+ && get_adjustment_relation(get_vadjustment(), y) != AdjustmentRelation.IN_RANGE) {
+ Timeout.add(AUTOSCROLL_TICKS_MSEC, selection_autoscroll);
+ autoscroll_scheduled = true;
+ }
+
+ // return true to stop a potential drag-and-drop operation
+ return true;
+ }
+
+ private void updated_selection_band() {
+ assert(layout.is_drag_select_active());
+
+ // get all items inside the selection
+ Gee.List<CheckerboardItem>? intersection = layout.items_in_selection_band();
+ if (intersection == null)
+ return;
+
+ Marker to_unselect = get_view().start_marking();
+ Marker to_select = get_view().start_marking();
+
+ // mark all selected items to be unselected
+ to_unselect.mark_many(get_view().get_selected());
+
+ // except for the items that were selected before the drag began
+ assert(previously_selected != null);
+ to_unselect.unmark_many(previously_selected);
+ to_select.mark_many(previously_selected);
+
+ // toggle selection on everything in the intersection and update the cursor
+ cursor = null;
+
+ foreach (CheckerboardItem item in intersection) {
+ if (to_select.toggle(item))
+ to_unselect.unmark(item);
+ else
+ to_unselect.mark(item);
+
+ if (cursor == null)
+ cursor = item;
+ }
+
+ get_view().select_marked(to_select);
+ get_view().unselect_marked(to_unselect);
+ }
+
+ private bool selection_autoscroll() {
+ if (!layout.is_drag_select_active()) {
+ autoscroll_scheduled = false;
+
+ return false;
+ }
+
+ // as the viewport never scrolls horizontally, only interested in vertical
+ Gtk.Adjustment vadj = get_vadjustment();
+
+ int x, y;
+ Gdk.ModifierType mask;
+ get_event_source_pointer(out x, out y, out mask);
+
+ int new_value = (int) vadj.get_value();
+ switch (get_adjustment_relation(vadj, y)) {
+ case AdjustmentRelation.BELOW:
+ // pointer above window, scroll up
+ new_value -= AUTOSCROLL_PIXELS;
+ layout.set_drag_select_endpoint(x, new_value);
+ break;
+
+ case AdjustmentRelation.ABOVE:
+ // pointer below window, scroll down, extend selection to bottom of page
+ new_value += AUTOSCROLL_PIXELS;
+ layout.set_drag_select_endpoint(x, new_value + (int) vadj.get_page_size());
+ break;
+
+ case AdjustmentRelation.IN_RANGE:
+ autoscroll_scheduled = false;
+
+ return false;
+
+ default:
+ warn_if_reached();
+ break;
+ }
+
+ // It appears that in GTK+ 2.18, the adjustment is not clamped the way it was in 2.16.
+ // This may have to do with how adjustments are different w/ scrollbars, that they're upper
+ // clamp is upper - page_size ... either way, enforce these limits here
+ vadj.set_value(new_value.clamp((int) vadj.get_lower(),
+ (int) vadj.get_upper() - (int) vadj.get_page_size()));
+
+ updated_selection_band();
+
+ return true;
+ }
+
+ public void cursor_to_item(CheckerboardItem item) {
+ assert(get_view().contains(item));
+
+ cursor = item;
+
+ if (!get_ctrl_pressed()) {
+ get_view().unselect_all();
+ Marker marker = get_view().mark(item);
+ get_view().select_marked(marker);
+ }
+ layout.set_cursor(item);
+ scroll_to_item(item);
+ }
+
+ public void move_cursor(CompassPoint point) {
+ // if no items, nothing to do
+ if (get_view().get_count() == 0)
+ return;
+
+ // if there is no better starting point, simply select the first and exit
+ // The right half of the or is related to Bug #732334, the cursor might be non-null and still not contained in
+ // the view, if the user dragged a full screen Photo off screen
+ if (cursor == null && layout.get_cursor() == null || cursor != null && !get_view().contains(cursor)) {
+ CheckerboardItem item = layout.get_item_at_coordinate(0, 0);
+ cursor_to_item(item);
+ anchor = item;
+
+ return;
+ }
+
+ if (cursor == null) {
+ cursor = layout.get_cursor() as CheckerboardItem;
+ }
+
+ // move the cursor relative to the "first" item
+ CheckerboardItem? item = layout.get_item_relative_to(cursor, point);
+ if (item != null)
+ cursor_to_item(item);
+ }
+
+ public void set_cursor(CheckerboardItem item) {
+ Marker marker = get_view().mark(item);
+ get_view().select_marked(marker);
+
+ cursor = item;
+ anchor = item;
+ }
+
+ public void select_between_items(CheckerboardItem item_start, CheckerboardItem item_end) {
+ Marker marker = get_view().start_marking();
+
+ bool passed_start = false;
+ bool passed_end = false;
+
+ foreach (DataObject object in get_view().get_all()) {
+ CheckerboardItem item = (CheckerboardItem) object;
+
+ if (item_start == item)
+ passed_start = true;
+
+ if (item_end == item)
+ passed_end = true;
+
+ if (passed_start || passed_end)
+ marker.mark((DataView) object);
+
+ if (passed_start && passed_end)
+ break;
+ }
+
+ get_view().select_marked(marker);
+ }
+
+ public void select_anchor_to_cursor(uint state) {
+ if (cursor == null || anchor == null)
+ return;
+
+ if (state == Gdk.ModifierType.SHIFT_MASK) {
+ get_view().unselect_all();
+ select_between_items(anchor, cursor);
+ } else {
+ anchor = cursor;
+ }
+ }
+
+ protected virtual void set_display_titles(bool display) {
+ get_view().freeze_notifications();
+ get_view().set_property(CheckerboardItem.PROP_SHOW_TITLES, display);
+ get_view().thaw_notifications();
+ }
+
+ protected virtual void set_display_comments(bool display) {
+ get_view().freeze_notifications();
+ get_view().set_property(CheckerboardItem.PROP_SHOW_COMMENTS, display);
+ get_view().thaw_notifications();
+ }
+}
+
+