PhotoPage.vala 118 KB
Newer Older
1
/* Copyright 2016 Software Freedom Conservancy Inc.
2 3
 *
 * This software is licensed under the GNU LGPL (version 2.1 or later).
4
 * See the COPYING file in this distribution.
5
 */
6

7 8 9 10 11 12 13 14 15
public class ZoomBuffer : Object {
    private enum ObjectState {
        SOURCE_NOT_LOADED,
        SOURCE_LOAD_IN_PROGRESS,
        SOURCE_NOT_TRANSFORMED,
        TRANSFORMED_READY
    }

    private class IsoSourceFetchJob : BackgroundJob {
Jim Nelson's avatar
Jim Nelson committed
16
        private Photo to_fetch;
17 18 19
        
        public Gdk.Pixbuf? fetched = null;

Jim Nelson's avatar
Jim Nelson committed
20
        public IsoSourceFetchJob(ZoomBuffer owner, Photo to_fetch,
21 22 23 24 25 26 27 28
            CompletionCallback completion_callback) {
            base(owner, completion_callback);
            
            this.to_fetch = to_fetch;
        }
        
        public override void execute() {
            try {
29
                fetched = to_fetch.get_pixbuf_with_options(Scaling.for_original(),
Jim Nelson's avatar
Jim Nelson committed
30
                    Photo.Exception.ADJUST);
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
            } catch (Error fetch_error) {
                critical("IsoSourceFetchJob: execute( ): can't get pixbuf from backing photo");
            }
        }
    }

    // it's worth noting that there are two different kinds of transformation jobs (though this
    // single class supports them both). There are "isomorphic" (or "iso") transformation jobs that
    // operate over full-size pixbufs and are relatively long-running and then there are
    // "demand" transformation jobs that occur over much smaller pixbufs as needed; these are
    // relatively quick to run.
    private class TransformationJob : BackgroundJob {
        private Gdk.Pixbuf to_transform;
        private PixelTransformer? transformer;
        private Cancellable cancellable;
        
        public Gdk.Pixbuf transformed = null;

        public TransformationJob(ZoomBuffer owner, Gdk.Pixbuf to_transform, PixelTransformer?
            transformer, CompletionCallback completion_callback, Cancellable cancellable) {
            base(owner, completion_callback, cancellable);

            this.cancellable = cancellable;
            this.to_transform = to_transform;
            this.transformer = transformer;
            this.transformed = to_transform.copy();
        }
        
        public override void execute() {
            if (transformer != null) {
                transformer.transform_to_other_pixbuf(to_transform, transformed, cancellable);
            }
        }
    }
    
    private const int MEGAPIXEL = 1048576;
    private const int USE_REDUCED_THRESHOLD = (int) 2.0 * MEGAPIXEL;

    private Gdk.Pixbuf iso_source_image = null;
    private Gdk.Pixbuf? reduced_source_image = null;
    private Gdk.Pixbuf iso_transformed_image = null;
    private Gdk.Pixbuf? reduced_transformed_image = null;
    private Gdk.Pixbuf preview_image = null;
Jim Nelson's avatar
Jim Nelson committed
74
    private Photo backing_photo = null;
75 76 77 78 79 80 81 82 83
    private ObjectState object_state = ObjectState.SOURCE_NOT_LOADED;
    private Gdk.Pixbuf? demand_transform_cached_pixbuf = null;
    private ZoomState demand_transform_zoom_state;
    private TransformationJob? demand_transform_job = null; // only 1 demand transform job can be
                                                            // active at a time
    private Workers workers = null;
    private SinglePhotoPage parent_page;
    private bool is_interactive_redraw_in_progress = false;

Jim Nelson's avatar
Jim Nelson committed
84
    public ZoomBuffer(SinglePhotoPage parent_page, Photo backing_photo,
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128
        Gdk.Pixbuf preview_image) {
        this.parent_page = parent_page;
        this.preview_image = preview_image;
        this.backing_photo = backing_photo;
        this.workers = new Workers(2, false);
    }

    private void on_iso_source_fetch_complete(BackgroundJob job) {
        IsoSourceFetchJob fetch_job = (IsoSourceFetchJob) job;
        if (fetch_job.fetched == null) {
            critical("ZoomBuffer: iso_source_fetch_complete( ): fetch job has null image member");
            return;
        }

        iso_source_image = fetch_job.fetched;
        if ((iso_source_image.width * iso_source_image.height) > USE_REDUCED_THRESHOLD) {
            reduced_source_image = iso_source_image.scale_simple(iso_source_image.width / 2,
                iso_source_image.height / 2, Gdk.InterpType.BILINEAR);
        }
        object_state = ObjectState.SOURCE_NOT_TRANSFORMED;

        if (!is_interactive_redraw_in_progress)
            parent_page.repaint();

        BackgroundJob transformation_job = new TransformationJob(this, iso_source_image,
            backing_photo.get_pixel_transformer(), on_iso_transformation_complete,
            new Cancellable());
        workers.enqueue(transformation_job);
    }
    
    private void on_iso_transformation_complete(BackgroundJob job) {
        TransformationJob transform_job = (TransformationJob) job;
        if (transform_job.transformed == null) {
            critical("ZoomBuffer: on_iso_transformation_complete( ): completed job has null " +
                "image");
            return;
        }

        iso_transformed_image = transform_job.transformed;
        if ((iso_transformed_image.width * iso_transformed_image.height) > USE_REDUCED_THRESHOLD) {
            reduced_transformed_image = iso_transformed_image.scale_simple(
                iso_transformed_image.width / 2, iso_transformed_image.height / 2,
                Gdk.InterpType.BILINEAR);
        }
Jim Nelson's avatar
Jim Nelson committed
129
        object_state = ObjectState.TRANSFORMED_READY;
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150
    }
    
    private void on_demand_transform_complete(BackgroundJob job) {
        TransformationJob transform_job = (TransformationJob) job;
        if (transform_job.transformed == null) {
            critical("ZoomBuffer: on_demand_transform_complete( ): completed job has null " +
                "image");
            return;
        }

        demand_transform_cached_pixbuf = transform_job.transformed;
        demand_transform_job = null;

        parent_page.repaint();
    }

    // passing a 'reduced_pixbuf' that has one-quarter the number of pixels as the 'iso_pixbuf' is
    // optional, but including one can dramatically increase performance obtaining projection
    // pixbufs at for ZoomStates with zoom factors less than 0.5
    private Gdk.Pixbuf get_view_projection_pixbuf(ZoomState zoom_state, Gdk.Pixbuf iso_pixbuf,
        Gdk.Pixbuf? reduced_pixbuf = null) {
151
        Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content();
152 153 154 155 156 157 158 159 160 161 162 163
        Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(
            iso_pixbuf);
        Gdk.Pixbuf sample_source_pixbuf = iso_pixbuf;

        if ((reduced_pixbuf != null) && (zoom_state.get_zoom_factor() < 0.5)) {
            sample_source_pixbuf = reduced_pixbuf;
            view_rect_proj.x /= 2;
            view_rect_proj.y /= 2;
            view_rect_proj.width /= 2;
            view_rect_proj.height /= 2;
        }

164 165 166 167 168 169 170 171 172
        // On very small images, it's possible for these to
        // be 0, and GTK doesn't like sampling a region 0 px
        // across.
        view_rect_proj.width = view_rect_proj.width.clamp(1, int.MAX);
        view_rect_proj.height = view_rect_proj.height.clamp(1, int.MAX);

        view_rect.width = view_rect.width.clamp(1, int.MAX);
        view_rect.height = view_rect.height.clamp(1, int.MAX);

173 174 175 176 177 178
        Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(sample_source_pixbuf, view_rect_proj.x,
            view_rect_proj.y, view_rect_proj.width, view_rect_proj.height);

        Gdk.Pixbuf zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height,
            Gdk.InterpType.BILINEAR);

179 180
        assert(zoomed != null);

181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201
        return zoomed;
    }
    
    private Gdk.Pixbuf get_zoomed_image_source_not_transformed(ZoomState zoom_state) {
        if (demand_transform_cached_pixbuf != null) {
            if (zoom_state.equals(demand_transform_zoom_state)) {
                // if a cached pixbuf from a previous on-demand transform operation exists and
                // its zoom state is the same as the currently requested zoom state, then we
                // don't need to do any work -- just return the cached copy
                return demand_transform_cached_pixbuf;
            } else if (zoom_state.get_zoom_factor() ==
                       demand_transform_zoom_state.get_zoom_factor()) {
                // if a cached pixbuf from a previous on-demand transform operation exists and
                // its zoom state is different from the currently requested zoom state, then we
                // can't just use the cached pixbuf as-is. However, we might be able to use *some*
                // of the information in the previously cached pixbuf. Specifically, if the zoom
                // state of the previously cached pixbuf is merely a translation of the currently
                // requested zoom state (the zoom states are not equal but the zoom factors are the
                // same), then all that has happened is that the user has panned the viewing
                // window. So keep all the pixels from the cached pixbuf that are still on-screen
                // in the current view.
202 203 204
                Gdk.Rectangle curr_rect = zoom_state.get_viewing_rectangle_wrt_content();
                Gdk.Rectangle pre_rect =
                    demand_transform_zoom_state.get_viewing_rectangle_wrt_content();
205 206
                Gdk.Rectangle transfer_src_rect = Gdk.Rectangle();
                Gdk.Rectangle transfer_dest_rect = Gdk.Rectangle();
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
                                 
                transfer_src_rect.x = (curr_rect.x - pre_rect.x).clamp(0, pre_rect.width);
                transfer_src_rect.y = (curr_rect.y - pre_rect.y).clamp(0, pre_rect.height);
                int transfer_src_right = ((curr_rect.x + curr_rect.width) - pre_rect.width).clamp(0,
                    pre_rect.width);
                transfer_src_rect.width = transfer_src_right - transfer_src_rect.x;
                int transfer_src_bottom = ((curr_rect.y + curr_rect.height) - pre_rect.width).clamp(
                    0, pre_rect.height);
                transfer_src_rect.height = transfer_src_bottom - transfer_src_rect.y;
                
                transfer_dest_rect.x = (pre_rect.x - curr_rect.x).clamp(0, curr_rect.width);
                transfer_dest_rect.y = (pre_rect.y - curr_rect.y).clamp(0, curr_rect.height);
                int transfer_dest_right = (transfer_dest_rect.x + transfer_src_rect.width).clamp(0,
                    curr_rect.width);
                transfer_dest_rect.width = transfer_dest_right - transfer_dest_rect.x;
                int transfer_dest_bottom = (transfer_dest_rect.y + transfer_src_rect.height).clamp(0,
                    curr_rect.height);
                transfer_dest_rect.height = transfer_dest_bottom - transfer_dest_rect.y;

                Gdk.Pixbuf composited_result = get_zoom_preview_image_internal(zoom_state);
                demand_transform_cached_pixbuf.copy_area (transfer_src_rect.x,
                    transfer_src_rect.y, transfer_dest_rect.width, transfer_dest_rect.height,
                    composited_result, transfer_dest_rect.x, transfer_dest_rect.y);

                return composited_result;
            }
        }

        // ok -- the cached pixbuf didn't help us -- so check if there is a demand
        // transformation background job currently in progress. if such a job is in progress,
        // then check if it's for the same zoom state as the one requested here. If the
        // zoom states are the same, then just return the preview image for now -- we won't
        // get a crisper one until the background job completes. If the zoom states are not the
        // same however, then cancel the existing background job and initiate a new one for the
        // currently requested zoom state.
        if (demand_transform_job != null) {
            if (zoom_state.equals(demand_transform_zoom_state)) {
                return get_zoom_preview_image_internal(zoom_state);
            } else {
                demand_transform_job.cancel();
                demand_transform_job = null;

                Gdk.Pixbuf zoomed = get_view_projection_pixbuf(zoom_state, iso_source_image,
                    reduced_source_image);
                
                demand_transform_job = new TransformationJob(this, zoomed,
                    backing_photo.get_pixel_transformer(), on_demand_transform_complete,
                    new Cancellable());
                demand_transform_zoom_state = zoom_state;
                workers.enqueue(demand_transform_job);
                
                return get_zoom_preview_image_internal(zoom_state);
            }
        }
        
        // if no on-demand background transform job is in progress at all, then start one
        if (demand_transform_job == null) {
            Gdk.Pixbuf zoomed = get_view_projection_pixbuf(zoom_state, iso_source_image,
                reduced_source_image);
            
            demand_transform_job = new TransformationJob(this, zoomed,
                backing_photo.get_pixel_transformer(), on_demand_transform_complete,
                new Cancellable());

            demand_transform_zoom_state = zoom_state;
            
            workers.enqueue(demand_transform_job);
            
            return get_zoom_preview_image_internal(zoom_state);
        }
        
        // execution should never reach this point -- the various nested conditionals above should
        // account for every possible case that can occur when the ZoomBuffer is in the
        // SOURCE-NOT-TRANSFORMED state. So if execution does reach this point, print a critical
        // warning to the console and just zoom using the preview image (the preview image, since
        // it's managed by the SinglePhotoPage that created us, is assumed to be good).
        critical("ZoomBuffer: get_zoomed_image( ): in SOURCE-NOT-TRANSFORMED but can't transform " +
            "on-screen projection on-demand; using preview image");
        return get_zoom_preview_image_internal(zoom_state);
    }

    public Gdk.Pixbuf get_zoom_preview_image_internal(ZoomState zoom_state) {
        if (object_state == ObjectState.SOURCE_NOT_LOADED) {
            BackgroundJob iso_source_fetch_job = new IsoSourceFetchJob(this, backing_photo,
                on_iso_source_fetch_complete);
            workers.enqueue(iso_source_fetch_job);

            object_state = ObjectState.SOURCE_LOAD_IN_PROGRESS;
        }
296
        Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content();
297 298 299
        Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(
            preview_image);

300 301 302
        view_rect_proj.width = view_rect_proj.width.clamp(1, int.MAX);   
        view_rect_proj.height = view_rect_proj.height.clamp(1, int.MAX);   

303 304 305 306 307 308 309 310 311
        Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(preview_image,
            view_rect_proj.x, view_rect_proj.y, view_rect_proj.width, view_rect_proj.height);

        Gdk.Pixbuf zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height,
            Gdk.InterpType.BILINEAR);
       
        return zoomed;
    }

Jim Nelson's avatar
Jim Nelson committed
312
    public Photo get_backing_photo() {
313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363
        return backing_photo;
    }
    
    public void update_preview_image(Gdk.Pixbuf preview_image) {
        this.preview_image = preview_image;
    }
    
    // invoke with no arguments or with null to merely flush the cache or alternatively pass in a
    // single zoom state argument to re-seed the cache for that zoom state after it's been flushed
    public void flush_demand_cache(ZoomState? initial_zoom_state = null) {
        demand_transform_cached_pixbuf = null;
        if (initial_zoom_state != null)
            get_zoomed_image(initial_zoom_state);
    }

    public Gdk.Pixbuf get_zoomed_image(ZoomState zoom_state) {
        is_interactive_redraw_in_progress = false;
        // if request is for a zoomed image with an interpolation factor of zero (i.e., no zooming
        // needs to be performed since the zoom slider is all the way to the left), then just
        // return the zoom preview image
        if (zoom_state.get_interpolation_factor() == 0.0) {
            return get_zoom_preview_image_internal(zoom_state);
        }
        
        switch (object_state) {
            case ObjectState.SOURCE_NOT_LOADED:
            case ObjectState.SOURCE_LOAD_IN_PROGRESS:
                return get_zoom_preview_image_internal(zoom_state);
            
            case ObjectState.SOURCE_NOT_TRANSFORMED:
                return get_zoomed_image_source_not_transformed(zoom_state);
            
            case ObjectState.TRANSFORMED_READY:
                // if an isomorphic, transformed pixbuf is ready, then just sample the projection of
                // current viewing window from it and return that.
                return get_view_projection_pixbuf(zoom_state, iso_transformed_image,
                    reduced_transformed_image);
            
            default:
                critical("ZoomBuffer: get_zoomed_image( ): object is an inconsistent state");
                return get_zoom_preview_image_internal(zoom_state);
        }
    }

    public Gdk.Pixbuf get_zoom_preview_image(ZoomState zoom_state) {
        is_interactive_redraw_in_progress = true;

        return get_zoom_preview_image_internal(zoom_state);
    }
}

364
public abstract class EditingHostPage : SinglePhotoPage {
Jim Nelson's avatar
Jim Nelson committed
365 366 367
    public const int TRINKET_SCALE = 20;
    public const int TRINKET_PADDING = 1;
    
368
    public const double ZOOM_INCREMENT_SIZE = 0.1;
369
    public const int PAN_INCREMENT_SIZE = 64; /* in pixels */
370
    public const int TOOL_WINDOW_SEPARATOR = 8;
371 372
    public const int PIXBUF_CACHE_COUNT = 5;
    public const int ORIGINAL_PIXBUF_CACHE_COUNT = 5;
373
    
374
    private class EditingHostCanvas : EditingTools.PhotoCanvas {
375
        private EditingHostPage host_page;
376
        
377
        public EditingHostCanvas(EditingHostPage host_page) {
Jim Nelson's avatar
Jim Nelson committed
378
            base(host_page.get_container(), host_page.canvas.get_window(), host_page.get_photo(),
379
                host_page.get_cairo_context(), host_page.get_surface_dim(), host_page.get_scaled_pixbuf(),
380
                host_page.get_scaled_pixbuf_position());
381
            
382
            this.host_page = host_page;
383
        }
384
        
385
        public override void repaint() {
386
            host_page.repaint();
387
        }
388 389
    }
    
390
    private SourceCollection sources;
391
    private ViewCollection? parent_view = null;
392
    private Gdk.Pixbuf swapped = null;
393
    private bool pixbuf_dirty = true;
Jim Nelson's avatar
Jim Nelson committed
394
    private Gtk.ToolButton rotate_button = null;
395
    private Gtk.ToggleToolButton crop_button = null;
396
    private Gtk.ToggleToolButton redeye_button = null;
397
    private Gtk.ToggleToolButton adjust_button = null;
Clint Rogers's avatar
Clint Rogers committed
398
    private Gtk.ToggleToolButton straighten_button = null;
399 400 401
#if ENABLE_FACES
    private Gtk.ToggleToolButton faces_button = null;
#endif
402
    private Gtk.ToolButton enhance_button = null;
403
    private Gtk.Scale zoom_slider = null;
404 405
    private Gtk.ToolButton prev_button = new Gtk.ToolButton(null, Resources.PREVIOUS_LABEL);
    private Gtk.ToolButton next_button = new Gtk.ToolButton(null, Resources.NEXT_LABEL);
406
    private EditingTools.EditingTool current_tool = null;
407 408
    private Gtk.ToggleToolButton current_editing_toggle = null;
    private Gdk.Pixbuf cancel_editing_pixbuf = null;
409
    private bool photo_missing = false;
410
    private PixbufCache cache = null;
411
    private PixbufCache master_cache = null;
412
    private DragAndDropHandler dnd_handler = null;
413 414 415 416
    private bool enable_interactive_zoom_refresh = false;
    private Gdk.Point zoom_pan_start_point;
    private bool is_pan_in_progress = false;
    private double saved_slider_val = 0.0;
417
    private ZoomBuffer? zoom_buffer = null;
418
    private Gee.HashMap<string, int> last_locations = new Gee.HashMap<string, int>();
419
    
420
    protected EditingHostPage(SourceCollection sources, string name) {
421
        base(name, false);
422
        
423 424 425
        this.sources = sources;
        
        // when photo is altered need to update it here
426
        sources.items_altered.connect(on_photos_altered);
427
        
428 429 430 431
        // monitor when the ViewCollection's contents change
        get_view().contents_altered.connect(on_view_contents_ordering_altered);
        get_view().ordering_changed.connect(on_view_contents_ordering_altered);
        
432 433 434 435
        // the viewport can change size independent of the window being resized (when the searchbar
        // disappears, for example)
        viewport.size_allocate.connect(on_viewport_resized);
        
436
        // set up page's toolbar (used by AppWindow for layout and FullscreenWindow as a popup)
437 438
        Gtk.Toolbar toolbar = get_toolbar();
        
439
        // rotate tool
440
        rotate_button = new Gtk.ToolButton (null, Resources.ROTATE_CW_LABEL);
441
        rotate_button.set_icon_name(Resources.CLOCKWISE);
442
        rotate_button.set_tooltip_text(Resources.ROTATE_CW_TOOLTIP);
443
        rotate_button.clicked.connect(on_rotate_clockwise);
444
        rotate_button.is_important = true;
Jim Nelson's avatar
Jim Nelson committed
445
        toolbar.insert(rotate_button, -1);
446 447 448
        unowned Gtk.BindingSet binding_set = Gtk.BindingSet.by_class(rotate_button.get_class());
        Gtk.BindingEntry.add_signal(binding_set, Gdk.Key.KP_Space, Gdk.ModifierType.CONTROL_MASK, "clicked", 0);
        Gtk.BindingEntry.add_signal(binding_set, Gdk.Key.space, Gdk.ModifierType.CONTROL_MASK, "clicked", 0);
449
        
450
        // crop tool
451
        crop_button = new Gtk.ToggleToolButton ();
452
        crop_button.set_icon_name("image-crop-symbolic");
453 454
        crop_button.set_label(Resources.CROP_LABEL);
        crop_button.set_tooltip_text(Resources.CROP_TOOLTIP);
455
        crop_button.toggled.connect(on_crop_toggled);
456
        crop_button.is_important = true;
457
        toolbar.insert(crop_button, -1);
458

459
        // straightening tool
460
        straighten_button = new Gtk.ToggleToolButton ();
Jens Georg's avatar
Jens Georg committed
461
        straighten_button.set_icon_name(Resources.STRAIGHTEN);
462 463 464 465 466 467
        straighten_button.set_label(Resources.STRAIGHTEN_LABEL);
        straighten_button.set_tooltip_text(Resources.STRAIGHTEN_TOOLTIP);
        straighten_button.toggled.connect(on_straighten_toggled);
        straighten_button.is_important = true;
        toolbar.insert(straighten_button, -1);

468
        // redeye reduction tool
469
        redeye_button = new Gtk.ToggleToolButton ();
470
        redeye_button.set_icon_name("stock-eye-symbolic");
471 472
        redeye_button.set_label(Resources.RED_EYE_LABEL);
        redeye_button.set_tooltip_text(Resources.RED_EYE_TOOLTIP);
473
        redeye_button.toggled.connect(on_redeye_toggled);
474
        redeye_button.is_important = true;
475
        toolbar.insert(redeye_button, -1);
476 477
        
        // adjust tool
478 479
        adjust_button = new Gtk.ToggleToolButton();
        adjust_button.set_icon_name(Resources.ADJUST);
480 481
        adjust_button.set_label(Resources.ADJUST_LABEL);
        adjust_button.set_tooltip_text(Resources.ADJUST_TOOLTIP);
482
        adjust_button.toggled.connect(on_adjust_toggled);
483
        adjust_button.is_important = true;
484
        toolbar.insert(adjust_button, -1);
485

486
        // enhance tool
487 488
        enhance_button = new Gtk.ToolButton(null, Resources.ENHANCE_LABEL);
        enhance_button.set_icon_name(Resources.ENHANCE);
489
        enhance_button.set_tooltip_text(Resources.ENHANCE_TOOLTIP);
490
        enhance_button.clicked.connect(on_enhance);
491
        enhance_button.is_important = true;
492
        toolbar.insert(enhance_button, -1);
493
        
494 495 496
#if ENABLE_FACES
        // faces tool
        insert_faces_button(toolbar);
497 498
        faces_button = new Gtk.ToggleToolButton();
        //face_button
499 500
#endif

501 502 503 504 505 506
        // separator to force next/prev buttons to right side of toolbar
        Gtk.SeparatorToolItem separator = new Gtk.SeparatorToolItem();
        separator.set_expand(true);
        separator.set_draw(false);
        toolbar.insert(separator, -1);
        
507
        Gtk.Box zoom_group = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0);
508
        
509
        Gtk.Image zoom_out = new Gtk.Image.from_icon_name("image-zoom-out-symbolic", Gtk.IconSize.SMALL_TOOLBAR);
510 511 512 513 514 515 516 517
        Gtk.EventBox zoom_out_box = new Gtk.EventBox();
        zoom_out_box.set_above_child(true);
        zoom_out_box.set_visible_window(false);
        zoom_out_box.add(zoom_out);

        zoom_out_box.button_press_event.connect(on_zoom_out_pressed);

        zoom_group.pack_start(zoom_out_box, false, false, 0);
518

519
        // zoom slider
520
        zoom_slider = new Gtk.Scale(Gtk.Orientation.HORIZONTAL, new Gtk.Adjustment(0.0, 0.0, 1.1, 0.1, 0.1, 0.1));
521 522
        zoom_slider.set_draw_value(false);
        zoom_slider.set_size_request(120, -1);
523 524 525 526
        zoom_slider.value_changed.connect(on_zoom_slider_value_changed);
        zoom_slider.button_press_event.connect(on_zoom_slider_drag_begin);
        zoom_slider.button_release_event.connect(on_zoom_slider_drag_end);
        zoom_slider.key_press_event.connect(on_zoom_slider_key_press);
527 528

        zoom_group.pack_start(zoom_slider, false, false, 0);
529
        
530
        Gtk.Image zoom_in = new Gtk.Image.from_icon_name("image-zoom-in-symbolic", Gtk.IconSize.SMALL_TOOLBAR);
531 532 533 534 535 536 537 538 539 540 541 542 543
        Gtk.EventBox zoom_in_box = new Gtk.EventBox();
        zoom_in_box.set_above_child(true);
        zoom_in_box.set_visible_window(false);
        zoom_in_box.add(zoom_in);
        
        zoom_in_box.button_press_event.connect(on_zoom_in_pressed);

        zoom_group.pack_start(zoom_in_box, false, false, 0);

        Gtk.ToolItem group_wrapper = new Gtk.ToolItem();
        group_wrapper.add(zoom_group);

        toolbar.insert(group_wrapper, -1);
544

545 546 547 548
        separator = new Gtk.SeparatorToolItem();
        separator.set_draw(false);
        toolbar.insert(separator, -1);

549
        // previous button
550
        prev_button.set_tooltip_text(_("Previous photo"));
551
        prev_button.set_icon_name("go-previous-symbolic");
552
        prev_button.clicked.connect(on_previous_photo);
Jim Nelson's avatar
Jim Nelson committed
553
        toolbar.insert(prev_button, -1);
554 555
        
        // next button
556
        next_button.set_tooltip_text(_("Next photo"));
557
        next_button.set_icon_name("go-next-symbolic");
558
        next_button.clicked.connect(on_next_photo);
Jim Nelson's avatar
Jim Nelson committed
559
        toolbar.insert(next_button, -1);
560 561
    }
    
562
    ~EditingHostPage() {
563
        sources.items_altered.disconnect(on_photos_altered);
564 565 566
        
        get_view().contents_altered.disconnect(on_view_contents_ordering_altered);
        get_view().ordering_changed.disconnect(on_view_contents_ordering_altered);
567
    }
568
    
569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584
    private void on_zoom_slider_value_changed() {
        ZoomState new_zoom_state = ZoomState.rescale(get_zoom_state(), zoom_slider.get_value());

        if (enable_interactive_zoom_refresh) {
            on_interactive_zoom(new_zoom_state);
            
            if (new_zoom_state.is_default())
                set_zoom_state(new_zoom_state);
        } else {
            if (new_zoom_state.is_default()) {
                cancel_zoom();
            } else {
                set_zoom_state(new_zoom_state);
            }
            repaint();
        }
585 586
        
        update_cursor_for_zoom_context();
587 588 589 590
    }

    private bool on_zoom_slider_drag_begin(Gdk.EventButton event) {
        enable_interactive_zoom_refresh = true;
591 592 593
        
        if (get_container() is FullscreenWindow)
            ((FullscreenWindow) get_container()).disable_toolbar_dismissal();
594 595 596 597 598 599 600

        return false;
    }

    private bool on_zoom_slider_drag_end(Gdk.EventButton event) {
        enable_interactive_zoom_refresh = false;

601
        if (get_container() is FullscreenWindow)
602
            ((FullscreenWindow) get_container()).update_toolbar_dismissal();
603

604 605
        ZoomState zoom_state = ZoomState.rescale(get_zoom_state(), zoom_slider.get_value());
        set_zoom_state(zoom_state);
606 607
        
        repaint();
608 609 610

        return false;
    }
611 612 613 614 615 616 617 618 619 620 621

    private bool on_zoom_out_pressed(Gdk.EventButton event) {
        snap_zoom_to_min();
        return true;
    }
    
    private bool on_zoom_in_pressed(Gdk.EventButton event) {
        snap_zoom_to_max();
        return true;
    }

622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671
    private Gdk.Point get_cursor_wrt_viewport(Gdk.EventScroll event) {
        Gdk.Point cursor_wrt_canvas = {0};
        cursor_wrt_canvas.x = (int) event.x;
        cursor_wrt_canvas.y = (int) event.y;

        Gdk.Rectangle viewport_wrt_canvas = get_zoom_state().get_viewing_rectangle_wrt_screen();
        Gdk.Point result = {0};
        result.x = cursor_wrt_canvas.x - viewport_wrt_canvas.x;
        result.x = result.x.clamp(0, viewport_wrt_canvas.width);
        result.y = cursor_wrt_canvas.y - viewport_wrt_canvas.y;
        result.y = result.y.clamp(0, viewport_wrt_canvas.height);

        return result;
    }

    private Gdk.Point get_cursor_wrt_viewport_center(Gdk.EventScroll event) {
        Gdk.Point cursor_wrt_viewport = get_cursor_wrt_viewport(event);
        Gdk.Rectangle viewport_wrt_canvas = get_zoom_state().get_viewing_rectangle_wrt_screen();
        
        Gdk.Point viewport_center = {0};
        viewport_center.x = viewport_wrt_canvas.width / 2;
        viewport_center.y = viewport_wrt_canvas.height / 2;

        return subtract_points(cursor_wrt_viewport, viewport_center);
    }

    private Gdk.Point get_iso_pixel_under_cursor(Gdk.EventScroll event) {
        Gdk.Point viewport_center_iso = scale_point(get_zoom_state().get_viewport_center(),
            1.0 / get_zoom_state().get_zoom_factor());

        Gdk.Point cursor_wrt_center_iso = scale_point(get_cursor_wrt_viewport_center(event),
            1.0 / get_zoom_state().get_zoom_factor());

        return add_points(viewport_center_iso, cursor_wrt_center_iso);
    }

    private double snap_interpolation_factor(double interp) {
        if (interp < 0.03)
            interp = 0.0;
        else if (interp > 0.97)
            interp = 1.0;

        return interp;
    }

    private double adjust_interpolation_factor(double adjustment) {
        return snap_interpolation_factor(get_zoom_state().get_interpolation_factor() + adjustment);
    }

    private void zoom_about_event_cursor_point(Gdk.EventScroll event, double zoom_increment) {
672 673 674
        if (photo_missing)
            return;

675 676 677 678 679 680 681 682 683 684 685 686
        Gdk.Point cursor_wrt_viewport_center = get_cursor_wrt_viewport_center(event);
        Gdk.Point iso_pixel_under_cursor = get_iso_pixel_under_cursor(event);
    
        double interp = adjust_interpolation_factor(zoom_increment);
        zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed);
        zoom_slider.set_value(interp);
        zoom_slider.value_changed.connect(on_zoom_slider_value_changed);

        ZoomState new_zoom_state = ZoomState.rescale(get_zoom_state(), interp);

        if (new_zoom_state.is_min()) {
            cancel_zoom();
687
            update_cursor_for_zoom_context();
688 689 690 691 692 693 694 695 696 697 698 699 700
            repaint();
            return;
        }

        Gdk.Point new_zoomed_old_cursor = scale_point(iso_pixel_under_cursor,
            new_zoom_state.get_zoom_factor());
        Gdk.Point desired_new_viewport_center = subtract_points(new_zoomed_old_cursor,
            cursor_wrt_viewport_center);

        new_zoom_state = ZoomState.pan(new_zoom_state, desired_new_viewport_center);

        set_zoom_state(new_zoom_state);
        repaint();
701 702

        update_cursor_for_zoom_context();
703
    }
704

705
    protected void snap_zoom_to_min() {
706
        zoom_slider.set_value(0.0);
707 708 709 710 711 712 713 714 715 716 717 718 719
    }

    protected void snap_zoom_to_max() {
        zoom_slider.set_value(1.0);
    }

    protected void snap_zoom_to_isomorphic() {
        ZoomState iso_state = ZoomState.rescale_to_isomorphic(get_zoom_state());
        zoom_slider.set_value(iso_state.get_interpolation_factor());
    }

    protected virtual bool on_zoom_slider_key_press(Gdk.EventKey event) {
        switch (Gdk.keyval_name(event.keyval)) {
720 721 722
            case "equal":
            case "plus":
            case "KP_Add":
723
                activate_action("IncreaseSize");
724 725 726 727 728
                return true;
            
            case "minus":
            case "underscore":
            case "KP_Subtract":
729
                activate_action("DecreaseSize");
730
                return true;
731 732
            
            case "KP_Divide":
733
                activate_action("Zoom100");
734 735 736
                return true;

            case "KP_Multiply":
737
                activate_action("ZoomFit");
738
                return true;
739 740
        }

741 742 743 744
        return false;
    }

    protected virtual void on_increase_size() {
745
        zoom_slider.set_value(adjust_interpolation_factor(ZOOM_INCREMENT_SIZE));
746
    }
747
    
748
    protected virtual void on_decrease_size() {
749
        zoom_slider.set_value(adjust_interpolation_factor(-ZOOM_INCREMENT_SIZE));
750 751 752 753 754 755
    }

    protected override void save_zoom_state() {
        base.save_zoom_state();
        saved_slider_val = zoom_slider.get_value();
    }
756

757 758 759 760
    protected override ZoomBuffer? get_zoom_buffer() {
        return zoom_buffer;
    }
    
761
    protected override bool on_mousewheel_up(Gdk.EventScroll event) {
762
        if (get_zoom_state().is_max() || !zoom_slider.get_sensitive())
763
            return false;
764

765
        zoom_about_event_cursor_point(event, ZOOM_INCREMENT_SIZE);
766
        return true;
767
    }
768
    
769
    protected override bool on_mousewheel_down(Gdk.EventScroll event) {
770
        if (get_zoom_state().is_min() || !zoom_slider.get_sensitive())
771
            return false;
772
        
773
        zoom_about_event_cursor_point(event, -ZOOM_INCREMENT_SIZE);
774
        return true;
775 776
    }

777 778 779
    protected override void restore_zoom_state() {
        base.restore_zoom_state();

780
        zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed);
781
        zoom_slider.set_value(saved_slider_val);
782
        zoom_slider.value_changed.connect(on_zoom_slider_value_changed);
783 784 785 786 787 788
    }

    public override bool is_zoom_supported() {
        return true;
    }

789 790 791
    public override void set_container(Gtk.Window container) {
        base.set_container(container);
        
792
        // DnD not available in fullscreen mode
793
        if (!(container is FullscreenWindow))
794
            dnd_handler = new DragAndDropHandler(this);
795 796
    }
    
797 798
    public ViewCollection? get_parent_view() {
        return parent_view;
799 800
    }
    
801
    public bool has_photo() {
802
        return get_photo() != null;
803 804
    }
    
805
    public Photo? get_photo() {
806 807 808
        // If there is currently no selected photo, return null.
        if (get_view().get_selected_count() == 0)
            return null;
809
        
810 811 812 813
        // Use the selected photo.  There should only ever be one selected photo,
        // which is the currently displayed photo.
        assert(get_view().get_selected_count() == 1);
        return (Photo) get_view().get_selected_at(0).get_source();
814 815
    }
    
816
    // Called before the photo changes.
817
    protected virtual void photo_changing(Photo new_photo) {
818 819 820 821 822 823 824
        // If this is a raw image with a missing development, we can regenerate it,
        // so don't mark it as missing.
        if (new_photo.get_file_format() == PhotoFileFormat.RAW)
            set_photo_missing(false);
        else
            set_photo_missing(!new_photo.get_file().query_exists());
        
825 826
        update_ui(photo_missing);
    }
827
    
Jim Nelson's avatar
Jim Nelson committed
828
    private void set_photo(Photo photo) {
829
        zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed);
830
        zoom_slider.set_value(0.0);
831
        zoom_slider.value_changed.connect(on_zoom_slider_value_changed);
832
        
833
        photo_changing(photo);
834 835 836 837 838 839 840 841 842
        DataView view = get_view().get_view_for_source(photo);
        assert(view != null);
        
        // Select photo.
        get_view().unselect_all();
        Marker marker = get_view().mark(view);
        get_view().select_marked(marker);
        
        // also select it in the parent view's collection, so when the user returns to that view
843
        // it's apparent which one was being viewed here
844 845
        if (parent_view != null) {
            parent_view.unselect_all();
846
            DataView? view_in_parent = parent_view.get_view_for_source_filtered(photo);
847 848
            if (null != view_in_parent)
                parent_view.select_marked(parent_view.mark(view_in_parent));
849
        }
850 851
    }
    
852 853 854 855 856 857
    public override void realize() {
        base.realize();
        
        rebuild_caches("realize");
    }
    
858 859
    public override void switched_to() {
        base.switched_to();
860 861 862
        
        rebuild_caches("switched_to");
        
863
        // check if the photo altered while away
864
        if (has_photo() && pixbuf_dirty)
865
            replace_photo(get_photo());
866 867
    }
    
Jim Nelson's avatar
Jim Nelson committed
868
    public override void switching_from() {
869
        base.switching_from();
870
        
871 872 873
        cancel_zoom();
        is_pan_in_progress = false;
        
874
        deactivate_tool();
875

876 877 878 879 880 881 882 883 884
        // Ticket #3255 - Checkerboard page didn't `remember` what was selected
        // when the user went into and out of the photo page without navigating 
        // to next or previous.
        // Since the base class intentionally unselects everything in the parent
        // view, reselect the currently marked photo here...
        if ((has_photo()) && (parent_view != null)) {
            parent_view.select_marked(parent_view.mark(parent_view.get_view_for_source(get_photo())));
        }

885
        parent_view = null;
886
        get_view().clear();
Jim Nelson's avatar
Jim Nelson committed
887 888
    }
    
889 890
    public override void switching_to_fullscreen(FullscreenWindow fsw) {
        base.switching_to_fullscreen(fsw);
891
        
892
        deactivate_tool();
893
        
894 895 896
        cancel_zoom();
        is_pan_in_progress = false;
        
897 898 899
        Page page = fsw.get_current_page();
        if (page != null)
            page.get_view().items_selected.connect(on_selection_changed);
900
    }
901
    
902 903 904
    public override void returning_from_fullscreen(FullscreenWindow fsw) {
        base.returning_from_fullscreen(fsw);
        
905
        repaint();
906
        
907 908 909
        Page page = fsw.get_current_page();
        if (page != null)
            page.get_view().items_selected.disconnect(on_selection_changed);
910 911 912
    }
    
    private void on_selection_changed(Gee.Iterable<DataView> selected) {
913
        foreach (DataView view in selected) {
914
            replace_photo((Photo) view.get_source());
915 916 917
            break;
        }
    }
918 919 920 921 922

    protected void enable_rotate(bool should_enable) {
        rotate_button.set_sensitive(should_enable);
    }

923 924 925
    // This function should be called if the viewport has changed and the pixbuf cache needs to be
    // regenerated.  Use refresh_caches() if the contents of the ViewCollection have changed
    // but not the viewport.
926
    private void rebuild_caches(string caller) {
927
        Scaling scaling = get_canvas_scaling();
928
        
929 930 931 932
        // only rebuild if not the same scaling
        if (cache != null && cache.get_scaling().equals(scaling))
            return;
        
933
        debug("Rebuild pixbuf caches: %s (%s)", caller, scaling.to_string());
934 935 936 937
        
        // if dropping an old cache, clear the signal handler so currently executing requests
        // don't complete and cancel anything queued up
        if (cache != null) {
938
            cache.fetched.disconnect(on_pixbuf_fetched);
939 940 941
            cache.cancel_all();
        }
        
942
        cache = new PixbufCache(sources, PixbufCache.PhotoType.BASELINE, scaling, PIXBUF_CACHE_COUNT);
943
        cache.fetched.connect(on_pixbuf_fetched);
944
        
945 946
        master_cache = new PixbufCache(sources, PixbufCache.PhotoType.MASTER, scaling, 
            ORIGINAL_PIXBUF_CACHE_COUNT, master_cache_filter);
947
        
948 949 950 951 952
        refresh_caches(caller);
    }
    
    // See note at rebuild_caches() for usage.
    private void refresh_caches(string caller) {
953 954 955
        if (has_photo()) {
            debug("Refresh pixbuf caches (%s): prefetching neighbors of %s", caller,
                get_photo().to_string());
956
            prefetch_neighbors(get_view(), get_photo());
957 958 959
        } else {
            debug("Refresh pixbuf caches (%s): (no photo)", caller);
        }
960 961
    }
    
962 963 964 965
    private bool master_cache_filter(Photo photo) {
        return photo.has_transformations() || photo.has_editable();
    }
    
Jim Nelson's avatar
Jim Nelson committed
966
    private void on_pixbuf_fetched(Photo photo, Gdk.Pixbuf? pixbuf, Error? err) {
967
        // if not of the current photo, nothing more to do
968
        if (!photo.equals(get_photo()))
969
            return;
970

971
        if (pixbuf != null) {
972 973
            // update the preview image in the zoom buffer
            if ((zoom_buffer != null) && (zoom_buffer.get_backing_photo() == photo))
Lucas Beeler's avatar
Lucas Beeler committed
974
                zoom_buffer = new ZoomBuffer(this, photo, pixbuf);
975

976
            // if no tool, use the pixbuf directly, otherwise, let the tool decide what should be
977
            // displayed
978
            Dimensions max_dim = photo.get_dimensions();
979 980
            if (current_tool != null) {
                try {
981
                    Dimensions tool_pixbuf_dim;
982
                    Gdk.Pixbuf? tool_pixbuf = current_tool.get_display_pixbuf(get_canvas_scaling(),
983 984 985 986 987 988
                        photo, out tool_pixbuf_dim);

                    if (tool_pixbuf != null) {
                         pixbuf = tool_pixbuf;
                        max_dim = tool_pixbuf_dim;
                    }
989 990 991 992 993 994 995 996
                } catch(Error err) {
                    warning("Unable to fetch tool pixbuf for %s: %s", photo.to_string(), err.message);
                    set_photo_missing(true);
                    
                    return;
                }
            }
            
997
            set_pixbuf(pixbuf, max_dim);
998
            pixbuf_dirty = false;
999 1000
            
            notify_photo_backing_missing((Photo) photo, false);
1001
        } else if (err != null) {
1002 1003
            // this call merely updates the UI, and can be called indiscriminantly, whether or not
            // the photo is actually missing
1004
            set_photo_missing(true);
1005 1006 1007
            
            // this call should only be used when we're sure the photo is missing
            notify_photo_backing_missing((Photo) photo, true);
1008 1009 1010
        }
    }
    
1011
    private void prefetch_neighbors(ViewCollection controller, Photo photo) {
1012 1013 1014 1015 1016 1017
        PixbufCache.PixbufCacheBatch normal_batch = new PixbufCache.PixbufCacheBatch();
        PixbufCache.PixbufCacheBatch master_batch = new PixbufCache.PixbufCacheBatch();
        
        normal_batch.set(BackgroundJob.JobPriority.HIGHEST, photo);
        master_batch.set(BackgroundJob.JobPriority.LOW, photo);
        
1018
        DataSource next_source, prev_source;
1019
        if (!controller.get_immediate_neighbors(photo, out next_source, out prev_source, Photo.TYPENAME))
1020
            return;
1021
        
Jim Nelson's avatar
Jim Nelson committed
1022 1023
        Photo next = (Photo) next_source;
        Photo prev = (Photo) prev_source;
1024
        
1025
        // prefetch the immediate neighbors and their outer neighbors, for plenty of readahead
1026
        foreach (DataSource neighbor_source in controller.get_extended_neighbors(photo, Photo.TYPENAME)) {
1027
            Photo neighbor = (Photo) neighbor_source;
1028
            
1029 1030 1031 1032
            BackgroundJob.JobPriority priority = BackgroundJob.JobPriority.NORMAL;
            if (neighbor.equals(next) || neighbor.equals(prev))
                priority = BackgroundJob.JobPriority.HIGH;
            
1033 1034
            normal_batch.set(priority, neighbor);
            master_batch.set(BackgroundJob.JobPriority.LOWEST, neighbor);
1035
        }
1036 1037 1038
        
        cache.prefetch_batch(normal_batch);
        master_cache.prefetch_batch(master_batch);
1039 1040 1041 1042
    }
    
    // Cancels prefetches of old neighbors, but does not cancel them if they are the new
    // neighbors
1043 1044 1045
    private void cancel_prefetch_neighbors(ViewCollection old_controller, Photo old_photo,
        ViewCollection new_controller, Photo new_photo) {
        Gee.Set<Photo> old_neighbors = (Gee.Set<Photo>)
1046
            old_controller.get_extended_neighbors(old_photo, Photo.TYPENAME);
1047
        Gee.Set<Photo> new_neighbors = (Gee.Set<Photo>)
1048
            new_controller.get_extended_neighbors(new_photo, Photo.TYPENAME);
1049
        
1050
        foreach (Photo old_neighbor in old_neighbors) {
1051 1052 1053 1054
            // cancel prefetch and drop from cache if old neighbor is not part of the new
            // neighborhood
            if (!new_neighbors.contains(old_neighbor) && !new_photo.equals(old_neighbor)) {
                cache.drop(old_neighbor);
1055
                master_cache.drop(old_neighbor);
1056 1057
            }
        }
1058 1059 1060 1061
        
        // do same for old photo
        if (!new_neighbors.contains(old_photo) && !new_photo.equals(old_photo)) {
            cache.drop(old_photo);
1062
            master_cache.drop(old_photo);
1063
        }
1064 1065
    }
    
1066
    protected virtual DataView create_photo_view(DataSource source) {
1067 1068 1069 1070 1071 1072 1073
        return new PhotoView((PhotoSource) source);
    }
    
    private bool is_photo(DataSource source) {
        return source is PhotoSource;
    }
    
1074 1075 1076
    protected void display_copy_of(ViewCollection controller, Photo starting_photo) {
        assert(controller.get_view_for_source(starting_photo) != null);
        
1077 1078 1079 1080 1081
        if (controller != get_view() && controller != parent_view) {
            get_view().clear();
            get_view().copy_into(controller, create_photo_view, is_photo);
            parent_view = controller;
        }
1082
        
1083
        replace_photo(starting_photo);
1084
    }
1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097
    
    protected void display_mirror_of(ViewCollection controller, Photo starting_photo) {
        assert(controller.get_view_for_source(starting_photo) != null);
        
        if (controller != get_view() && controller != parent_view) {
            get_view().clear();
            get_view().mirror(controller, create_photo_view, is_photo);
            parent_view = controller;
        }
        
        replace_photo(starting_photo);
    }
    
1098
    protected virtual void update_ui(bool missing) {
1099
        bool sensitivity = !missing;
1100

1101 1102
        rotate_button.sensitive = sensitivity;
        crop_button.sensitive = sensitivity;
1103
        straighten_button.sensitive = sensitivity;
1104 1105 1106
        redeye_button.sensitive = sensitivity;
        adjust_button.sensitive = sensitivity;
        enhance_button.sensitive = sensitivity;
1107
        zoom_slider.sensitive = sensitivity;
1108

1109 1110
        deactivate_tool();
    }
1111 1112 1113 1114 1115
    
    // This should only be called when it's known that the photo is actually missing.
    protected virtual void notify_photo_backing_missing(Photo photo, bool missing) {
    }
    
1116 1117 1118 1119 1120
    private void draw_message(string message) {
        // draw the message in the center of the window
        Pango.Layout pango_layout = create_pango_layout(message);
        int text_width, text_height;
        pango_layout.get_pixel_size(out text_width, out text_height);
Jim Nelson's avatar
Jim Nelson committed
1121 1122 1123 1124
        
        Gtk.Allocation allocation;
        get_allocation(out allocation);
        
1125 1126 1127 1128 1129
        int x = allocation.width - text_width;
        x = (x > 0) ? x / 2 : 0;
        
        int y = allocation.height - text_height;
        y = (y > 0) ? y / 2 : 0;
1130 1131
        
        paint_text(pango_layout, x, y);
1132 1133
    }

1134
    // This method can be called indiscriminantly, whether or not the backing is actually present.
1135
    protected void set_photo_missing(bool missing) {
1136
        if (photo_missing == missing)
1137
            return;
1138
        
1139
        photo_missing = missing;
1140 1141 1142 1143 1144
        
        Photo? photo = get_photo();
        if (photo == null)
            return;
        
1145
        update_ui(missing);
1146
        
1147 1148
        if (photo_missing) {
            try {
1149 1150
                Gdk.Pixbuf pixbuf = photo.get_preview_pixbuf(get_canvas_scaling());
                
1151 1152
                pixbuf = pixbuf.composite_color_simple(pixbuf.get_width(), pixbuf.get_height(),
                    Gdk.InterpType.NEAREST, 100, 2, 0, 0);
1153 1154
                
                set_pixbuf(pixbuf, photo.get_dimensions());
1155
            } catch (GLib.Error err) {
1156
                set_pixbuf(new Gdk.Pixbuf(Gdk.Colorspace.RGB, false, 8, 1, 1), photo.get_dimensions());
1157 1158 1159 1160 1161
                warning("%s", err.message);
            }
        }
    }

1162 1163 1164 1165
    public bool get_photo_missing() {
        return photo_missing;
    }

Jim Nelson's avatar
Jim Nelson committed
1166
    protected virtual bool confirm_replace_photo(Photo? old_photo, Photo new_photo) {
1167
        return true;
1168
    }
1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179
    
    private Gdk.Pixbuf get_zoom_pixbuf(Photo new_photo) {
        Gdk.Pixbuf? pixbuf = cache.get_ready_pixbuf(new_photo);
        if (pixbuf == null) {
            try {
                pixbuf = new_photo.get_preview_pixbuf(get_canvas_scaling());
            } catch (Error err) {
                warning("%s", err.message);
            }
        }
        if (pixbuf == null) {
1180
            pixbuf = get_placeholder_pixbuf();
1181 1182 1183 1184
            get_canvas_scaling().perform_on_pixbuf(pixbuf, Gdk.InterpType.NEAREST, true);
        }
        return pixbuf;
    }
1185

1186
    private void replace_photo(Photo new_photo) {
1187 1188
        // if it's the same Photo object, the scaling hasn't changed, and the photo's file
        // has not gone missing or re-appeared, there's nothing to do otherwise,
1189 1190 1191
        // just need to reload the image for the proper scaling. Of course, the photo's pixels
        // might've changed, so rebuild the zoom buffer.
        if (new_photo.equals(get_photo()) && !pixbuf_dirty && !photo_missing) {
1192
            zoom_buffer = new ZoomBuffer(this, new_photo, get_zoom_pixbuf(new_photo));
1193
            return;
1194
        }
1195

1196
        // only check if okay to replace if there's something to replace and someone's concerned
1197 1198
        if (has_photo() && !new_photo.equals(get_photo()) && confirm_replace_photo != null) {
            if (!confirm_replace_photo(get_photo(), new_photo))
1199 1200 1201
                return;
        }

1202 1203
        deactivate_tool();
        
1204
        // swap out new photo and old photo and process change
1205
        Photo old_photo = get_photo();
1206 1207
        set_photo(new_photo);
        set_page_name(new_photo.get_name());
1208

1209
        // clear out the swap buffer
1210
        swapped = null;
1211

1212
        // reset flags
1213
        set_photo_missing(!new_photo.get_file().query_exists());
1214
        pixbuf_dirty = true;
1215
        
1216
        // it's possible for this to be called prior to the page being realized, however, the
1217
        // underlying canvas has a scaling, so use that (hence rebuild rather than refresh)
1218 1219
        rebuild_caches("replace_photo");
        
1220
        if (old_photo != null)
1221
            cancel_prefetch_neighbors(get_view(), old_photo, get_view(), new_photo);
1222
        
1223
        cancel_zoom();
1224 1225
        
        zoom_buffer = new ZoomBuffer(this, new_photo, get_zoom_pixbuf(new_photo));
1226
        
1227
        quick_update_pixbuf();
1228
        
1229 1230
        // now refresh the caches, which ensures that the neighbors get pulled into memory
        refresh_caches("replace_photo");
Jim Nelson's avatar
Jim Nelson committed
1231 1232
    }
    
1233 1234 1235
    protected override void cancel_zoom() {
        base.cancel_zoom();

1236
        zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed);
1237
        zoom_slider.set_value(0.0);
1238
        zoom_slider.value_changed.connect(on_zoom_slider_value_changed);
1239

1240 1241
        if (get_photo() != null)
            set_zoom_state(ZoomState(get_photo().get_dimensions(), get_surface_dim(), 0.0));
1242 1243 1244 1245 1246

        // when cancelling zoom, panning becomes impossible, so set the cursor back to
        // a left pointer in case it had been a hand-grip cursor indicating that panning
        // was possible; the null guards are required because zoom can be cancelled at
        // any time
Jim Nelson's avatar
Jim Nelson committed
1247
        if (canvas != null && canvas.get_window() != null)
1248
            set_page_cursor(Gdk.CursorType.LEFT_PTR);
1249 1250
        
        repaint();
1251 1252
    }
    
1253
    private void quick_update_pixbuf() {
1254
        Gdk.Pixbuf? pixbuf = cache.get_ready_pixbuf(get_photo());
1255
        if (pixbuf != null) {
1256
            set_pixbuf(pixbuf, get_photo().get_dimensions());
1257 1258 1259 1260 1261 1262 1263
            pixbuf_dirty = false;
            
            return;
        }
        
        Scaling scaling = get_canvas_scaling();
        
1264
        debug("Using progressive load for %s (%s)", get_photo().to_string(), scaling.to_string());
1265
        
1266 1267
        // throw a resized large thumbnail up to get an image on the screen quickly,
        // and when ready decode and display the full image
1268
        try {
1269
            set_pixbuf(get_photo().get_preview_pixbuf(scaling), get_photo().get_dimensions());
1270 1271 1272
        } catch (Error err) {
            warning("%s", err.message);
        }
1273
        
1274
        cache.prefetch(get_photo(), BackgroundJob.JobPriority.HIGHEST);
1275 1276 1277 1278
        
        // although final pixbuf not in place, it's on its way, so set this to clean so later calls
        // don't reload again
        pixbuf_dirty = false;
1279 1280 1281
    }
    
    private bool update_pixbuf() {
1282 1283
#if MEASURE_PIPELINE
        Timer timer = new Timer();
1284 1285 1286 1287 1288 1289
#endif
        
        Photo? photo = get_photo();
        if (photo == null)
            return false;
        
1290
        Gdk.Pixbuf pixbuf = null;