/* 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 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? added, Gee.Iterable? removed) { update_view_filter_message(); } private void on_items_state_changed(Gee.Iterable changed) { update_view_filter_message(); } private void on_items_visibility_changed(Gee.Collection 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 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(); 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? 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(); } }