[shotwell] Split-up Page.vala



commit 49dd545e91d958817fd502ee3a45b1c0de8642b0
Author: Jens Georg <mail jensge org>
Date:   Sat Feb 23 19:47:51 2019 +0100

    Split-up Page.vala

 po/POTFILES.in              |    4 +
 po/POTFILES.skip            |    4 +
 src/CheckerboardPage.vala   |  763 ++++++++++++++++++++++
 src/DragAndDropHandler.vala |  187 ++++++
 src/Page.vala               | 1472 -------------------------------------------
 src/PageMessagePane.vala    |   19 +
 src/SinglePhotoPage.vala    |  529 ++++++++++++++++
 src/meson.build             |    4 +
 8 files changed, 1510 insertions(+), 1472 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index edc08eac..f0ea7c05 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -83,6 +83,7 @@ src/BatchImport.vala
 src/camera/CameraBranch.vala
 src/camera/CameraTable.vala
 src/camera/ImportPage.vala
+src/CheckerboardPage.vala
 src/CollectionPage.vala
 src/Commands.vala
 src/data_imports/DataImportsPluginHost.vala
@@ -100,6 +101,7 @@ src/dialogs/WelcomeDialog.vala
 src/Dimensions.vala
 src/direct/DirectPhotoPage.vala
 src/DirectoryMonitor.vala
+src/DragAndDropHandler.vala
 src/editing_tools/EditingTools.vala
 src/editing_tools/StraightenTool.vala
 src/events/EventDirectoryItem.vala
@@ -123,6 +125,7 @@ src/main.vala
 src/MediaMonitor.vala
 src/MediaPage.vala
 src/Page.vala
+src/PageMessagePane.vala
 src/PhotoPage.vala
 src/photos/BmpSupport.vala
 src/photos/GifSupport.vala
@@ -145,6 +148,7 @@ src/searches/SavedSearchDialog.vala
 src/searches/SearchBoolean.vala
 src/searches/SearchesBranch.vala
 src/SearchFilter.vala
+src/SinglePhotoPage.vala
 src/SlideshowPage.vala
 src/slideshow/Slideshow.vala
 src/slideshow/TransitionEffects.vala
diff --git a/po/POTFILES.skip b/po/POTFILES.skip
index 00c4e430..373808a0 100644
--- a/po/POTFILES.skip
+++ b/po/POTFILES.skip
@@ -36,6 +36,7 @@ src/BatchImport.c
 src/camera/CameraBranch.c
 src/camera/CameraTable.c
 src/camera/ImportPage.c
+src/CheckerboardPage.c
 src/CollectionPage.c
 src/Commands.c
 src/data_imports/DataImports.c
@@ -53,6 +54,7 @@ src/Dialogs.c
 src/Dimensions.c
 src/direct/DirectPhotoPage.c
 src/DirectoryMonitor.c
+src/DragAndDropHandler.c
 src/editing_tools/EditingTools.c
 src/editing_tools/StraightenTool.c
 src/Event.c
@@ -76,6 +78,7 @@ src/main.c
 src/MediaMonitor.c
 src/MediaPage.c
 src/Page.c
+src/PageMessagePane.c
 src/Photo.c
 src/PhotoPage.c
 src/photos/BmpSupport.c
@@ -98,6 +101,7 @@ src/searches/SavedSearchDialog.c
 src/searches/SearchBoolean.c
 src/searches/SearchesBranch.c
 src/SearchFilter.c
+src/SinglePhotoPage.c
 src/SlideshowPage.c
 src/slideshow/Slideshow.c
 src/slideshow/TransitionEffects.c
diff --git a/src/CheckerboardPage.vala b/src/CheckerboardPage.vala
new file mode 100644
index 00000000..b8ef3332
--- /dev/null
+++ b/src/CheckerboardPage.vala
@@ -0,0 +1,763 @@
+/* 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;
+    private MapWidget map_widget = 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;
+    }
+
+    public 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);
+
+        map_widget = MapWidget.get_instance();
+    }
+
+    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();
+            map_widget.unhighlight_position_marker(current_hovered_item);
+            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();
+        map_widget.highlight_position_marker(item);
+
+        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();
+    }
+}
+
+
diff --git a/src/DragAndDropHandler.vala b/src/DragAndDropHandler.vala
new file mode 100644
index 00000000..4348de22
--- /dev/null
+++ b/src/DragAndDropHandler.vala
@@ -0,0 +1,187 @@
+/* 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.
+ */
+
+//
+// DragAndDropHandler attaches signals to a Page to properly handle drag-and-drop requests for the
+// Page as a DnD Source.  (DnD Destination handling is handled by the appropriate AppWindow, i.e.
+// LibraryWindow and DirectWindow). Assumes the Page's ViewCollection holds MediaSources.
+//
+public class DragAndDropHandler {
+    private enum TargetType {
+        XDS,
+        MEDIA_LIST
+    }
+
+    private const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = {
+        { "XdndDirectSave0", Gtk.TargetFlags.OTHER_APP, TargetType.XDS },
+        { "shotwell/media-id-atom", Gtk.TargetFlags.SAME_APP, TargetType.MEDIA_LIST }
+    };
+
+    private static Gdk.Atom? XDS_ATOM = null;
+    private static Gdk.Atom? TEXT_ATOM = null;
+    private static uint8[]? XDS_FAKE_TARGET = null;
+
+    private weak Page page;
+    private Gtk.Widget event_source;
+    private File? drag_destination = null;
+    private ExporterUI exporter = null;
+
+    public DragAndDropHandler(Page page) {
+        this.page = page;
+        this.event_source = page.get_event_source();
+        assert(event_source != null);
+        assert(event_source.get_has_window());
+
+        // Need to do this because static member variables are not properly handled
+        if (XDS_ATOM == null)
+            XDS_ATOM = Gdk.Atom.intern_static_string("XdndDirectSave0");
+
+        if (TEXT_ATOM == null)
+            TEXT_ATOM = Gdk.Atom.intern_static_string("text/plain");
+
+        if (XDS_FAKE_TARGET == null)
+            XDS_FAKE_TARGET = string_to_uchar_array("shotwell.txt");
+
+        // register what's available on this DnD Source
+        Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, SOURCE_TARGET_ENTRIES,
+            Gdk.DragAction.COPY);
+
+        // attach to the event source's DnD signals, not the Page's, which is a NO_WINDOW widget
+        // and does not emit them
+        event_source.drag_begin.connect(on_drag_begin);
+        event_source.drag_data_get.connect(on_drag_data_get);
+        event_source.drag_end.connect(on_drag_end);
+        event_source.drag_failed.connect(on_drag_failed);
+    }
+
+    ~DragAndDropHandler() {
+        if (event_source != null) {
+            event_source.drag_begin.disconnect(on_drag_begin);
+            event_source.drag_data_get.disconnect(on_drag_data_get);
+            event_source.drag_end.disconnect(on_drag_end);
+            event_source.drag_failed.disconnect(on_drag_failed);
+        }
+
+        page = null;
+        event_source = null;
+    }
+
+    private void on_drag_begin(Gdk.DragContext context) {
+        debug("on_drag_begin (%s)", page.get_page_name());
+
+        if (page == null || page.get_view().get_selected_count() == 0 || exporter != null)
+            return;
+
+        drag_destination = null;
+
+        // use the first media item as the icon
+        ThumbnailSource thumb = (ThumbnailSource) page.get_view().get_selected_at(0).get_source();
+
+        try {
+            Gdk.Pixbuf icon = thumb.get_thumbnail(AppWindow.DND_ICON_SCALE);
+            Gtk.drag_source_set_icon_pixbuf(event_source, icon);
+        } catch (Error err) {
+            warning("Unable to fetch icon for drag-and-drop from %s: %s", thumb.to_string(),
+                err.message);
+        }
+
+        // set the XDS property to indicate an XDS save is available
+#if VALA_0_20
+        Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE,
+            XDS_FAKE_TARGET, 1);
+#else
+        Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE,
+            XDS_FAKE_TARGET);
+#endif
+    }
+
+    private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data,
+        uint target_type, uint time) {
+        debug("on_drag_data_get (%s)", page.get_page_name());
+
+        if (page == null || page.get_view().get_selected_count() == 0)
+            return;
+
+        switch (target_type) {
+            case TargetType.XDS:
+                // Fetch the XDS property that has been set with the destination path
+                uchar[] data = new uchar[4096];
+                Gdk.Atom actual_type;
+                int actual_format = 0;
+                bool fetched = Gdk.property_get(context.get_source_window(), XDS_ATOM, TEXT_ATOM,
+                    0, data.length, 0, out actual_type, out actual_format, out data);
+
+                // the destination path is actually for our XDS_FAKE_TARGET, use its parent
+                // to determine where the file(s) should go
+                if (fetched && data != null && data.length > 0)
+                    drag_destination = File.new_for_uri(uchar_array_to_string(data)).get_parent();
+
+                debug("on_drag_data_get (%s): %s", page.get_page_name(),
+                    (drag_destination != null) ? drag_destination.get_path() : "(no path)");
+
+                // Set the property to "S" for Success or "E" for Error
+                selection_data.set(XDS_ATOM, 8,
+                    string_to_uchar_array((drag_destination != null) ? "S" : "E"));
+            break;
+
+            case TargetType.MEDIA_LIST:
+                Gee.Collection<MediaSource> sources =
+                    (Gee.Collection<MediaSource>) page.get_view().get_selected_sources();
+
+                // convert the selected media sources to Gdk.Atom-encoded sourceID strings for
+                // internal drag-and-drop
+                selection_data.set(Gdk.Atom.intern_static_string("SourceIDAtom"), (int) sizeof(Gdk.Atom),
+                    serialize_media_sources(sources));
+            break;
+
+            default:
+                warning("on_drag_data_get (%s): unknown target type %u", page.get_page_name(),
+                    target_type);
+            break;
+        }
+    }
+
+    private void on_drag_end() {
+        debug("on_drag_end (%s)", page.get_page_name());
+
+        if (page == null || page.get_view().get_selected_count() == 0 || drag_destination == null
+            || exporter != null) {
+            return;
+        }
+
+        debug("Exporting to %s", drag_destination.get_path());
+
+        // drag-and-drop export doesn't pop up an export dialog, so use what are likely the
+        // most common export settings (the current -- or "working" -- file format, with
+        // all transformations applied, at the image's original size).
+        if (drag_destination.get_path() != null) {
+            exporter = new ExporterUI(new Exporter(
+                (Gee.Collection<Photo>) page.get_view().get_selected_sources(),
+                drag_destination, Scaling.for_original(), ExportFormatParameters.current()));
+            exporter.export(on_export_completed);
+        } else {
+            AppWindow.error_message(_("Photos cannot be exported to this directory."));
+        }
+
+        drag_destination = null;
+    }
+
+    private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
+        debug("on_drag_failed (%s): %d", page.get_page_name(), (int) drag_result);
+
+        if (page == null)
+            return false;
+
+        drag_destination = null;
+
+        return false;
+    }
+
+    private void on_export_completed() {
+        exporter = null;
+    }
+
+}
diff --git a/src/Page.vala b/src/Page.vala
index 59cbfb8b..076f42ae 100644
--- a/src/Page.vala
+++ b/src/Page.vala
@@ -1213,1475 +1213,3 @@ public abstract class Page : Gtk.ScrolledWindow {
 
 }
 
-[GtkTemplate (ui = "/org/gnome/Shotwell/ui/message_pane.ui")]
-private class PageMessagePane : Gtk.Box {
-    [GtkChild]
-    public Gtk.Label label;
-
-    [GtkChild]
-    public Gtk.Image icon_image;
-
-    public PageMessagePane() {
-        Object();
-    }
-}
-
-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;
-    private MapWidget map_widget = 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;
-    }
-
-    public 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);
-
-        map_widget = MapWidget.get_instance();
-    }
-    
-    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();
-            map_widget.unhighlight_position_marker(current_hovered_item);
-            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();
-        map_widget.highlight_position_marker(item);
-
-        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();
-    }
-}
-
-public abstract class SinglePhotoPage : Page {
-    public const Gdk.InterpType FAST_INTERP = Gdk.InterpType.NEAREST;
-    public const Gdk.InterpType QUALITY_INTERP = Gdk.InterpType.BILINEAR;
-    public const int KEY_REPEAT_INTERVAL_MSEC = 200;
-    
-    public enum UpdateReason {
-        NEW_PIXBUF,
-        QUALITY_IMPROVEMENT,
-        RESIZED_CANVAS
-    }
-    
-    protected Gtk.DrawingArea canvas = new Gtk.DrawingArea();
-    protected Gtk.Viewport viewport = new Gtk.Viewport(null, null);
-    
-    private bool scale_up_to_viewport;
-    private TransitionClock transition_clock;
-    private int transition_duration_msec = 0;
-    private Cairo.Surface pixmap = null;
-    private Cairo.Context pixmap_ctx = null;
-    private Cairo.Context text_ctx = null;
-    private Dimensions pixmap_dim = Dimensions();
-    private Gdk.Pixbuf unscaled = null;
-    private Dimensions max_dim = Dimensions();
-    private Gdk.Pixbuf scaled = null;
-    private Gdk.Pixbuf old_scaled = null; // previous scaled image
-    private Gdk.Rectangle scaled_pos = Gdk.Rectangle();
-    private ZoomState static_zoom_state;
-    private bool zoom_high_quality = true;
-    private ZoomState saved_zoom_state;
-    private bool has_saved_zoom_state = false;
-    private uint32 last_nav_key = 0;
-    
-    public SinglePhotoPage(string page_name, bool scale_up_to_viewport) {
-        base(page_name);
-        
-        this.scale_up_to_viewport = scale_up_to_viewport;
-        
-        transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock();
-        
-        // With the current code automatically resizing the image to the viewport, scrollbars
-        // should never be shown, but this may change if/when zooming is supported
-        set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
-
-        set_border_width(0);
-        set_shadow_type(Gtk.ShadowType.NONE);
-        
-        viewport.set_shadow_type(Gtk.ShadowType.NONE);
-        viewport.set_border_width(0);
-        viewport.add(canvas);
-        
-        add(viewport);
-
-        canvas.add_events(Gdk.EventMask.EXPOSURE_MASK | Gdk.EventMask.STRUCTURE_MASK 
-            | Gdk.EventMask.SUBSTRUCTURE_MASK);
-        
-        viewport.size_allocate.connect(on_viewport_resize);
-        canvas.draw.connect(on_canvas_exposed);
-        
-        set_event_source(canvas);
-        Config.Facade.get_instance().colors_changed.connect(on_colors_changed);
-    }
-
-    ~SinglePhotoPage() {
-        Config.Facade.get_instance().colors_changed.disconnect(on_colors_changed);
-    }
-    
-    public bool is_transition_in_progress() {
-        return transition_clock.is_in_progress();
-    }
-    
-    public void cancel_transition() {
-        if (transition_clock.is_in_progress())
-            transition_clock.cancel();
-    }
-    
-    public void set_transition(string effect_id, int duration_msec) {
-        cancel_transition();
-        
-        transition_clock = TransitionEffectsManager.get_instance().create_transition_clock(effect_id);
-        if (transition_clock == null)
-            transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock();
-        
-        transition_duration_msec = duration_msec;
-    }
-    
-    // This method includes a call to pixmap_ctx.paint().
-    private void render_zoomed_to_pixmap(ZoomState zoom_state) {
-        assert(is_zoom_supported());
-        
-        Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content();
-        
-        Gdk.Pixbuf zoomed;
-        if (get_zoom_buffer() != null) {
-            zoomed = (zoom_high_quality) ? get_zoom_buffer().get_zoomed_image(zoom_state) :
-                get_zoom_buffer().get_zoom_preview_image(zoom_state);
-        } else {
-            Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(unscaled);
-            
-            Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(unscaled, view_rect_proj.x,
-                view_rect_proj.y, view_rect_proj.width, view_rect_proj.height);
-            
-            zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height,
-                Gdk.InterpType.BILINEAR);
-        }
-
-        if (zoomed == null) {
-            return;
-        }
-        
-        int draw_x = (pixmap_dim.width - view_rect.width) / 2;
-        draw_x = draw_x.clamp(0, int.MAX);
-
-        int draw_y = (pixmap_dim.height - view_rect.height) / 2;
-        draw_y = draw_y.clamp(0, int.MAX);
-        paint_pixmap_with_background(pixmap_ctx, zoomed, draw_x, draw_y);
-    }
-
-    protected void on_interactive_zoom(ZoomState interactive_zoom_state) {
-        assert(is_zoom_supported());
-        
-        set_source_color_from_string(pixmap_ctx, "#000");
-        pixmap_ctx.paint();
-        
-        bool old_quality_setting = zoom_high_quality;
-        zoom_high_quality = false;
-        render_zoomed_to_pixmap(interactive_zoom_state);
-        zoom_high_quality = old_quality_setting;
-        
-        canvas.queue_draw();
-    }
-
-    protected void on_interactive_pan(ZoomState interactive_zoom_state) {
-        assert(is_zoom_supported());
-        
-        set_source_color_from_string(pixmap_ctx, "#000");
-        pixmap_ctx.paint();
-        
-        bool old_quality_setting = zoom_high_quality;
-        zoom_high_quality = true;
-        render_zoomed_to_pixmap(interactive_zoom_state);
-        zoom_high_quality = old_quality_setting;
-        
-        canvas.queue_draw();
-    }
-
-    protected virtual bool is_zoom_supported() {
-        return false;
-    }
-
-    protected virtual void cancel_zoom() {
-        if (pixmap != null) {
-            set_source_color_from_string(pixmap_ctx, "#000");
-            pixmap_ctx.paint();
-        }
-    }
-
-    protected virtual void save_zoom_state() {
-        saved_zoom_state = static_zoom_state;
-        has_saved_zoom_state = true;
-    }
-    
-    protected virtual void restore_zoom_state() {
-        if (!has_saved_zoom_state)
-            return;
-
-        static_zoom_state = saved_zoom_state;
-        repaint();
-        has_saved_zoom_state = false;
-    }
-    
-    protected virtual ZoomBuffer? get_zoom_buffer() {
-        return null;
-    }
-    
-    protected ZoomState get_saved_zoom_state() {
-        return saved_zoom_state;
-    }
-
-    protected void set_zoom_state(ZoomState zoom_state) {
-        assert(is_zoom_supported());
-
-        static_zoom_state = zoom_state;
-    }
-
-    protected ZoomState get_zoom_state() {
-        assert(is_zoom_supported());
-
-        return static_zoom_state;
-    }
-
-    public override void switched_to() {
-        base.switched_to();
-        
-        if (unscaled != null)
-            repaint();
-    }
-    
-    public override void set_container(Gtk.Window container) {
-        base.set_container(container);
-        
-        // scrollbar policy in fullscreen mode needs to be auto/auto, else the pixbuf will shift
-        // off the screen
-        if (container is FullscreenWindow)
-            set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
-    }
-    
-    // max_dim represents the maximum size of the original pixbuf (i.e. pixbuf may be scaled and
-    // the caller capable of producing larger ones depending on the viewport size).  max_dim
-    // is used when scale_up_to_viewport is set to true.  Pass a Dimensions with no area if
-    // max_dim should be ignored (i.e. scale_up_to_viewport is false).
-    public void set_pixbuf(Gdk.Pixbuf unscaled, Dimensions max_dim, Direction? direction = null) {
-        static_zoom_state = ZoomState(max_dim, pixmap_dim,
-            static_zoom_state.get_interpolation_factor(),
-            static_zoom_state.get_viewport_center());
-
-        cancel_transition();
-        
-        this.unscaled = unscaled;
-        this.max_dim = max_dim;
-        this.old_scaled = scaled;
-        scaled = null;
-        
-        // need to make sure this has happened
-        canvas.realize();
-        
-        repaint(direction);
-    }
-    
-    public void blank_display() {
-        unscaled = null;
-        max_dim = Dimensions();
-        scaled = null;
-        pixmap = null;
-        
-        // this has to have happened
-        canvas.realize();
-        
-        // force a redraw
-        invalidate_all();
-    }
-    
-    public Cairo.Surface? get_surface() {
-        return pixmap;
-    }
-    
-    public Dimensions get_surface_dim() {
-        return pixmap_dim;
-    }
-    
-    public Cairo.Context get_cairo_context() {
-        return pixmap_ctx;
-    }
-    
-    public void paint_text(Pango.Layout pango_layout, int x, int y) {
-        text_ctx.move_to(x, y);
-        Pango.cairo_show_layout(text_ctx, pango_layout);
-    }
-    
-    public Scaling get_canvas_scaling() {
-        return (get_container() is FullscreenWindow) ? Scaling.for_screen(AppWindow.get_instance(), 
scale_up_to_viewport)
-            : Scaling.for_widget(viewport, scale_up_to_viewport);
-    }
-
-    public Gdk.Pixbuf? get_unscaled_pixbuf() {
-        return unscaled;
-    }
-    
-    public Gdk.Pixbuf? get_scaled_pixbuf() {
-        return scaled;
-    }
-    
-    // Returns a rectangle describing the pixbuf in relation to the canvas
-    public Gdk.Rectangle get_scaled_pixbuf_position() {
-        return scaled_pos;
-    }
-    
-    public bool is_inside_pixbuf(int x, int y) {
-        return coord_in_rectangle(x, y, scaled_pos);
-    }
-    
-    public void invalidate(Gdk.Rectangle rect) {
-        if (canvas.get_window() != null)
-            canvas.get_window().invalidate_rect(rect, false);
-    }
-    
-    public void invalidate_all() {
-        if (canvas.get_window() != null)
-            canvas.get_window().invalidate_rect(null, false);
-    }
-    
-    private void on_viewport_resize() {
-        // do fast repaints while resizing
-        internal_repaint(true, null);
-    }
-    
-    protected override void on_resize_finished(Gdk.Rectangle rect) {
-        base.on_resize_finished(rect);
-       
-        // when the resize is completed, do a high-quality repaint
-        repaint();
-    }
-
-    private bool on_canvas_exposed(Cairo.Context exposed_ctx) {
-        // draw pixmap onto canvas unless it's not been instantiated, in which case draw black
-        // (so either old image or contents of another page is not left on screen)
-        if (pixmap != null)
-            exposed_ctx.set_source_surface(pixmap, 0, 0);
-        else
-            set_source_color_from_string(exposed_ctx, "#000");
-        
-        exposed_ctx.rectangle(0, 0, get_allocated_width(), get_allocated_height());
-        exposed_ctx.paint();
-        
-        return true;
-    }
-    
-    protected virtual void new_surface(Cairo.Context ctx, Dimensions ctx_dim) {
-    }
-    
-    protected virtual void updated_pixbuf(Gdk.Pixbuf pixbuf, UpdateReason reason, Dimensions old_dim) {
-    }
-
-    protected virtual void paint(Cairo.Context ctx, Dimensions ctx_dim) {
-        if (is_zoom_supported() && (!static_zoom_state.is_default())) {
-            set_source_color_from_string(ctx, "#000");
-            ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height);
-            ctx.fill();
-            
-            render_zoomed_to_pixmap(static_zoom_state);
-        } else if (!transition_clock.paint(ctx, ctx_dim.width, ctx_dim.height)) {
-            // transition is not running, so paint the full image on a black background
-            set_source_color_from_string(ctx, "#000");
-
-            ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height);
-            ctx.fill();
-
-            paint_pixmap_with_background(ctx, scaled, scaled_pos.x, scaled_pos.y);
-        }
-    }
-    
-    private void repaint_pixmap() {
-        if (pixmap_ctx == null)
-            return;
-        
-        paint(pixmap_ctx, pixmap_dim);
-        invalidate_all();
-    }
-    
-    public void repaint(Direction? direction = null) {
-        internal_repaint(false, direction);
-    }
-    
-    private void internal_repaint(bool fast, Direction? direction) {
-        // if not in view, assume a full repaint needed in future but do nothing more
-        if (!is_in_view()) {
-            pixmap = null;
-            scaled = null;
-            
-            return;
-        }
-        
-        // no image or window, no painting
-        if (unscaled == null || canvas.get_window() == null)
-            return;
-        
-        Gtk.Allocation allocation;
-        viewport.get_allocation(out allocation);
-        
-        int width = allocation.width;
-        int height = allocation.height;
-        
-        if (width <= 0 || height <= 0)
-            return;
-            
-        bool new_pixbuf = (scaled == null);
-        
-        // save if reporting an image being rescaled
-        Dimensions old_scaled_dim = Dimensions.for_rectangle(scaled_pos);
-        Gdk.Rectangle old_scaled_pos = scaled_pos;
-
-        // attempt to reuse pixmap
-        if (pixmap_dim.width != width || pixmap_dim.height != height)
-            pixmap = null;
-        
-        // if necessary, create a pixmap as large as the entire viewport
-        bool new_pixmap = false;
-        if (pixmap == null) {
-            init_pixmap(width, height);
-            new_pixmap = true;
-        }
-        
-        if (new_pixbuf || new_pixmap) {
-            Dimensions unscaled_dim = Dimensions.for_pixbuf(unscaled);
-            
-            // determine scaled size of pixbuf ... if a max dimensions is set and not scaling up,
-            // respect it
-            Dimensions scaled_dim = Dimensions();
-            if (!scale_up_to_viewport && max_dim.has_area() && max_dim.width < width && max_dim.height < 
height)
-                scaled_dim = max_dim;
-            else
-                scaled_dim = unscaled_dim.get_scaled_proportional(pixmap_dim);
-            
-            assert(width >= scaled_dim.width);
-            assert(height >= scaled_dim.height);
-
-            // center pixbuf on the canvas
-            scaled_pos.x = (width - scaled_dim.width) / 2;
-            scaled_pos.y = (height - scaled_dim.height) / 2;
-            scaled_pos.width = scaled_dim.width;
-            scaled_pos.height = scaled_dim.height;
-        }
-        
-        Gdk.InterpType interp = (fast) ? FAST_INTERP : QUALITY_INTERP;
-        
-        // rescale if canvas rescaled or better quality is requested
-        if (scaled == null) {
-            scaled = resize_pixbuf(unscaled, Dimensions.for_rectangle(scaled_pos), interp);
-            
-            UpdateReason reason = UpdateReason.RESIZED_CANVAS;
-            if (new_pixbuf)
-                reason = UpdateReason.NEW_PIXBUF;
-            else if (!new_pixmap && interp == QUALITY_INTERP)
-                reason = UpdateReason.QUALITY_IMPROVEMENT;
-
-            static_zoom_state = ZoomState(max_dim, pixmap_dim,
-                static_zoom_state.get_interpolation_factor(),
-                static_zoom_state.get_viewport_center());
-
-            updated_pixbuf(scaled, reason, old_scaled_dim);
-        }
-
-        zoom_high_quality = !fast;
-        
-        if (direction != null && !transition_clock.is_in_progress()) {
-            Spit.Transitions.Visuals visuals = new Spit.Transitions.Visuals(old_scaled,
-                old_scaled_pos, scaled, scaled_pos, parse_color("#000"));
-            
-            transition_clock.start(visuals, direction.to_transition_direction(), transition_duration_msec,
-                repaint_pixmap);
-        }
-        
-        if (!transition_clock.is_in_progress())
-            repaint_pixmap();
-    }
-    
-    private void init_pixmap(int width, int height) {
-        assert(unscaled != null);
-        assert(canvas.get_window() != null);
-        
-        // Cairo backing surface (manual double-buffering)
-        pixmap = new Cairo.ImageSurface(Cairo.Format.ARGB32, width, height);
-        pixmap_dim = Dimensions(width, height);
-        
-        // Cairo context for drawing on the pixmap
-        pixmap_ctx = new Cairo.Context(pixmap);
-        
-        // need a new pixbuf to fit this scale
-        scaled = null;
-        
-        // Cairo context for drawing text on the pixmap
-        text_ctx = new Cairo.Context(pixmap);
-        set_source_color_from_string(text_ctx, "#fff");
-        
-        
-        // no need to resize canvas, viewport does that automatically
-        
-        new_surface(pixmap_ctx, pixmap_dim);
-    }
-
-    protected override bool on_context_keypress() {
-        return popup_context_menu(get_page_context_menu());
-    }
-    
-    protected virtual void on_previous_photo() {
-    }
-    
-    protected virtual void on_next_photo() {
-    }
-    
-    public override bool key_press_event(Gdk.EventKey event) {
-        // if the user holds the arrow keys down, we will receive a steady stream of key press
-        // events for an operation that isn't designed for a rapid succession of output ... 
-        // we staunch the supply of new photos to under a quarter second (#533)
-        bool nav_ok = (event.time - last_nav_key) > KEY_REPEAT_INTERVAL_MSEC;
-        
-        bool handled = true;
-        switch (Gdk.keyval_name(event.keyval)) {
-            case "Left":
-            case "KP_Left":
-            case "BackSpace":
-                if (nav_ok) {
-                    on_previous_photo();
-                    last_nav_key = event.time;
-                }
-            break;
-            
-            case "Right":
-            case "KP_Right":
-            case "space":
-                if (nav_ok) {
-                    on_next_photo();
-                    last_nav_key = event.time;
-                }
-            break;
-            
-            default:
-                handled = false;
-            break;
-        }
-        
-        if (handled)
-            return true;
-        
-        return (base.key_press_event != null) ? base.key_press_event(event) : true;
-    }
-
-    private void on_colors_changed() {
-        invalidate_transparent_background();
-        repaint();
-    }
-}
-
-//
-// DragAndDropHandler attaches signals to a Page to properly handle drag-and-drop requests for the
-// Page as a DnD Source.  (DnD Destination handling is handled by the appropriate AppWindow, i.e.
-// LibraryWindow and DirectWindow). Assumes the Page's ViewCollection holds MediaSources.
-//
-public class DragAndDropHandler {
-    private enum TargetType {
-        XDS,
-        MEDIA_LIST
-    }
-    
-    private const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = {
-        { "XdndDirectSave0", Gtk.TargetFlags.OTHER_APP, TargetType.XDS },
-        { "shotwell/media-id-atom", Gtk.TargetFlags.SAME_APP, TargetType.MEDIA_LIST }
-    };
-    
-    private static Gdk.Atom? XDS_ATOM = null;
-    private static Gdk.Atom? TEXT_ATOM = null;
-    private static uint8[]? XDS_FAKE_TARGET = null;
-    
-    private weak Page page;
-    private Gtk.Widget event_source;
-    private File? drag_destination = null;
-    private ExporterUI exporter = null;
-    
-    public DragAndDropHandler(Page page) {
-        this.page = page;
-        this.event_source = page.get_event_source();
-        assert(event_source != null);
-        assert(event_source.get_has_window());
-        
-        // Need to do this because static member variables are not properly handled
-        if (XDS_ATOM == null)
-            XDS_ATOM = Gdk.Atom.intern_static_string("XdndDirectSave0");
-        
-        if (TEXT_ATOM == null)
-            TEXT_ATOM = Gdk.Atom.intern_static_string("text/plain");
-        
-        if (XDS_FAKE_TARGET == null)
-            XDS_FAKE_TARGET = string_to_uchar_array("shotwell.txt");
-        
-        // register what's available on this DnD Source
-        Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, SOURCE_TARGET_ENTRIES,
-            Gdk.DragAction.COPY);
-        
-        // attach to the event source's DnD signals, not the Page's, which is a NO_WINDOW widget
-        // and does not emit them
-        event_source.drag_begin.connect(on_drag_begin);
-        event_source.drag_data_get.connect(on_drag_data_get);
-        event_source.drag_end.connect(on_drag_end);
-        event_source.drag_failed.connect(on_drag_failed);
-    }
-    
-    ~DragAndDropHandler() {
-        if (event_source != null) {
-            event_source.drag_begin.disconnect(on_drag_begin);
-            event_source.drag_data_get.disconnect(on_drag_data_get);
-            event_source.drag_end.disconnect(on_drag_end);
-            event_source.drag_failed.disconnect(on_drag_failed);
-        }
-        
-        page = null;
-        event_source = null;
-    }
-    
-    private void on_drag_begin(Gdk.DragContext context) {
-        debug("on_drag_begin (%s)", page.get_page_name());
-        
-        if (page == null || page.get_view().get_selected_count() == 0 || exporter != null)
-            return;
-        
-        drag_destination = null;
-        
-        // use the first media item as the icon
-        ThumbnailSource thumb = (ThumbnailSource) page.get_view().get_selected_at(0).get_source();
-        
-        try {
-            Gdk.Pixbuf icon = thumb.get_thumbnail(AppWindow.DND_ICON_SCALE);
-            Gtk.drag_source_set_icon_pixbuf(event_source, icon);
-        } catch (Error err) {
-            warning("Unable to fetch icon for drag-and-drop from %s: %s", thumb.to_string(),
-                err.message);
-        }
-        
-        // set the XDS property to indicate an XDS save is available
-#if VALA_0_20
-        Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE,
-            XDS_FAKE_TARGET, 1);
-#else
-        Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE,
-            XDS_FAKE_TARGET);
-#endif
-    }
-    
-    private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data,
-        uint target_type, uint time) {
-        debug("on_drag_data_get (%s)", page.get_page_name());
-        
-        if (page == null || page.get_view().get_selected_count() == 0)
-            return;
-        
-        switch (target_type) {
-            case TargetType.XDS:
-                // Fetch the XDS property that has been set with the destination path
-                uchar[] data = new uchar[4096];
-                Gdk.Atom actual_type;
-                int actual_format = 0;
-                bool fetched = Gdk.property_get(context.get_source_window(), XDS_ATOM, TEXT_ATOM,
-                    0, data.length, 0, out actual_type, out actual_format, out data);
-                
-                // the destination path is actually for our XDS_FAKE_TARGET, use its parent
-                // to determine where the file(s) should go
-                if (fetched && data != null && data.length > 0)
-                    drag_destination = File.new_for_uri(uchar_array_to_string(data)).get_parent();
-                
-                debug("on_drag_data_get (%s): %s", page.get_page_name(),
-                    (drag_destination != null) ? drag_destination.get_path() : "(no path)");
-                
-                // Set the property to "S" for Success or "E" for Error
-                selection_data.set(XDS_ATOM, 8,
-                    string_to_uchar_array((drag_destination != null) ? "S" : "E"));
-            break;
-            
-            case TargetType.MEDIA_LIST:
-                Gee.Collection<MediaSource> sources =
-                    (Gee.Collection<MediaSource>) page.get_view().get_selected_sources();
-                
-                // convert the selected media sources to Gdk.Atom-encoded sourceID strings for
-                // internal drag-and-drop
-                selection_data.set(Gdk.Atom.intern_static_string("SourceIDAtom"), (int) sizeof(Gdk.Atom),
-                    serialize_media_sources(sources));
-            break;
-            
-            default:
-                warning("on_drag_data_get (%s): unknown target type %u", page.get_page_name(),
-                    target_type);
-            break;
-        }
-    }
-    
-    private void on_drag_end() {
-        debug("on_drag_end (%s)", page.get_page_name());
-
-        if (page == null || page.get_view().get_selected_count() == 0 || drag_destination == null
-            || exporter != null) {
-            return;
-        }
-
-        debug("Exporting to %s", drag_destination.get_path());
-        
-        // drag-and-drop export doesn't pop up an export dialog, so use what are likely the
-        // most common export settings (the current -- or "working" -- file format, with
-        // all transformations applied, at the image's original size).
-        if (drag_destination.get_path() != null) {
-            exporter = new ExporterUI(new Exporter(
-                (Gee.Collection<Photo>) page.get_view().get_selected_sources(),
-                drag_destination, Scaling.for_original(), ExportFormatParameters.current()));
-            exporter.export(on_export_completed);
-        } else {
-            AppWindow.error_message(_("Photos cannot be exported to this directory."));
-        }
-        
-        drag_destination = null;
-    }
-    
-    private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
-        debug("on_drag_failed (%s): %d", page.get_page_name(), (int) drag_result);
-        
-        if (page == null)
-            return false;
-        
-        drag_destination = null;
-        
-        return false;
-    }
-    
-    private void on_export_completed() {
-        exporter = null;
-    }
-
-}
diff --git a/src/PageMessagePane.vala b/src/PageMessagePane.vala
new file mode 100644
index 00000000..a5c5eee6
--- /dev/null
+++ b/src/PageMessagePane.vala
@@ -0,0 +1,19 @@
+/* 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.
+ */
+
+[GtkTemplate (ui = "/org/gnome/Shotwell/ui/message_pane.ui")]
+private class PageMessagePane : Gtk.Box {
+    [GtkChild]
+    public Gtk.Label label;
+
+    [GtkChild]
+    public Gtk.Image icon_image;
+
+    public PageMessagePane() {
+        Object();
+    }
+}
+
diff --git a/src/SinglePhotoPage.vala b/src/SinglePhotoPage.vala
new file mode 100644
index 00000000..0985eb1c
--- /dev/null
+++ b/src/SinglePhotoPage.vala
@@ -0,0 +1,529 @@
+/* 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 SinglePhotoPage : Page {
+    public const Gdk.InterpType FAST_INTERP = Gdk.InterpType.NEAREST;
+    public const Gdk.InterpType QUALITY_INTERP = Gdk.InterpType.BILINEAR;
+    public const int KEY_REPEAT_INTERVAL_MSEC = 200;
+
+    public enum UpdateReason {
+        NEW_PIXBUF,
+        QUALITY_IMPROVEMENT,
+        RESIZED_CANVAS
+    }
+
+    protected Gtk.DrawingArea canvas = new Gtk.DrawingArea();
+    protected Gtk.Viewport viewport = new Gtk.Viewport(null, null);
+
+    private bool scale_up_to_viewport;
+    private TransitionClock transition_clock;
+    private int transition_duration_msec = 0;
+    private Cairo.Surface pixmap = null;
+    private Cairo.Context pixmap_ctx = null;
+    private Cairo.Context text_ctx = null;
+    private Dimensions pixmap_dim = Dimensions();
+    private Gdk.Pixbuf unscaled = null;
+    private Dimensions max_dim = Dimensions();
+    private Gdk.Pixbuf scaled = null;
+    private Gdk.Pixbuf old_scaled = null; // previous scaled image
+    private Gdk.Rectangle scaled_pos = Gdk.Rectangle();
+    private ZoomState static_zoom_state;
+    private bool zoom_high_quality = true;
+    private ZoomState saved_zoom_state;
+    private bool has_saved_zoom_state = false;
+    private uint32 last_nav_key = 0;
+
+    public SinglePhotoPage(string page_name, bool scale_up_to_viewport) {
+        base(page_name);
+
+        this.scale_up_to_viewport = scale_up_to_viewport;
+
+        transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock();
+
+        // With the current code automatically resizing the image to the viewport, scrollbars
+        // should never be shown, but this may change if/when zooming is supported
+        set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
+
+        set_border_width(0);
+        set_shadow_type(Gtk.ShadowType.NONE);
+
+        viewport.set_shadow_type(Gtk.ShadowType.NONE);
+        viewport.set_border_width(0);
+        viewport.add(canvas);
+
+        add(viewport);
+
+        canvas.add_events(Gdk.EventMask.EXPOSURE_MASK | Gdk.EventMask.STRUCTURE_MASK 
+            | Gdk.EventMask.SUBSTRUCTURE_MASK);
+
+        viewport.size_allocate.connect(on_viewport_resize);
+        canvas.draw.connect(on_canvas_exposed);
+
+        set_event_source(canvas);
+        Config.Facade.get_instance().colors_changed.connect(on_colors_changed);
+    }
+
+    ~SinglePhotoPage() {
+        Config.Facade.get_instance().colors_changed.disconnect(on_colors_changed);
+    }
+
+    public bool is_transition_in_progress() {
+        return transition_clock.is_in_progress();
+    }
+
+    public void cancel_transition() {
+        if (transition_clock.is_in_progress())
+            transition_clock.cancel();
+    }
+
+    public void set_transition(string effect_id, int duration_msec) {
+        cancel_transition();
+
+        transition_clock = TransitionEffectsManager.get_instance().create_transition_clock(effect_id);
+        if (transition_clock == null)
+            transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock();
+
+        transition_duration_msec = duration_msec;
+    }
+
+    // This method includes a call to pixmap_ctx.paint().
+    private void render_zoomed_to_pixmap(ZoomState zoom_state) {
+        assert(is_zoom_supported());
+
+        Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content();
+
+        Gdk.Pixbuf zoomed;
+        if (get_zoom_buffer() != null) {
+            zoomed = (zoom_high_quality) ? get_zoom_buffer().get_zoomed_image(zoom_state) :
+                get_zoom_buffer().get_zoom_preview_image(zoom_state);
+        } else {
+            Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(unscaled);
+
+            Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(unscaled, view_rect_proj.x,
+                view_rect_proj.y, view_rect_proj.width, view_rect_proj.height);
+
+            zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height,
+                Gdk.InterpType.BILINEAR);
+        }
+
+        if (zoomed == null) {
+            return;
+        }
+
+        int draw_x = (pixmap_dim.width - view_rect.width) / 2;
+        draw_x = draw_x.clamp(0, int.MAX);
+
+        int draw_y = (pixmap_dim.height - view_rect.height) / 2;
+        draw_y = draw_y.clamp(0, int.MAX);
+        paint_pixmap_with_background(pixmap_ctx, zoomed, draw_x, draw_y);
+    }
+
+    protected void on_interactive_zoom(ZoomState interactive_zoom_state) {
+        assert(is_zoom_supported());
+
+        set_source_color_from_string(pixmap_ctx, "#000");
+        pixmap_ctx.paint();
+
+        bool old_quality_setting = zoom_high_quality;
+        zoom_high_quality = false;
+        render_zoomed_to_pixmap(interactive_zoom_state);
+        zoom_high_quality = old_quality_setting;
+
+        canvas.queue_draw();
+    }
+
+    protected void on_interactive_pan(ZoomState interactive_zoom_state) {
+        assert(is_zoom_supported());
+
+        set_source_color_from_string(pixmap_ctx, "#000");
+        pixmap_ctx.paint();
+
+        bool old_quality_setting = zoom_high_quality;
+        zoom_high_quality = true;
+        render_zoomed_to_pixmap(interactive_zoom_state);
+        zoom_high_quality = old_quality_setting;
+
+        canvas.queue_draw();
+    }
+
+    protected virtual bool is_zoom_supported() {
+        return false;
+    }
+
+    protected virtual void cancel_zoom() {
+        if (pixmap != null) {
+            set_source_color_from_string(pixmap_ctx, "#000");
+            pixmap_ctx.paint();
+        }
+    }
+
+    protected virtual void save_zoom_state() {
+        saved_zoom_state = static_zoom_state;
+        has_saved_zoom_state = true;
+    }
+
+    protected virtual void restore_zoom_state() {
+        if (!has_saved_zoom_state)
+            return;
+
+        static_zoom_state = saved_zoom_state;
+        repaint();
+        has_saved_zoom_state = false;
+    }
+
+    protected virtual ZoomBuffer? get_zoom_buffer() {
+        return null;
+    }
+
+    protected ZoomState get_saved_zoom_state() {
+        return saved_zoom_state;
+    }
+
+    protected void set_zoom_state(ZoomState zoom_state) {
+        assert(is_zoom_supported());
+
+        static_zoom_state = zoom_state;
+    }
+
+    protected ZoomState get_zoom_state() {
+        assert(is_zoom_supported());
+
+        return static_zoom_state;
+    }
+
+    public override void switched_to() {
+        base.switched_to();
+
+        if (unscaled != null)
+            repaint();
+    }
+
+    public override void set_container(Gtk.Window container) {
+        base.set_container(container);
+
+        // scrollbar policy in fullscreen mode needs to be auto/auto, else the pixbuf will shift
+        // off the screen
+        if (container is FullscreenWindow)
+            set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
+    }
+
+    // max_dim represents the maximum size of the original pixbuf (i.e. pixbuf may be scaled and
+    // the caller capable of producing larger ones depending on the viewport size).  max_dim
+    // is used when scale_up_to_viewport is set to true.  Pass a Dimensions with no area if
+    // max_dim should be ignored (i.e. scale_up_to_viewport is false).
+    public void set_pixbuf(Gdk.Pixbuf unscaled, Dimensions max_dim, Direction? direction = null) {
+        static_zoom_state = ZoomState(max_dim, pixmap_dim,
+            static_zoom_state.get_interpolation_factor(),
+            static_zoom_state.get_viewport_center());
+
+        cancel_transition();
+
+        this.unscaled = unscaled;
+        this.max_dim = max_dim;
+        this.old_scaled = scaled;
+        scaled = null;
+
+        // need to make sure this has happened
+        canvas.realize();
+
+        repaint(direction);
+    }
+
+    public void blank_display() {
+        unscaled = null;
+        max_dim = Dimensions();
+        scaled = null;
+        pixmap = null;
+
+        // this has to have happened
+        canvas.realize();
+
+        // force a redraw
+        invalidate_all();
+    }
+
+    public Cairo.Surface? get_surface() {
+        return pixmap;
+    }
+
+    public Dimensions get_surface_dim() {
+        return pixmap_dim;
+    }
+
+    public Cairo.Context get_cairo_context() {
+        return pixmap_ctx;
+    }
+
+    public void paint_text(Pango.Layout pango_layout, int x, int y) {
+        text_ctx.move_to(x, y);
+        Pango.cairo_show_layout(text_ctx, pango_layout);
+    }
+
+    public Scaling get_canvas_scaling() {
+        return (get_container() is FullscreenWindow) ? Scaling.for_screen(AppWindow.get_instance(), 
scale_up_to_viewport)
+            : Scaling.for_widget(viewport, scale_up_to_viewport);
+    }
+
+    public Gdk.Pixbuf? get_unscaled_pixbuf() {
+        return unscaled;
+    }
+
+    public Gdk.Pixbuf? get_scaled_pixbuf() {
+        return scaled;
+    }
+
+    // Returns a rectangle describing the pixbuf in relation to the canvas
+    public Gdk.Rectangle get_scaled_pixbuf_position() {
+        return scaled_pos;
+    }
+
+    public bool is_inside_pixbuf(int x, int y) {
+        return coord_in_rectangle(x, y, scaled_pos);
+    }
+
+    public void invalidate(Gdk.Rectangle rect) {
+        if (canvas.get_window() != null)
+            canvas.get_window().invalidate_rect(rect, false);
+    }
+
+    public void invalidate_all() {
+        if (canvas.get_window() != null)
+            canvas.get_window().invalidate_rect(null, false);
+    }
+
+    private void on_viewport_resize() {
+        // do fast repaints while resizing
+        internal_repaint(true, null);
+    }
+
+    protected override void on_resize_finished(Gdk.Rectangle rect) {
+        base.on_resize_finished(rect);
+
+        // when the resize is completed, do a high-quality repaint
+        repaint();
+    }
+
+    private bool on_canvas_exposed(Cairo.Context exposed_ctx) {
+        // draw pixmap onto canvas unless it's not been instantiated, in which case draw black
+        // (so either old image or contents of another page is not left on screen)
+        if (pixmap != null)
+            exposed_ctx.set_source_surface(pixmap, 0, 0);
+        else
+            set_source_color_from_string(exposed_ctx, "#000");
+
+        exposed_ctx.rectangle(0, 0, get_allocated_width(), get_allocated_height());
+        exposed_ctx.paint();
+
+        return true;
+    }
+
+    protected virtual void new_surface(Cairo.Context ctx, Dimensions ctx_dim) {
+    }
+
+    protected virtual void updated_pixbuf(Gdk.Pixbuf pixbuf, UpdateReason reason, Dimensions old_dim) {
+    }
+
+    protected virtual void paint(Cairo.Context ctx, Dimensions ctx_dim) {
+        if (is_zoom_supported() && (!static_zoom_state.is_default())) {
+            set_source_color_from_string(ctx, "#000");
+            ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height);
+            ctx.fill();
+
+            render_zoomed_to_pixmap(static_zoom_state);
+        } else if (!transition_clock.paint(ctx, ctx_dim.width, ctx_dim.height)) {
+            // transition is not running, so paint the full image on a black background
+            set_source_color_from_string(ctx, "#000");
+
+            ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height);
+            ctx.fill();
+
+            paint_pixmap_with_background(ctx, scaled, scaled_pos.x, scaled_pos.y);
+        }
+    }
+
+    private void repaint_pixmap() {
+        if (pixmap_ctx == null)
+            return;
+
+        paint(pixmap_ctx, pixmap_dim);
+        invalidate_all();
+    }
+
+    public void repaint(Direction? direction = null) {
+        internal_repaint(false, direction);
+    }
+
+    private void internal_repaint(bool fast, Direction? direction) {
+        // if not in view, assume a full repaint needed in future but do nothing more
+        if (!is_in_view()) {
+            pixmap = null;
+            scaled = null;
+
+            return;
+        }
+
+        // no image or window, no painting
+        if (unscaled == null || canvas.get_window() == null)
+            return;
+
+        Gtk.Allocation allocation;
+        viewport.get_allocation(out allocation);
+
+        int width = allocation.width;
+        int height = allocation.height;
+
+        if (width <= 0 || height <= 0)
+            return;
+
+        bool new_pixbuf = (scaled == null);
+
+        // save if reporting an image being rescaled
+        Dimensions old_scaled_dim = Dimensions.for_rectangle(scaled_pos);
+        Gdk.Rectangle old_scaled_pos = scaled_pos;
+
+        // attempt to reuse pixmap
+        if (pixmap_dim.width != width || pixmap_dim.height != height)
+            pixmap = null;
+
+        // if necessary, create a pixmap as large as the entire viewport
+        bool new_pixmap = false;
+        if (pixmap == null) {
+            init_pixmap(width, height);
+            new_pixmap = true;
+        }
+
+        if (new_pixbuf || new_pixmap) {
+            Dimensions unscaled_dim = Dimensions.for_pixbuf(unscaled);
+
+            // determine scaled size of pixbuf ... if a max dimensions is set and not scaling up,
+            // respect it
+            Dimensions scaled_dim = Dimensions();
+            if (!scale_up_to_viewport && max_dim.has_area() && max_dim.width < width && max_dim.height < 
height)
+                scaled_dim = max_dim;
+            else
+                scaled_dim = unscaled_dim.get_scaled_proportional(pixmap_dim);
+
+            assert(width >= scaled_dim.width);
+            assert(height >= scaled_dim.height);
+
+            // center pixbuf on the canvas
+            scaled_pos.x = (width - scaled_dim.width) / 2;
+            scaled_pos.y = (height - scaled_dim.height) / 2;
+            scaled_pos.width = scaled_dim.width;
+            scaled_pos.height = scaled_dim.height;
+        }
+
+        Gdk.InterpType interp = (fast) ? FAST_INTERP : QUALITY_INTERP;
+
+        // rescale if canvas rescaled or better quality is requested
+        if (scaled == null) {
+            scaled = resize_pixbuf(unscaled, Dimensions.for_rectangle(scaled_pos), interp);
+
+            UpdateReason reason = UpdateReason.RESIZED_CANVAS;
+            if (new_pixbuf)
+                reason = UpdateReason.NEW_PIXBUF;
+            else if (!new_pixmap && interp == QUALITY_INTERP)
+                reason = UpdateReason.QUALITY_IMPROVEMENT;
+
+            static_zoom_state = ZoomState(max_dim, pixmap_dim,
+                static_zoom_state.get_interpolation_factor(),
+                static_zoom_state.get_viewport_center());
+
+            updated_pixbuf(scaled, reason, old_scaled_dim);
+        }
+
+        zoom_high_quality = !fast;
+
+        if (direction != null && !transition_clock.is_in_progress()) {
+            Spit.Transitions.Visuals visuals = new Spit.Transitions.Visuals(old_scaled,
+                old_scaled_pos, scaled, scaled_pos, parse_color("#000"));
+
+            transition_clock.start(visuals, direction.to_transition_direction(), transition_duration_msec,
+                repaint_pixmap);
+        }
+
+        if (!transition_clock.is_in_progress())
+            repaint_pixmap();
+    }
+
+    private void init_pixmap(int width, int height) {
+        assert(unscaled != null);
+        assert(canvas.get_window() != null);
+
+        // Cairo backing surface (manual double-buffering)
+        pixmap = new Cairo.ImageSurface(Cairo.Format.ARGB32, width, height);
+        pixmap_dim = Dimensions(width, height);
+
+        // Cairo context for drawing on the pixmap
+        pixmap_ctx = new Cairo.Context(pixmap);
+
+        // need a new pixbuf to fit this scale
+        scaled = null;
+
+        // Cairo context for drawing text on the pixmap
+        text_ctx = new Cairo.Context(pixmap);
+        set_source_color_from_string(text_ctx, "#fff");
+
+
+        // no need to resize canvas, viewport does that automatically
+
+        new_surface(pixmap_ctx, pixmap_dim);
+    }
+
+    protected override bool on_context_keypress() {
+        return popup_context_menu(get_page_context_menu());
+    }
+
+    protected virtual void on_previous_photo() {
+    }
+
+    protected virtual void on_next_photo() {
+    }
+
+    public override bool key_press_event(Gdk.EventKey event) {
+        // if the user holds the arrow keys down, we will receive a steady stream of key press
+        // events for an operation that isn't designed for a rapid succession of output ... 
+        // we staunch the supply of new photos to under a quarter second (#533)
+        bool nav_ok = (event.time - last_nav_key) > KEY_REPEAT_INTERVAL_MSEC;
+
+        bool handled = true;
+        switch (Gdk.keyval_name(event.keyval)) {
+            case "Left":
+            case "KP_Left":
+            case "BackSpace":
+                if (nav_ok) {
+                    on_previous_photo();
+                    last_nav_key = event.time;
+                }
+            break;
+
+            case "Right":
+            case "KP_Right":
+            case "space":
+                if (nav_ok) {
+                    on_next_photo();
+                    last_nav_key = event.time;
+                }
+            break;
+
+            default:
+                handled = false;
+            break;
+        }
+
+        if (handled)
+            return true;
+
+        return (base.key_press_event != null) ? base.key_press_event(event) : true;
+    }
+
+    private void on_colors_changed() {
+        invalidate_transparent_background();
+        repaint();
+    }
+}
+
+
diff --git a/src/meson.build b/src/meson.build
index 3844a5b3..d5bbeb04 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -182,6 +182,10 @@ executable('shotwell',
             'CheckerboardLayout.vala',
             'PhotoPage.vala',
             'Page.vala',
+            'SinglePhotoPage.vala',
+            'CheckerboardPage.vala',
+            'DragAndDropHandler.vala',
+            'PageMessagePane.vala',
             'SortedList.vala',
             'Dimensions.vala',
             'Box.vala',


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