diff options
Diffstat (limited to 'src/PhotoPage.vala')
-rw-r--r-- | src/PhotoPage.vala | 3361 |
1 files changed, 3361 insertions, 0 deletions
diff --git a/src/PhotoPage.vala b/src/PhotoPage.vala new file mode 100644 index 0000000..d74d004 --- /dev/null +++ b/src/PhotoPage.vala @@ -0,0 +1,3361 @@ +/* Copyright 2009-2014 Yorba Foundation + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public class ZoomBuffer : Object { + private enum ObjectState { + SOURCE_NOT_LOADED, + SOURCE_LOAD_IN_PROGRESS, + SOURCE_NOT_TRANSFORMED, + TRANSFORMED_READY + } + + private class IsoSourceFetchJob : BackgroundJob { + private Photo to_fetch; + + public Gdk.Pixbuf? fetched = null; + + public IsoSourceFetchJob(ZoomBuffer owner, Photo to_fetch, + CompletionCallback completion_callback) { + base(owner, completion_callback); + + this.to_fetch = to_fetch; + } + + public override void execute() { + try { + fetched = to_fetch.get_pixbuf_with_options(Scaling.for_original(), + Photo.Exception.ADJUST); + } 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; + private Photo backing_photo = null; + 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; + + public ZoomBuffer(SinglePhotoPage parent_page, Photo backing_photo, + 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); + } + object_state = ObjectState.TRANSFORMED_READY; + } + + 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) { + Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content(); + 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; + } + + // 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); + + 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); + + assert(zoomed != null); + + 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. + Gdk.Rectangle curr_rect = zoom_state.get_viewing_rectangle_wrt_content(); + Gdk.Rectangle pre_rect = + demand_transform_zoom_state.get_viewing_rectangle_wrt_content(); + Gdk.Rectangle transfer_src_rect = Gdk.Rectangle(); + Gdk.Rectangle transfer_dest_rect = Gdk.Rectangle(); + + 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; + } + Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content(); + Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection( + preview_image); + + view_rect_proj.width = view_rect_proj.width.clamp(1, int.MAX); + view_rect_proj.height = view_rect_proj.height.clamp(1, int.MAX); + + 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; + } + + public Photo get_backing_photo() { + 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); + } +} + +public abstract class EditingHostPage : SinglePhotoPage { + public const int TRINKET_SCALE = 20; + public const int TRINKET_PADDING = 1; + + public const double ZOOM_INCREMENT_SIZE = 0.1; + public const int PAN_INCREMENT_SIZE = 64; /* in pixels */ + public const int TOOL_WINDOW_SEPARATOR = 8; + public const int PIXBUF_CACHE_COUNT = 5; + public const int ORIGINAL_PIXBUF_CACHE_COUNT = 5; + + private class EditingHostCanvas : EditingTools.PhotoCanvas { + private EditingHostPage host_page; + + public EditingHostCanvas(EditingHostPage host_page) { + base(host_page.get_container(), host_page.canvas.get_window(), host_page.get_photo(), + host_page.get_cairo_context(), host_page.get_surface_dim(), host_page.get_scaled_pixbuf(), + host_page.get_scaled_pixbuf_position()); + + this.host_page = host_page; + } + + public override void repaint() { + host_page.repaint(); + } + } + + private SourceCollection sources; + private ViewCollection? parent_view = null; + private Gdk.Pixbuf swapped = null; + private bool pixbuf_dirty = true; + private Gtk.ToolButton rotate_button = null; + private Gtk.ToggleToolButton crop_button = null; + private Gtk.ToggleToolButton redeye_button = null; + private Gtk.ToggleToolButton adjust_button = null; + private Gtk.ToggleToolButton straighten_button = null; + private Gtk.ToolButton enhance_button = null; + private Gtk.Scale zoom_slider = null; + private Gtk.ToolButton prev_button = new Gtk.ToolButton.from_stock(Gtk.Stock.GO_BACK); + private Gtk.ToolButton next_button = new Gtk.ToolButton.from_stock(Gtk.Stock.GO_FORWARD); + private EditingTools.EditingTool current_tool = null; + private Gtk.ToggleToolButton current_editing_toggle = null; + private Gdk.Pixbuf cancel_editing_pixbuf = null; + private bool photo_missing = false; + private PixbufCache cache = null; + private PixbufCache master_cache = null; + private DragAndDropHandler dnd_handler = null; + 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; + private ZoomBuffer? zoom_buffer = null; + private Gee.HashMap<string, int> last_locations = new Gee.HashMap<string, int>(); + + public EditingHostPage(SourceCollection sources, string name) { + base(name, false); + + this.sources = sources; + + // when photo is altered need to update it here + sources.items_altered.connect(on_photos_altered); + + // 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); + + // the viewport can change size independent of the window being resized (when the searchbar + // disappears, for example) + viewport.size_allocate.connect(on_viewport_resized); + + // set up page's toolbar (used by AppWindow for layout and FullscreenWindow as a popup) + Gtk.Toolbar toolbar = get_toolbar(); + + // rotate tool + rotate_button = new Gtk.ToolButton.from_stock(""); + rotate_button.set_icon_name(Resources.CLOCKWISE); + rotate_button.set_label(Resources.ROTATE_CW_LABEL); + rotate_button.set_tooltip_text(Resources.ROTATE_CW_TOOLTIP); + rotate_button.clicked.connect(on_rotate_clockwise); + rotate_button.is_important = true; + toolbar.insert(rotate_button, -1); + + // crop tool + crop_button = new Gtk.ToggleToolButton.from_stock(Resources.CROP); + crop_button.set_label(Resources.CROP_LABEL); + crop_button.set_tooltip_text(Resources.CROP_TOOLTIP); + crop_button.toggled.connect(on_crop_toggled); + crop_button.is_important = true; + toolbar.insert(crop_button, -1); + + // straightening tool + straighten_button = new Gtk.ToggleToolButton.from_stock(Resources.STRAIGHTEN); + 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); + + // redeye reduction tool + redeye_button = new Gtk.ToggleToolButton.from_stock(Resources.REDEYE); + redeye_button.set_label(Resources.RED_EYE_LABEL); + redeye_button.set_tooltip_text(Resources.RED_EYE_TOOLTIP); + redeye_button.toggled.connect(on_redeye_toggled); + redeye_button.is_important = true; + toolbar.insert(redeye_button, -1); + + // adjust tool + adjust_button = new Gtk.ToggleToolButton.from_stock(Resources.ADJUST); + adjust_button.set_label(Resources.ADJUST_LABEL); + adjust_button.set_tooltip_text(Resources.ADJUST_TOOLTIP); + adjust_button.toggled.connect(on_adjust_toggled); + adjust_button.is_important = true; + toolbar.insert(adjust_button, -1); + + // enhance tool + enhance_button = new Gtk.ToolButton.from_stock(Resources.ENHANCE); + enhance_button.set_label(Resources.ENHANCE_LABEL); + enhance_button.set_tooltip_text(Resources.ENHANCE_TOOLTIP); + enhance_button.clicked.connect(on_enhance); + enhance_button.is_important = true; + toolbar.insert(enhance_button, -1); + + // 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); + + Gtk.Box zoom_group = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0); + + Gtk.Image zoom_out = new Gtk.Image.from_pixbuf(Resources.load_icon(Resources.ICON_ZOOM_OUT, + Resources.ICON_ZOOM_SCALE)); + 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); + + // zoom slider + zoom_slider = new Gtk.Scale(Gtk.Orientation.HORIZONTAL, new Gtk.Adjustment(0.0, 0.0, 1.1, 0.1, 0.1, 0.1)); + zoom_slider.set_draw_value(false); + zoom_slider.set_size_request(120, -1); + 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); + + zoom_group.pack_start(zoom_slider, false, false, 0); + + Gtk.Image zoom_in = new Gtk.Image.from_pixbuf(Resources.load_icon(Resources.ICON_ZOOM_IN, + Resources.ICON_ZOOM_SCALE)); + 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); + + // previous button + prev_button.set_tooltip_text(_("Previous photo")); + prev_button.clicked.connect(on_previous_photo); + toolbar.insert(prev_button, -1); + + // next button + next_button.set_tooltip_text(_("Next photo")); + next_button.clicked.connect(on_next_photo); + toolbar.insert(next_button, -1); + } + + ~EditingHostPage() { + sources.items_altered.disconnect(on_photos_altered); + + get_view().contents_altered.disconnect(on_view_contents_ordering_altered); + get_view().ordering_changed.disconnect(on_view_contents_ordering_altered); + } + + 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(); + } + + update_cursor_for_zoom_context(); + } + + private bool on_zoom_slider_drag_begin(Gdk.EventButton event) { + enable_interactive_zoom_refresh = true; + + if (get_container() is FullscreenWindow) + ((FullscreenWindow) get_container()).disable_toolbar_dismissal(); + + return false; + } + + private bool on_zoom_slider_drag_end(Gdk.EventButton event) { + enable_interactive_zoom_refresh = false; + + if (get_container() is FullscreenWindow) + ((FullscreenWindow) get_container()).update_toolbar_dismissal(); + + ZoomState zoom_state = ZoomState.rescale(get_zoom_state(), zoom_slider.get_value()); + set_zoom_state(zoom_state); + + repaint(); + + return false; + } + + 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; + } + + 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) { + if (photo_missing) + return; + + 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(); + update_cursor_for_zoom_context(); + 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(); + + update_cursor_for_zoom_context(); + } + + protected void snap_zoom_to_min() { + zoom_slider.set_value(0.0); + } + + 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)) { + case "equal": + case "plus": + case "KP_Add": + activate_action("IncreaseSize"); + return true; + + case "minus": + case "underscore": + case "KP_Subtract": + activate_action("DecreaseSize"); + return true; + + case "KP_Divide": + activate_action("Zoom100"); + return true; + + case "KP_Multiply": + activate_action("ZoomFit"); + return true; + } + + return false; + } + + protected virtual void on_increase_size() { + zoom_slider.set_value(adjust_interpolation_factor(ZOOM_INCREMENT_SIZE)); + } + + protected virtual void on_decrease_size() { + zoom_slider.set_value(adjust_interpolation_factor(-ZOOM_INCREMENT_SIZE)); + } + + protected override void save_zoom_state() { + base.save_zoom_state(); + saved_slider_val = zoom_slider.get_value(); + } + + protected override ZoomBuffer? get_zoom_buffer() { + return zoom_buffer; + } + + protected override bool on_mousewheel_up(Gdk.EventScroll event) { + if (get_zoom_state().is_max() || !zoom_slider.get_sensitive()) + return false; + + zoom_about_event_cursor_point(event, ZOOM_INCREMENT_SIZE); + return false; + } + + protected override bool on_mousewheel_down(Gdk.EventScroll event) { + if (get_zoom_state().is_min() || !zoom_slider.get_sensitive()) + return false; + + zoom_about_event_cursor_point(event, -ZOOM_INCREMENT_SIZE); + return false; + } + + protected override void restore_zoom_state() { + base.restore_zoom_state(); + + zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed); + zoom_slider.set_value(saved_slider_val); + zoom_slider.value_changed.connect(on_zoom_slider_value_changed); + } + + public override bool is_zoom_supported() { + return true; + } + + public override void set_container(Gtk.Window container) { + base.set_container(container); + + // DnD not available in fullscreen mode + if (!(container is FullscreenWindow)) + dnd_handler = new DragAndDropHandler(this); + } + + public ViewCollection? get_parent_view() { + return parent_view; + } + + public bool has_photo() { + return get_photo() != null; + } + + public Photo? get_photo() { + // If there is currently no selected photo, return null. + if (get_view().get_selected_count() == 0) + return null; + + // 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(); + } + + // Called before the photo changes. + protected virtual void photo_changing(Photo new_photo) { + // 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()); + + update_ui(photo_missing); + } + + private void set_photo(Photo photo) { + zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed); + zoom_slider.set_value(0.0); + zoom_slider.value_changed.connect(on_zoom_slider_value_changed); + + photo_changing(photo); + 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 + // it's apparent which one was being viewed here + if (parent_view != null) { + parent_view.unselect_all(); + DataView? view_in_parent = parent_view.get_view_for_source_filtered(photo); + if (null != view_in_parent) + parent_view.select_marked(parent_view.mark(view_in_parent)); + } + } + + public override void realize() { + base.realize(); + + rebuild_caches("realize"); + } + + public override void switched_to() { + base.switched_to(); + + rebuild_caches("switched_to"); + + // check if the photo altered while away + if (has_photo() && pixbuf_dirty) + replace_photo(get_photo()); + } + + public override void switching_from() { + base.switching_from(); + + cancel_zoom(); + is_pan_in_progress = false; + + deactivate_tool(); + + // 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()))); + } + + parent_view = null; + get_view().clear(); + } + + public override void switching_to_fullscreen(FullscreenWindow fsw) { + base.switching_to_fullscreen(fsw); + + deactivate_tool(); + + cancel_zoom(); + is_pan_in_progress = false; + + Page page = fsw.get_current_page(); + if (page != null) + page.get_view().items_selected.connect(on_selection_changed); + } + + public override void returning_from_fullscreen(FullscreenWindow fsw) { + base.returning_from_fullscreen(fsw); + + repaint(); + + Page page = fsw.get_current_page(); + if (page != null) + page.get_view().items_selected.disconnect(on_selection_changed); + } + + private void on_selection_changed(Gee.Iterable<DataView> selected) { + foreach (DataView view in selected) { + replace_photo((Photo) view.get_source()); + break; + } + } + + protected void enable_rotate(bool should_enable) { + rotate_button.set_sensitive(should_enable); + } + + // 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. + private void rebuild_caches(string caller) { + Scaling scaling = get_canvas_scaling(); + + // only rebuild if not the same scaling + if (cache != null && cache.get_scaling().equals(scaling)) + return; + + debug("Rebuild pixbuf caches: %s (%s)", caller, scaling.to_string()); + + // if dropping an old cache, clear the signal handler so currently executing requests + // don't complete and cancel anything queued up + if (cache != null) { + cache.fetched.disconnect(on_pixbuf_fetched); + cache.cancel_all(); + } + + cache = new PixbufCache(sources, PixbufCache.PhotoType.BASELINE, scaling, PIXBUF_CACHE_COUNT); + cache.fetched.connect(on_pixbuf_fetched); + + master_cache = new PixbufCache(sources, PixbufCache.PhotoType.MASTER, scaling, + ORIGINAL_PIXBUF_CACHE_COUNT, master_cache_filter); + + refresh_caches(caller); + } + + // See note at rebuild_caches() for usage. + private void refresh_caches(string caller) { + if (has_photo()) { + debug("Refresh pixbuf caches (%s): prefetching neighbors of %s", caller, + get_photo().to_string()); + prefetch_neighbors(get_view(), get_photo()); + } else { + debug("Refresh pixbuf caches (%s): (no photo)", caller); + } + } + + private bool master_cache_filter(Photo photo) { + return photo.has_transformations() || photo.has_editable(); + } + + private void on_pixbuf_fetched(Photo photo, Gdk.Pixbuf? pixbuf, Error? err) { + // if not of the current photo, nothing more to do + if (!photo.equals(get_photo())) + return; + + if (pixbuf != null) { + // update the preview image in the zoom buffer + if ((zoom_buffer != null) && (zoom_buffer.get_backing_photo() == photo)) + zoom_buffer = new ZoomBuffer(this, photo, pixbuf); + + // if no tool, use the pixbuf directly, otherwise, let the tool decide what should be + // displayed + Dimensions max_dim = photo.get_dimensions(); + if (current_tool != null) { + try { + Dimensions tool_pixbuf_dim; + Gdk.Pixbuf? tool_pixbuf = current_tool.get_display_pixbuf(get_canvas_scaling(), + photo, out tool_pixbuf_dim); + + if (tool_pixbuf != null) { + pixbuf = tool_pixbuf; + max_dim = tool_pixbuf_dim; + } + } catch(Error err) { + warning("Unable to fetch tool pixbuf for %s: %s", photo.to_string(), err.message); + set_photo_missing(true); + + return; + } + } + + set_pixbuf(pixbuf, max_dim); + pixbuf_dirty = false; + + notify_photo_backing_missing((Photo) photo, false); + } else if (err != null) { + // this call merely updates the UI, and can be called indiscriminantly, whether or not + // the photo is actually missing + set_photo_missing(true); + + // this call should only be used when we're sure the photo is missing + notify_photo_backing_missing((Photo) photo, true); + } + } + + private void prefetch_neighbors(ViewCollection controller, Photo photo) { + 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); + + DataSource next_source, prev_source; + if (!controller.get_immediate_neighbors(photo, out next_source, out prev_source, Photo.TYPENAME)) + return; + + Photo next = (Photo) next_source; + Photo prev = (Photo) prev_source; + + // prefetch the immediate neighbors and their outer neighbors, for plenty of readahead + foreach (DataSource neighbor_source in controller.get_extended_neighbors(photo, Photo.TYPENAME)) { + Photo neighbor = (Photo) neighbor_source; + + BackgroundJob.JobPriority priority = BackgroundJob.JobPriority.NORMAL; + if (neighbor.equals(next) || neighbor.equals(prev)) + priority = BackgroundJob.JobPriority.HIGH; + + normal_batch.set(priority, neighbor); + master_batch.set(BackgroundJob.JobPriority.LOWEST, neighbor); + } + + cache.prefetch_batch(normal_batch); + master_cache.prefetch_batch(master_batch); + } + + // Cancels prefetches of old neighbors, but does not cancel them if they are the new + // neighbors + 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>) + old_controller.get_extended_neighbors(old_photo, Photo.TYPENAME); + Gee.Set<Photo> new_neighbors = (Gee.Set<Photo>) + new_controller.get_extended_neighbors(new_photo, Photo.TYPENAME); + + foreach (Photo old_neighbor in old_neighbors) { + // 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); + master_cache.drop(old_neighbor); + } + } + + // do same for old photo + if (!new_neighbors.contains(old_photo) && !new_photo.equals(old_photo)) { + cache.drop(old_photo); + master_cache.drop(old_photo); + } + } + + protected virtual DataView create_photo_view(DataSource source) { + return new PhotoView((PhotoSource) source); + } + + private bool is_photo(DataSource source) { + return source is PhotoSource; + } + + protected void display_copy_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().copy_into(controller, create_photo_view, is_photo); + parent_view = controller; + } + + replace_photo(starting_photo); + } + + 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); + } + + protected virtual void update_ui(bool missing) { + bool sensitivity = !missing; + + rotate_button.sensitive = sensitivity; + crop_button.sensitive = sensitivity; + straighten_button.sensitive = sensitivity; + redeye_button.sensitive = sensitivity; + adjust_button.sensitive = sensitivity; + enhance_button.sensitive = sensitivity; + zoom_slider.sensitive = sensitivity; + + deactivate_tool(); + } + + // 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) { + } + + 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); + + Gtk.Allocation allocation; + get_allocation(out allocation); + + int x = allocation.width - text_width; + x = (x > 0) ? x / 2 : 0; + + int y = allocation.height - text_height; + y = (y > 0) ? y / 2 : 0; + + paint_text(pango_layout, x, y); + } + + // This method can be called indiscriminantly, whether or not the backing is actually present. + protected void set_photo_missing(bool missing) { + if (photo_missing == missing) + return; + + photo_missing = missing; + + Photo? photo = get_photo(); + if (photo == null) + return; + + update_ui(missing); + + if (photo_missing) { + try { + Gdk.Pixbuf pixbuf = photo.get_preview_pixbuf(get_canvas_scaling()); + + pixbuf = pixbuf.composite_color_simple(pixbuf.get_width(), pixbuf.get_height(), + Gdk.InterpType.NEAREST, 100, 2, 0, 0); + + set_pixbuf(pixbuf, photo.get_dimensions()); + } catch (GLib.Error err) { + warning("%s", err.message); + } + } + } + + public bool get_photo_missing() { + return photo_missing; + } + + protected virtual bool confirm_replace_photo(Photo? old_photo, Photo new_photo) { + return true; + } + + 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) { + // Create empty pixbuf. + pixbuf = AppWindow.get_instance().render_icon(Gtk.Stock.MISSING_IMAGE, + Gtk.IconSize.DIALOG, null); + get_canvas_scaling().perform_on_pixbuf(pixbuf, Gdk.InterpType.NEAREST, true); + + } + return pixbuf; + } + + private void replace_photo(Photo new_photo) { + // 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, + // 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) { + zoom_buffer = new ZoomBuffer(this, new_photo, get_zoom_pixbuf(new_photo)); + return; + } + + // only check if okay to replace if there's something to replace and someone's concerned + if (has_photo() && !new_photo.equals(get_photo()) && confirm_replace_photo != null) { + if (!confirm_replace_photo(get_photo(), new_photo)) + return; + } + + deactivate_tool(); + + // swap out new photo and old photo and process change + Photo old_photo = get_photo(); + set_photo(new_photo); + set_page_name(new_photo.get_name()); + + // clear out the swap buffer + swapped = null; + + // reset flags + set_photo_missing(!new_photo.get_file().query_exists()); + pixbuf_dirty = true; + + // it's possible for this to be called prior to the page being realized, however, the + // underlying canvas has a scaling, so use that (hence rebuild rather than refresh) + rebuild_caches("replace_photo"); + + if (old_photo != null) + cancel_prefetch_neighbors(get_view(), old_photo, get_view(), new_photo); + + cancel_zoom(); + + zoom_buffer = new ZoomBuffer(this, new_photo, get_zoom_pixbuf(new_photo)); + + quick_update_pixbuf(); + + // now refresh the caches, which ensures that the neighbors get pulled into memory + refresh_caches("replace_photo"); + } + + protected override void cancel_zoom() { + base.cancel_zoom(); + + zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed); + zoom_slider.set_value(0.0); + zoom_slider.value_changed.connect(on_zoom_slider_value_changed); + + if (get_photo() != null) + set_zoom_state(ZoomState(get_photo().get_dimensions(), get_surface_dim(), 0.0)); + + // 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 + if (canvas != null && canvas.get_window() != null) + set_page_cursor(Gdk.CursorType.LEFT_PTR); + + repaint(); + } + + private void quick_update_pixbuf() { + Gdk.Pixbuf? pixbuf = cache.get_ready_pixbuf(get_photo()); + if (pixbuf != null) { + set_pixbuf(pixbuf, get_photo().get_dimensions()); + pixbuf_dirty = false; + + return; + } + + Scaling scaling = get_canvas_scaling(); + + debug("Using progressive load for %s (%s)", get_photo().to_string(), scaling.to_string()); + + // throw a resized large thumbnail up to get an image on the screen quickly, + // and when ready decode and display the full image + try { + set_pixbuf(get_photo().get_preview_pixbuf(scaling), get_photo().get_dimensions()); + } catch (Error err) { + warning("%s", err.message); + } + + cache.prefetch(get_photo(), BackgroundJob.JobPriority.HIGHEST); + + // 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; + } + + private bool update_pixbuf() { +#if MEASURE_PIPELINE + Timer timer = new Timer(); +#endif + + Photo? photo = get_photo(); + if (photo == null) + return false; + + Gdk.Pixbuf pixbuf = null; + Dimensions max_dim = photo.get_dimensions(); + + try { + Dimensions tool_pixbuf_dim = {0}; + if (current_tool != null) + pixbuf = current_tool.get_display_pixbuf(get_canvas_scaling(), photo, out tool_pixbuf_dim); + + if (pixbuf != null) + max_dim = tool_pixbuf_dim; + } catch (Error err) { + warning("%s", err.message); + set_photo_missing(true); + } + + if (!photo_missing) { + // if no pixbuf, see if it's waiting in the cache + if (pixbuf == null) + pixbuf = cache.get_ready_pixbuf(photo); + + // if still no pixbuf, background fetch and let the signal handler update the display + if (pixbuf == null) + cache.prefetch(photo); + } + + if (!photo_missing && pixbuf != null) { + set_pixbuf(pixbuf, max_dim); + pixbuf_dirty = false; + } + +#if MEASURE_PIPELINE + debug("UPDATE_PIXBUF: total=%lf", timer.elapsed()); +#endif + + return false; + } + + protected override void on_resize(Gdk.Rectangle rect) { + base.on_resize(rect); + + track_tool_window(); + } + + protected override void on_resize_finished(Gdk.Rectangle rect) { + // because we've loaded SinglePhotoPage with an image scaled to window size, as the window + // is resized it scales that, which pixellates, especially scaling upward. Once the window + // resize is complete, we get a fresh image for the new window's size + rebuild_caches("on_resize_finished"); + pixbuf_dirty = true; + + update_pixbuf(); + } + + private void on_viewport_resized() { + // this means the viewport (the display area) has changed, but not necessarily the + // toplevel window's dimensions + rebuild_caches("on_viewport_resized"); + pixbuf_dirty = true; + + update_pixbuf(); + } + + protected override void update_actions(int selected_count, int count) { + bool multiple_photos = get_view().get_sources_of_type_count(typeof(Photo)) > 1; + + prev_button.sensitive = multiple_photos; + next_button.sensitive = multiple_photos; + + Photo? photo = get_photo(); + Scaling scaling = get_canvas_scaling(); + + rotate_button.sensitive = ((photo != null) && (!photo_missing) && photo.check_can_rotate()) ? + is_rotate_available(photo) : false; + crop_button.sensitive = ((photo != null) && (!photo_missing)) ? + EditingTools.CropTool.is_available(photo, scaling) : false; + redeye_button.sensitive = ((photo != null) && (!photo_missing)) ? + EditingTools.RedeyeTool.is_available(photo, scaling) : false; + adjust_button.sensitive = ((photo != null) && (!photo_missing)) ? + EditingTools.AdjustTool.is_available(photo, scaling) : false; + enhance_button.sensitive = ((photo != null) && (!photo_missing)) ? + is_enhance_available(photo) : false; + straighten_button.sensitive = ((photo != null) && (!photo_missing)) ? + EditingTools.StraightenTool.is_available(photo, scaling) : false; + + base.update_actions(selected_count, count); + } + + protected override bool on_shift_pressed(Gdk.EventKey? event) { + // show quick compare of original only if no tool is in use, the original pixbuf is handy + if (current_tool == null && !get_ctrl_pressed() && !get_alt_pressed()) + swap_in_original(); + + return base.on_shift_pressed(event); + } + + protected override bool on_shift_released(Gdk.EventKey? event) { + if (current_tool == null) + swap_out_original(); + + return base.on_shift_released(event); + } + + protected override bool on_alt_pressed(Gdk.EventKey? event) { + if (current_tool == null) + swap_out_original(); + + return base.on_alt_pressed(event); + } + + protected override bool on_alt_released(Gdk.EventKey? event) { + if (current_tool == null && get_shift_pressed() && !get_ctrl_pressed()) + swap_in_original(); + + return base.on_alt_released(event); + } + + private void swap_in_original() { + Gdk.Pixbuf? original; + + original = + get_photo().get_original_orientation().rotate_pixbuf(get_photo().get_prefetched_copy()); + + if (original == null) + return; + + // store what's currently displayed only for the duration of the shift pressing + swapped = get_unscaled_pixbuf(); + + // save the zoom state and cancel zoom so that the user can see all of the original + // photo + if (zoom_slider.get_value() != 0.0) { + save_zoom_state(); + cancel_zoom(); + } + + set_pixbuf(original, get_photo().get_master_dimensions()); + } + + private void swap_out_original() { + if (swapped != null) { + set_pixbuf(swapped, get_photo().get_dimensions()); + + restore_zoom_state(); + update_cursor_for_zoom_context(); + + // only store swapped once; it'll be set the next on_shift_pressed + swapped = null; + } + } + + private void activate_tool(EditingTools.EditingTool tool) { + // cancel any zoom -- we don't currently allow tools to be used when an image is zoomed, + // though we may at some point in the future. + save_zoom_state(); + cancel_zoom(); + + // deactivate current tool ... current implementation is one tool at a time. In the future, + // tools may be allowed to be executing at the same time. + deactivate_tool(); + + // save current pixbuf to use if user cancels operation + cancel_editing_pixbuf = get_unscaled_pixbuf(); + + // see if the tool wants a different pixbuf displayed and what its max dimensions should be + Gdk.Pixbuf unscaled; + Dimensions max_dim = get_photo().get_dimensions(); + try { + Dimensions tool_pixbuf_dim = {0}; + unscaled = tool.get_display_pixbuf(get_canvas_scaling(), get_photo(), out tool_pixbuf_dim); + + if (unscaled != null) + max_dim = tool_pixbuf_dim; + } catch (Error err) { + warning("%s", err.message); + set_photo_missing(true); + + // untoggle tool button (usually done after deactivate, but tool never deactivated) + assert(current_editing_toggle != null); + current_editing_toggle.active = false; + + return; + } + + if (unscaled != null) + set_pixbuf(unscaled, max_dim); + + // create the PhotoCanvas object for a two-way interface to the tool + EditingTools.PhotoCanvas photo_canvas = new EditingHostCanvas(this); + + // hook tool into event system and activate it + current_tool = tool; + current_tool.activate(photo_canvas); + + // if the tool has an auxiliary window, move it properly on the screen + place_tool_window(); + + // repaint entire view, with the tool now hooked in + repaint(); + } + + private void deactivate_tool(Command? command = null, Gdk.Pixbuf? new_pixbuf = null, + Dimensions new_max_dim = Dimensions(), bool needs_improvement = false) { + if (current_tool == null) + return; + + EditingTools.EditingTool tool = current_tool; + current_tool = null; + + // save the position of the tool + EditingTools.EditingToolWindow? tool_window = tool.get_tool_window(); + if (tool_window != null && tool_window.has_user_moved()) { + int last_location_x, last_location_y; + tool_window.get_position(out last_location_x, out last_location_y); + last_locations[tool.name + "_x"] = last_location_x; + last_locations[tool.name + "_y"] = last_location_y; + } + + // deactivate with the tool taken out of the hooks and + // disconnect any signals we may have connected on activating + tool.deactivate(); + + tool.activated.disconnect(on_tool_activated); + tool.deactivated.disconnect(on_tool_deactivated); + tool.applied.disconnect(on_tool_applied); + tool.cancelled.disconnect(on_tool_cancelled); + tool.aborted.disconnect(on_tool_aborted); + + tool = null; + + // only null the toggle when the tool is completely deactivated; that is, deactive the tool + // before updating the UI + current_editing_toggle = null; + + // display the (possibly) new photo + Gdk.Pixbuf replacement = null; + if (new_pixbuf != null) { + replacement = new_pixbuf; + } else if (cancel_editing_pixbuf != null) { + replacement = cancel_editing_pixbuf; + new_max_dim = Dimensions.for_pixbuf(replacement); + needs_improvement = false; + } else { + needs_improvement = true; + } + + if (replacement != null) + set_pixbuf(replacement, new_max_dim); + cancel_editing_pixbuf = null; + + // if this is a rough pixbuf, schedule an improvement + if (needs_improvement) { + pixbuf_dirty = true; + Idle.add(update_pixbuf); + } + + // execute the tool's command + if (command != null) + get_command_manager().execute(command); + } + + // This virtual method is called only when the user double-clicks on the page and no tool + // is active + protected virtual bool on_double_click(Gdk.EventButton event) { + return false; + } + + // Return true to block the DnD handler from activating a drag + protected override bool on_left_click(Gdk.EventButton event) { + // report double-click if no tool is active, otherwise all double-clicks are eaten + if (event.type == Gdk.EventType.2BUTTON_PRESS) + return (current_tool == null) ? on_double_click(event) : false; + + int x = (int) event.x; + int y = (int) event.y; + + // if no editing tool, then determine whether we should start a pan operation over the + // zoomed photo or fall through to the default DnD behavior if we're not zoomed + if ((current_tool == null) && (zoom_slider.get_value() != 0.0)) { + zoom_pan_start_point.x = (int) event.x; + zoom_pan_start_point.y = (int) event.y; + is_pan_in_progress = true; + suspend_cursor_hiding(); + + return true; + } + + // default behavior when photo isn't zoomed -- return false to start DnD operation + if (current_tool == null) { + return false; + } + + // only concerned about mouse-downs on the pixbuf ... return true prevents DnD when the + // user drags outside the displayed photo + if (!is_inside_pixbuf(x, y)) + return true; + + current_tool.on_left_click(x, y); + + // block DnD handlers if tool is enabled + return true; + } + + protected override bool on_left_released(Gdk.EventButton event) { + if (is_pan_in_progress) { + Gdk.Point viewport_center = get_zoom_state().get_viewport_center(); + int delta_x = ((int) event.x) - zoom_pan_start_point.x; + int delta_y = ((int) event.y) - zoom_pan_start_point.y; + viewport_center.x -= delta_x; + viewport_center.y -= delta_y; + + ZoomState zoom_state = ZoomState.pan(get_zoom_state(), viewport_center); + set_zoom_state(zoom_state); + get_zoom_buffer().flush_demand_cache(zoom_state); + + is_pan_in_progress = false; + restore_cursor_hiding(); + } + + // report all releases, as it's possible the user click and dragged from inside the + // pixbuf to the gutters + if (current_tool == null) + return false; + + current_tool.on_left_released((int) event.x, (int) event.y); + + if (current_tool.get_tool_window() != null) + current_tool.get_tool_window().present(); + + return false; + } + + protected override bool on_right_click(Gdk.EventButton event) { + return on_context_buttonpress(event); + } + + private void on_photos_altered(Gee.Map<DataObject, Alteration> map) { + if (!map.has_key(get_photo())) + return; + + pixbuf_dirty = true; + + // if transformed, want to prefetch the original pixbuf for this photo, but after the + // signal is completed as PixbufCache may remove it in this round of fired signals + if (get_photo().has_transformations()) + Idle.add(on_fetch_original); + + update_actions(get_view().get_selected_count(), get_view().get_count()); + } + + private void on_view_contents_ordering_altered() { + refresh_caches("on_view_contents_ordering_altered"); + } + + private bool on_fetch_original() { + if (has_photo()) + master_cache.prefetch(get_photo(), BackgroundJob.JobPriority.LOW); + + return false; + } + + private bool is_panning_possible() { + // panning is impossible if all the content to be drawn completely fits on the drawing + // canvas + Dimensions content_dim = {0}; + content_dim.width = get_zoom_state().get_zoomed_width(); + content_dim.height = get_zoom_state().get_zoomed_height(); + Dimensions canvas_dim = get_surface_dim(); + + return (!(canvas_dim.width >= content_dim.width && canvas_dim.height >= content_dim.height)); + } + + private void update_cursor_for_zoom_context() { + if (is_panning_possible()) + set_page_cursor(Gdk.CursorType.FLEUR); + else + set_page_cursor(Gdk.CursorType.LEFT_PTR); + } + + // Return true to block the DnD handler from activating a drag + protected override bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) { + if (current_tool != null) { + current_tool.on_motion(x, y, mask); + + // this requests more events after "hints" + Gdk.Event.request_motions(event); + + return true; + } + + update_cursor_for_zoom_context(); + + if (is_pan_in_progress) { + int delta_x = ((int) event.x) - zoom_pan_start_point.x; + int delta_y = ((int) event.y) - zoom_pan_start_point.y; + + Gdk.Point viewport_center = get_zoom_state().get_viewport_center(); + viewport_center.x -= delta_x; + viewport_center.y -= delta_y; + + ZoomState zoom_state = ZoomState.pan(get_zoom_state(), viewport_center); + + on_interactive_pan(zoom_state); + return true; + } + + return base.on_motion(event, x, y, mask); + } + + protected override bool on_leave_notify_event() { + if (current_tool != null) + return current_tool.on_leave_notify_event(); + + return base.on_leave_notify_event(); + } + + private void track_tool_window() { + // if editing tool window is present and the user hasn't touched it, it moves with the window + if (current_tool != null) { + EditingTools.EditingToolWindow tool_window = current_tool.get_tool_window(); + if (tool_window != null && !tool_window.has_user_moved()) + place_tool_window(); + } + } + + protected override void on_move(Gdk.Rectangle rect) { + track_tool_window(); + + base.on_move(rect); + } + + protected override void on_move_finished(Gdk.Rectangle rect) { + last_locations.clear(); + + base.on_move_finished(rect); + } + + private bool on_keyboard_pan_event(Gdk.EventKey event) { + ZoomState current_zoom_state = get_zoom_state(); + Gdk.Point viewport_center = current_zoom_state.get_viewport_center(); + + switch (Gdk.keyval_name(event.keyval)) { + case "Left": + case "KP_Left": + case "KP_4": + viewport_center.x -= PAN_INCREMENT_SIZE; + break; + + case "Right": + case "KP_Right": + case "KP_6": + viewport_center.x += PAN_INCREMENT_SIZE; + break; + + case "Down": + case "KP_Down": + case "KP_2": + viewport_center.y += PAN_INCREMENT_SIZE; + break; + + case "Up": + case "KP_Up": + case "KP_8": + viewport_center.y -= PAN_INCREMENT_SIZE; + break; + + default: + return false; + } + + ZoomState new_zoom_state = ZoomState.pan(current_zoom_state, viewport_center); + set_zoom_state(new_zoom_state); + repaint(); + + return true; + } + + public override bool key_press_event(Gdk.EventKey event) { + // editing tool gets first crack at the keypress + if (current_tool != null) { + if (current_tool.on_keypress(event)) + return true; + } + + // if panning is possible, the pan handler (on MUNI?) gets second crack at the keypress + if (is_panning_possible()) { + if (on_keyboard_pan_event(event)) + return true; + } + + // if the user pressed the "0", "1" or "2" keys then handle the event as if were + // directed at the zoom slider ("0", "1" and "2" are hotkeys that jump to preset + // zoom levels + if (on_zoom_slider_key_press(event)) + return true; + + bool handled = true; + + switch (Gdk.keyval_name(event.keyval)) { + // this block is only here to prevent base from moving focus to toolbar + case "Down": + case "KP_Down": + ; + break; + + case "equal": + case "plus": + case "KP_Add": + activate_action("IncreaseSize"); + break; + + // underscore is the keysym generated by SHIFT-[minus sign] -- this means zoom out + case "minus": + case "underscore": + case "KP_Subtract": + activate_action("DecreaseSize"); + break; + + default: + handled = false; + break; + } + + if (handled) + return true; + + return (base.key_press_event != null) ? base.key_press_event(event) : true; + } + + protected override void new_surface(Cairo.Context default_ctx, Dimensions dim) { + // if tool is open, update its canvas object + if (current_tool != null) + current_tool.canvas.set_surface(default_ctx, dim); + } + + protected override void updated_pixbuf(Gdk.Pixbuf pixbuf, SinglePhotoPage.UpdateReason reason, + Dimensions old_dim) { + // only purpose here is to inform editing tool of change and drop the cancelled + // pixbuf, which is now sized incorrectly + if (current_tool != null && reason != SinglePhotoPage.UpdateReason.QUALITY_IMPROVEMENT) { + current_tool.canvas.resized_pixbuf(old_dim, pixbuf, get_scaled_pixbuf_position()); + cancel_editing_pixbuf = null; + } + } + + protected virtual Gdk.Pixbuf? get_bottom_left_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_top_left_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_top_right_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_bottom_right_trinket(int scale) { + return null; + } + + protected override void paint(Cairo.Context ctx, Dimensions ctx_dim) { + if (current_tool != null) { + current_tool.paint(ctx); + + return; + } + + if (photo_missing && has_photo()) { + set_source_color_from_string(ctx, "#000"); + ctx.rectangle(0, 0, get_surface_dim().width, get_surface_dim().height); + ctx.fill(); + ctx.paint(); + draw_message(_("Photo source file missing: %s").printf(get_photo().get_file().get_path())); + return; + } + + base.paint(ctx, ctx_dim); + + if (!get_zoom_state().is_default()) + return; + + // paint trinkets last + Gdk.Rectangle scaled_rect = get_scaled_pixbuf_position(); + + Gdk.Pixbuf? trinket = get_bottom_left_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = scaled_rect.x + TRINKET_PADDING; + int y = scaled_rect.y + scaled_rect.height - trinket.height - TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.width, trinket.height); + ctx.fill(); + } + + trinket = get_top_left_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = scaled_rect.x + TRINKET_PADDING; + int y = scaled_rect.y + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.width, trinket.height); + ctx.fill(); + } + + trinket = get_top_right_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = scaled_rect.x + scaled_rect.width - trinket.width - TRINKET_PADDING; + int y = scaled_rect.y + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.width, trinket.height); + ctx.fill(); + } + + trinket = get_bottom_right_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = scaled_rect.x + scaled_rect.width - trinket.width - TRINKET_PADDING; + int y = scaled_rect.y + scaled_rect.height - trinket.height - TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.width, trinket.height); + ctx.fill(); + } + } + + public bool is_rotate_available(Photo photo) { + return !photo_missing; + } + + private void rotate(Rotation rotation, string name, string description) { + cancel_zoom(); + + deactivate_tool(); + + if (!has_photo()) + return; + + RotateSingleCommand command = new RotateSingleCommand(get_photo(), rotation, name, + description); + get_command_manager().execute(command); + } + + public void on_rotate_clockwise() { + rotate(Rotation.CLOCKWISE, Resources.ROTATE_CW_FULL_LABEL, Resources.ROTATE_CW_TOOLTIP); + } + + public void on_rotate_counterclockwise() { + rotate(Rotation.COUNTERCLOCKWISE, Resources.ROTATE_CCW_FULL_LABEL, Resources.ROTATE_CCW_TOOLTIP); + } + + public void on_flip_horizontally() { + rotate(Rotation.MIRROR, Resources.HFLIP_LABEL, ""); + } + + public void on_flip_vertically() { + rotate(Rotation.UPSIDE_DOWN, Resources.VFLIP_LABEL, ""); + } + + public void on_revert() { + if (photo_missing) + return; + + deactivate_tool(); + + if (!has_photo()) + return; + + if (get_photo().has_editable()) { + if (!revert_editable_dialog(AppWindow.get_instance(), + (Gee.Collection<Photo>) get_view().get_sources())) { + return; + } + + get_photo().revert_to_master(); + } + + cancel_zoom(); + + set_photo_missing(false); + + RevertSingleCommand command = new RevertSingleCommand(get_photo()); + get_command_manager().execute(command); + } + + public void on_edit_title() { + LibraryPhoto item; + if (get_photo() is LibraryPhoto) + item = get_photo() as LibraryPhoto; + else + return; + + EditTitleDialog edit_title_dialog = new EditTitleDialog(item.get_title()); + string? new_title = edit_title_dialog.execute(); + if (new_title == null) + return; + + EditTitleCommand command = new EditTitleCommand(item, new_title); + get_command_manager().execute(command); + } + + public void on_edit_comment() { + LibraryPhoto item; + if (get_photo() is LibraryPhoto) + item = get_photo() as LibraryPhoto; + else + return; + + EditCommentDialog edit_comment_dialog = new EditCommentDialog(item.get_comment()); + string? new_comment = edit_comment_dialog.execute(); + if (new_comment == null) + return; + + EditCommentCommand command = new EditCommentCommand(item, new_comment); + get_command_manager().execute(command); + } + + public void on_adjust_date_time() { + if (!has_photo()) + return; + + AdjustDateTimeDialog dialog = new AdjustDateTimeDialog(get_photo(), 1, !(this is DirectPhotoPage)); + + int64 time_shift; + bool keep_relativity, modify_originals; + if (dialog.execute(out time_shift, out keep_relativity, out modify_originals)) { + get_view().get_selected(); + + AdjustDateTimePhotoCommand command = new AdjustDateTimePhotoCommand(get_photo(), + time_shift, modify_originals); + get_command_manager().execute(command); + } + } + + public void on_set_background() { + if (has_photo()) + DesktopIntegration.set_background(get_photo()); + } + + protected override bool on_ctrl_pressed(Gdk.EventKey? event) { + rotate_button.set_icon_name(Resources.COUNTERCLOCKWISE); + rotate_button.set_label(Resources.ROTATE_CCW_LABEL); + rotate_button.set_tooltip_text(Resources.ROTATE_CCW_TOOLTIP); + rotate_button.clicked.disconnect(on_rotate_clockwise); + rotate_button.clicked.connect(on_rotate_counterclockwise); + + if (current_tool == null) + swap_out_original(); + + return base.on_ctrl_pressed(event); + } + + protected override bool on_ctrl_released(Gdk.EventKey? event) { + rotate_button.set_icon_name(Resources.CLOCKWISE); + rotate_button.set_label(Resources.ROTATE_CW_LABEL); + rotate_button.set_tooltip_text(Resources.ROTATE_CW_TOOLTIP); + rotate_button.clicked.disconnect(on_rotate_counterclockwise); + rotate_button.clicked.connect(on_rotate_clockwise); + + if (current_tool == null && get_shift_pressed() && !get_alt_pressed()) + swap_in_original(); + + return base.on_ctrl_released(event); + } + + protected void on_tool_button_toggled(Gtk.ToggleToolButton toggle, EditingTools.EditingTool.Factory factory) { + // if the button is an activate, deactivate any current tool running; if the button is + // a deactivate, deactivate the current tool and exit + bool deactivating_only = (!toggle.active && current_editing_toggle == toggle); + deactivate_tool(); + + if (deactivating_only) { + restore_cursor_hiding(); + return; + } + + suspend_cursor_hiding(); + + current_editing_toggle = toggle; + + // create the tool, hook its signals, and activate + EditingTools.EditingTool tool = factory(); + tool.activated.connect(on_tool_activated); + tool.deactivated.connect(on_tool_deactivated); + tool.applied.connect(on_tool_applied); + tool.cancelled.connect(on_tool_cancelled); + tool.aborted.connect(on_tool_aborted); + + activate_tool(tool); + } + + private void on_tool_activated() { + assert(current_editing_toggle != null); + zoom_slider.set_sensitive(false); + current_editing_toggle.active = true; + } + + private void on_tool_deactivated() { + assert(current_editing_toggle != null); + zoom_slider.set_sensitive(true); + current_editing_toggle.active = false; + } + + private void on_tool_applied(Command? command, Gdk.Pixbuf? new_pixbuf, Dimensions new_max_dim, + bool needs_improvement) { + deactivate_tool(command, new_pixbuf, new_max_dim, needs_improvement); + } + + private void on_tool_cancelled() { + deactivate_tool(); + + restore_zoom_state(); + repaint(); + } + + private void on_tool_aborted() { + deactivate_tool(); + set_photo_missing(true); + } + + protected void toggle_crop() { + crop_button.set_active(!crop_button.get_active()); + } + + protected void toggle_straighten() { + straighten_button.set_active(!straighten_button.get_active()); + } + + protected void toggle_redeye() { + redeye_button.set_active(!redeye_button.get_active()); + } + + protected void toggle_adjust() { + adjust_button.set_active(!adjust_button.get_active()); + } + + private void on_straighten_toggled() { + on_tool_button_toggled(straighten_button, EditingTools.StraightenTool.factory); + } + + private void on_crop_toggled() { + on_tool_button_toggled(crop_button, EditingTools.CropTool.factory); + } + + private void on_redeye_toggled() { + on_tool_button_toggled(redeye_button, EditingTools.RedeyeTool.factory); + } + + private void on_adjust_toggled() { + on_tool_button_toggled(adjust_button, EditingTools.AdjustTool.factory); + } + + public bool is_enhance_available(Photo photo) { + return !photo_missing; + } + + public void on_enhance() { + // because running multiple tools at once is not currently supported, deactivate any current + // tool; however, there is a special case of running enhancement while the AdjustTool is + // open, so allow for that + if (!(current_tool is EditingTools.AdjustTool)) { + deactivate_tool(); + + cancel_zoom(); + } + + if (!has_photo()) + return; + + EditingTools.AdjustTool adjust_tool = current_tool as EditingTools.AdjustTool; + if (adjust_tool != null) { + adjust_tool.enhance(); + + return; + } + + EnhanceSingleCommand command = new EnhanceSingleCommand(get_photo()); + get_command_manager().execute(command); + } + + public void on_copy_adjustments() { + if (!has_photo()) + return; + PixelTransformationBundle.set_copied_color_adjustments(get_photo().get_color_adjustments()); + set_action_sensitive("PasteColorAdjustments", true); + } + + public void on_paste_adjustments() { + PixelTransformationBundle? copied_adjustments = PixelTransformationBundle.get_copied_color_adjustments(); + if (!has_photo() || copied_adjustments == null) + return; + + AdjustColorsSingleCommand command = new AdjustColorsSingleCommand(get_photo(), copied_adjustments, + Resources.PASTE_ADJUSTMENTS_LABEL, Resources.PASTE_ADJUSTMENTS_TOOLTIP); + get_command_manager().execute(command); + } + + private void place_tool_window() { + if (current_tool == null) + return; + + EditingTools.EditingToolWindow tool_window = current_tool.get_tool_window(); + if (tool_window == null) + return; + + // do this so window size is properly allocated, but window not shown + tool_window.show_all(); + tool_window.hide(); + + Gtk.Allocation tool_alloc; + tool_window.get_allocation(out tool_alloc); + int x, y; + + // Check if the last location of the adjust tool is stored. + if (last_locations.has_key(current_tool.name + "_x")) { + x = last_locations[current_tool.name + "_x"]; + y = last_locations[current_tool.name + "_y"]; + } else { + // No stored position + if (get_container() == AppWindow.get_instance()) { + + // Normal: position crop tool window centered on viewport/canvas at the bottom, + // straddling the canvas and the toolbar + int rx, ry; + get_container().get_window().get_root_origin(out rx, out ry); + + Gtk.Allocation viewport_allocation; + viewport.get_allocation(out viewport_allocation); + + int cx, cy, cwidth, cheight; + cx = viewport_allocation.x; + cy = viewport_allocation.y; + cwidth = viewport_allocation.width; + cheight = viewport_allocation.height; + + // it isn't clear why, but direct mode seems to want to position tool windows + // differently than library mode... + x = (this is DirectPhotoPage) ? (rx + cx + (cwidth / 2) - (tool_alloc.width / 2)) : + (rx + cx + (cwidth / 2)); + y = ry + cy + cheight - ((tool_alloc.height / 4) * 3); + } else { + assert(get_container() is FullscreenWindow); + + // Fullscreen: position crop tool window centered on screen at the bottom, just above the + // toolbar + Gtk.Allocation toolbar_alloc; + get_toolbar().get_allocation(out toolbar_alloc); + + Gdk.Screen screen = get_container().get_screen(); + x = screen.get_width(); + y = screen.get_height() - toolbar_alloc.height - + tool_alloc.height - TOOL_WINDOW_SEPARATOR; + + // put larger adjust tool off to the side + if (current_tool is EditingTools.AdjustTool) { + x = x * 3 / 4; + } else { + x = (x - tool_alloc.width) / 2; + } + } + } + + // however, clamp the window so it's never off-screen initially + Gdk.Screen screen = get_container().get_screen(); + x = x.clamp(0, screen.get_width() - tool_alloc.width); + y = y.clamp(0, screen.get_height() - tool_alloc.height); + + tool_window.move(x, y); + tool_window.show(); + tool_window.present(); + } + + protected override void on_next_photo() { + deactivate_tool(); + + if (!has_photo()) + return; + + Photo? current_photo = get_photo(); + assert(current_photo != null); + + DataView current = get_view().get_view_for_source(get_photo()); + if (current == null) + return; + + // search through the collection until the next photo is found or back at the starting point + DataView? next = current; + for (;;) { + next = get_view().get_next(next); + if (next == null) + break; + + Photo? next_photo = next.get_source() as Photo; + if (next_photo == null) + continue; + + if (next_photo == current_photo) + break; + + replace_photo(next_photo); + + break; + } + } + + protected override void on_previous_photo() { + deactivate_tool(); + + if (!has_photo()) + return; + + Photo? current_photo = get_photo(); + assert(current_photo != null); + + DataView current = get_view().get_view_for_source(get_photo()); + if (current == null) + return; + + // loop until a previous photo is found or back at the starting point + DataView? previous = current; + for (;;) { + previous = get_view().get_previous(previous); + if (previous == null) + break; + + Photo? previous_photo = previous.get_source() as Photo; + if (previous_photo == null) + continue; + + if (previous_photo == current_photo) + break; + + replace_photo(previous_photo); + + break; + } + } + + public bool has_current_tool() { + return (current_tool != null); + } + + protected void unset_view_collection() { + parent_view = null; + } +} + +// +// LibraryPhotoPage +// + +public class LibraryPhotoPage : EditingHostPage { + + private class LibraryPhotoPageViewFilter : ViewFilter { + public override bool predicate (DataView view) { + return !((MediaSource) view.get_source()).is_trashed(); + } + } + + private CollectionPage? return_page = null; + private bool return_to_collection_on_release = false; + private LibraryPhotoPageViewFilter filter = new LibraryPhotoPageViewFilter(); + + public LibraryPhotoPage() { + base(LibraryPhoto.global, "Photo"); + + // monitor view to update UI elements + get_view().items_altered.connect(on_photos_altered); + + // watch for photos being destroyed or altered, either here or in other pages + LibraryPhoto.global.item_destroyed.connect(on_photo_destroyed); + LibraryPhoto.global.items_altered.connect(on_metadata_altered); + + // watch for updates to the external app settings + Config.Facade.get_instance().external_app_changed.connect(on_external_app_changed); + + // Filter out trashed files. + get_view().install_view_filter(filter); + LibraryPhoto.global.items_unlinking.connect(on_photo_unlinking); + LibraryPhoto.global.items_relinked.connect(on_photo_relinked); + } + + ~LibraryPhotoPage() { + LibraryPhoto.global.item_destroyed.disconnect(on_photo_destroyed); + LibraryPhoto.global.items_altered.disconnect(on_metadata_altered); + Config.Facade.get_instance().external_app_changed.disconnect(on_external_app_changed); + } + + public bool not_trashed_view_filter(DataView view) { + return !((MediaSource) view.get_source()).is_trashed(); + } + + private void on_photo_unlinking(Gee.Collection<DataSource> unlinking) { + filter.refresh(); + } + + private void on_photo_relinked(Gee.Collection<DataSource> relinked) { + filter.refresh(); + } + + protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) { + base.init_collect_ui_filenames(ui_filenames); + + ui_filenames.add("photo_context.ui"); + ui_filenames.add("photo.ui"); + } + + protected override Gtk.ActionEntry[] init_collect_action_entries() { + Gtk.ActionEntry[] actions = base.init_collect_action_entries(); + + Gtk.ActionEntry export = { "Export", Gtk.Stock.SAVE_AS, TRANSLATABLE, "<Ctrl><Shift>E", + TRANSLATABLE, on_export }; + export.label = Resources.EXPORT_MENU; + actions += export; + + Gtk.ActionEntry print = { "Print", Gtk.Stock.PRINT, TRANSLATABLE, "<Ctrl>P", + TRANSLATABLE, on_print }; + print.label = Resources.PRINT_MENU; + actions += print; + + Gtk.ActionEntry publish = { "Publish", Resources.PUBLISH, TRANSLATABLE, "<Ctrl><Shift>P", + TRANSLATABLE, on_publish }; + publish.label = Resources.PUBLISH_MENU; + publish.tooltip = Resources.PUBLISH_TOOLTIP; + actions += publish; + + Gtk.ActionEntry remove_from_library = { "RemoveFromLibrary", Gtk.Stock.REMOVE, TRANSLATABLE, + "<Shift>Delete", TRANSLATABLE, on_remove_from_library }; + remove_from_library.label = Resources.REMOVE_FROM_LIBRARY_MENU; + actions += remove_from_library; + + Gtk.ActionEntry move_to_trash = { "MoveToTrash", "user-trash-full", TRANSLATABLE, "Delete", + TRANSLATABLE, on_move_to_trash }; + move_to_trash.label = Resources.MOVE_TO_TRASH_MENU; + actions += move_to_trash; + + Gtk.ActionEntry view = { "ViewMenu", null, TRANSLATABLE, null, null, on_view_menu }; + view.label = _("_View"); + actions += view; + + Gtk.ActionEntry tools = { "Tools", null, TRANSLATABLE, null, null, null }; + tools.label = _("T_ools"); + actions += tools; + + Gtk.ActionEntry prev = { "PrevPhoto", Gtk.Stock.GO_BACK, TRANSLATABLE, null, + TRANSLATABLE, on_previous_photo }; + prev.label = _("_Previous Photo"); + prev.tooltip = _("Previous Photo"); + actions += prev; + + Gtk.ActionEntry next = { "NextPhoto", Gtk.Stock.GO_FORWARD, TRANSLATABLE, null, + TRANSLATABLE, on_next_photo }; + next.label = _("_Next Photo"); + next.tooltip = _("Next Photo"); + actions += next; + + Gtk.ActionEntry rotate_right = { "RotateClockwise", Resources.CLOCKWISE, TRANSLATABLE, + "<Ctrl>R", TRANSLATABLE, on_rotate_clockwise }; + rotate_right.label = Resources.ROTATE_CW_MENU; + rotate_right.tooltip = Resources.ROTATE_CW_TOOLTIP; + actions += rotate_right; + + Gtk.ActionEntry rotate_left = { "RotateCounterclockwise", Resources.COUNTERCLOCKWISE, + TRANSLATABLE, "<Ctrl><Shift>R", TRANSLATABLE, on_rotate_counterclockwise }; + rotate_left.label = Resources.ROTATE_CCW_MENU; + rotate_left.tooltip = Resources.ROTATE_CCW_TOOLTIP; + actions += rotate_left; + + Gtk.ActionEntry hflip = { "FlipHorizontally", Resources.HFLIP, TRANSLATABLE, null, + TRANSLATABLE, on_flip_horizontally }; + hflip.label = Resources.HFLIP_MENU; + actions += hflip; + + Gtk.ActionEntry vflip = { "FlipVertically", Resources.VFLIP, TRANSLATABLE, null, + TRANSLATABLE, on_flip_vertically }; + vflip.label = Resources.VFLIP_MENU; + actions += vflip; + + Gtk.ActionEntry enhance = { "Enhance", Resources.ENHANCE, TRANSLATABLE, "<Ctrl>E", + TRANSLATABLE, on_enhance }; + enhance.label = Resources.ENHANCE_MENU; + enhance.tooltip = Resources.ENHANCE_TOOLTIP; + actions += enhance; + + Gtk.ActionEntry copy_adjustments = { "CopyColorAdjustments", null, TRANSLATABLE, + "<Ctrl><Shift>C", TRANSLATABLE, on_copy_adjustments}; + copy_adjustments.label = Resources.COPY_ADJUSTMENTS_MENU; + copy_adjustments.tooltip = Resources.COPY_ADJUSTMENTS_TOOLTIP; + actions += copy_adjustments; + + Gtk.ActionEntry paste_adjustments = { "PasteColorAdjustments", null, TRANSLATABLE, + "<Ctrl><Shift>V", TRANSLATABLE, on_paste_adjustments}; + paste_adjustments.label = Resources.PASTE_ADJUSTMENTS_MENU; + paste_adjustments.tooltip = Resources.PASTE_ADJUSTMENTS_TOOLTIP; + actions += paste_adjustments; + + Gtk.ActionEntry crop = { "Crop", Resources.CROP, TRANSLATABLE, "<Ctrl>O", + TRANSLATABLE, toggle_crop }; + crop.label = Resources.CROP_MENU; + crop.tooltip = Resources.CROP_TOOLTIP; + actions += crop; + + Gtk.ActionEntry straighten = { "Straighten", Gtk.Stock.REFRESH, TRANSLATABLE, "<Ctrl>A", + TRANSLATABLE, toggle_straighten }; + straighten.label = Resources.STRAIGHTEN_MENU; + straighten.tooltip = Resources.STRAIGHTEN_TOOLTIP; + actions += straighten; + + Gtk.ActionEntry red_eye = { "RedEye", Resources.REDEYE, TRANSLATABLE, "<Ctrl>Y", + TRANSLATABLE, toggle_redeye }; + red_eye.label = Resources.RED_EYE_MENU; + red_eye.tooltip = Resources.RED_EYE_TOOLTIP; + actions += red_eye; + + Gtk.ActionEntry adjust = { "Adjust", Resources.ADJUST, TRANSLATABLE, "<Ctrl>D", + TRANSLATABLE, toggle_adjust }; + adjust.label = Resources.ADJUST_MENU; + adjust.tooltip = Resources.ADJUST_TOOLTIP; + actions += adjust; + + Gtk.ActionEntry revert = { "Revert", Gtk.Stock.REVERT_TO_SAVED, TRANSLATABLE, + null, TRANSLATABLE, on_revert }; + revert.label = Resources.REVERT_MENU; + actions += revert; + + Gtk.ActionEntry edit_title = { "EditTitle", null, TRANSLATABLE, "F2", TRANSLATABLE, + on_edit_title }; + edit_title.label = Resources.EDIT_TITLE_MENU; + actions += edit_title; + + Gtk.ActionEntry edit_comment = { "EditComment", null, TRANSLATABLE, "F3", TRANSLATABLE, + on_edit_comment }; + edit_comment.label = Resources.EDIT_COMMENT_MENU; + actions += edit_comment; + + Gtk.ActionEntry adjust_date_time = { "AdjustDateTime", null, TRANSLATABLE, null, + TRANSLATABLE, on_adjust_date_time }; + adjust_date_time.label = Resources.ADJUST_DATE_TIME_MENU; + actions += adjust_date_time; + + Gtk.ActionEntry external_edit = { "ExternalEdit", Gtk.Stock.EDIT, TRANSLATABLE, + "<Ctrl>Return", TRANSLATABLE, on_external_edit }; + external_edit.label = Resources.EXTERNAL_EDIT_MENU; + actions += external_edit; + + Gtk.ActionEntry edit_raw = { "ExternalEditRAW", null, TRANSLATABLE, "<Ctrl><Shift>Return", + TRANSLATABLE, on_external_edit_raw }; + edit_raw.label = Resources.EXTERNAL_EDIT_RAW_MENU; + actions += edit_raw; + + Gtk.ActionEntry send_to = { "SendTo", "document-send", TRANSLATABLE, null, + TRANSLATABLE, on_send_to }; + send_to.label = Resources.SEND_TO_MENU; + actions += send_to; + + Gtk.ActionEntry set_background = { "SetBackground", null, TRANSLATABLE, "<Ctrl>B", + TRANSLATABLE, on_set_background }; + set_background.label = Resources.SET_BACKGROUND_MENU; + set_background.tooltip = Resources.SET_BACKGROUND_TOOLTIP; + actions += set_background; + + Gtk.ActionEntry flag = { "Flag", null, TRANSLATABLE, "<Ctrl>G", TRANSLATABLE, on_flag_unflag }; + flag.label = Resources.FLAG_MENU; + actions += flag; + + Gtk.ActionEntry set_rating = { "Rate", null, TRANSLATABLE, null, null, null }; + set_rating.label = Resources.RATING_MENU; + actions += set_rating; + + Gtk.ActionEntry increase_rating = { "IncreaseRating", null, TRANSLATABLE, + "greater", TRANSLATABLE, on_increase_rating }; + increase_rating.label = Resources.INCREASE_RATING_MENU; + actions += increase_rating; + + Gtk.ActionEntry decrease_rating = { "DecreaseRating", null, TRANSLATABLE, + "less", TRANSLATABLE, on_decrease_rating }; + decrease_rating.label = Resources.DECREASE_RATING_MENU; + actions += decrease_rating; + + Gtk.ActionEntry rate_rejected = { "RateRejected", null, TRANSLATABLE, + "9", TRANSLATABLE, on_rate_rejected }; + rate_rejected.label = Resources.rating_menu(Rating.REJECTED); + actions += rate_rejected; + + Gtk.ActionEntry rate_unrated = { "RateUnrated", null, TRANSLATABLE, + "0", TRANSLATABLE, on_rate_unrated }; + rate_unrated.label = Resources.rating_menu(Rating.UNRATED); + actions += rate_unrated; + + Gtk.ActionEntry rate_one = { "RateOne", null, TRANSLATABLE, + "1", TRANSLATABLE, on_rate_one }; + rate_one.label = Resources.rating_menu(Rating.ONE); + actions += rate_one; + + Gtk.ActionEntry rate_two = { "RateTwo", null, TRANSLATABLE, + "2", TRANSLATABLE, on_rate_two }; + rate_two.label = Resources.rating_menu(Rating.TWO); + actions += rate_two; + + Gtk.ActionEntry rate_three = { "RateThree", null, TRANSLATABLE, + "3", TRANSLATABLE, on_rate_three }; + rate_three.label = Resources.rating_menu(Rating.THREE); + actions += rate_three; + + Gtk.ActionEntry rate_four = { "RateFour", null, TRANSLATABLE, + "4", TRANSLATABLE, on_rate_four }; + rate_four.label = Resources.rating_menu(Rating.FOUR); + actions += rate_four; + + Gtk.ActionEntry rate_five = { "RateFive", null, TRANSLATABLE, + "5", TRANSLATABLE, on_rate_five }; + rate_five.label = Resources.rating_menu(Rating.FIVE); + actions += rate_five; + + Gtk.ActionEntry increase_size = { "IncreaseSize", Gtk.Stock.ZOOM_IN, TRANSLATABLE, + "<Ctrl>plus", TRANSLATABLE, on_increase_size }; + increase_size.label = _("Zoom _In"); + increase_size.tooltip = _("Increase the magnification of the photo"); + actions += increase_size; + + Gtk.ActionEntry decrease_size = { "DecreaseSize", Gtk.Stock.ZOOM_OUT, TRANSLATABLE, + "<Ctrl>minus", TRANSLATABLE, on_decrease_size }; + decrease_size.label = _("Zoom _Out"); + decrease_size.tooltip = _("Decrease the magnification of the photo"); + actions += decrease_size; + + Gtk.ActionEntry best_fit = { "ZoomFit", Gtk.Stock.ZOOM_FIT, TRANSLATABLE, + "<Ctrl>0", TRANSLATABLE, snap_zoom_to_min }; + best_fit.label = _("Fit to _Page"); + best_fit.tooltip = _("Zoom the photo to fit on the screen"); + actions += best_fit; + + Gtk.ActionEntry actual_size = { "Zoom100", Gtk.Stock.ZOOM_100, TRANSLATABLE, + "<Ctrl>1", TRANSLATABLE, snap_zoom_to_isomorphic }; + /// xgettext:no-c-format + actual_size.label = _("Zoom _100%"); + /// xgettext:no-c-format + actual_size.tooltip = _("Zoom the photo to 100% magnification"); + actions += actual_size; + + Gtk.ActionEntry max_size = { "Zoom200", null, TRANSLATABLE, + "<Ctrl>2", TRANSLATABLE, snap_zoom_to_max }; + /// xgettext:no-c-format + max_size.label = _("Zoom _200%"); + /// xgettext:no-c-format + max_size.tooltip = _("Zoom the photo to 200% magnification"); + actions += max_size; + + Gtk.ActionEntry add_tags = { "AddTags", null, TRANSLATABLE, "<Ctrl>T", TRANSLATABLE, + on_add_tags }; + add_tags.label = Resources.ADD_TAGS_MENU; + actions += add_tags; + + Gtk.ActionEntry modify_tags = { "ModifyTags", null, TRANSLATABLE, "<Ctrl>M", TRANSLATABLE, + on_modify_tags }; + modify_tags.label = Resources.MODIFY_TAGS_MENU; + actions += modify_tags; + + Gtk.ActionEntry slideshow = { "Slideshow", null, TRANSLATABLE, "F5", TRANSLATABLE, + on_slideshow }; + slideshow.label = _("S_lideshow"); + slideshow.tooltip = _("Play a slideshow"); + actions += slideshow; + + Gtk.ActionEntry raw_developer = { "RawDeveloper", null, TRANSLATABLE, null, null, null }; + raw_developer.label = _("_Developer"); + actions += raw_developer; + + // These are identical to add_tags and send_to, except that they have + // different mnemonics and are _only_ for use in the context menu. + Gtk.ActionEntry send_to_context_menu = { "SendToContextMenu", "document-send", TRANSLATABLE, null, + TRANSLATABLE, on_send_to }; + send_to_context_menu.label = Resources.SEND_TO_CONTEXT_MENU; + actions += send_to_context_menu; + + Gtk.ActionEntry add_tags_context_menu = { "AddTagsContextMenu", null, TRANSLATABLE, "<Ctrl>A", TRANSLATABLE, + on_add_tags }; + add_tags_context_menu.label = Resources.ADD_TAGS_CONTEXT_MENU; + actions += add_tags_context_menu; + + return actions; + } + + protected override Gtk.ToggleActionEntry[] init_collect_toggle_action_entries() { + Gtk.ToggleActionEntry[] toggle_actions = base.init_collect_toggle_action_entries(); + + Gtk.ToggleActionEntry ratings = { "ViewRatings", null, TRANSLATABLE, "<Ctrl><Shift>N", + TRANSLATABLE, on_display_ratings, Config.Facade.get_instance().get_display_photo_ratings() }; + ratings.label = Resources.VIEW_RATINGS_MENU; + ratings.tooltip = Resources.VIEW_RATINGS_TOOLTIP; + toggle_actions += ratings; + + return toggle_actions; + } + + protected override InjectionGroup[] init_collect_injection_groups() { + InjectionGroup[] groups = base.init_collect_injection_groups(); + + InjectionGroup print_group = new InjectionGroup("/MenuBar/FileMenu/PrintPlaceholder"); + print_group.add_menu_item("Print"); + + groups += print_group; + + InjectionGroup publish_group = new InjectionGroup("/MenuBar/FileMenu/PublishPlaceholder"); + publish_group.add_menu_item("Publish"); + + groups += publish_group; + + InjectionGroup bg_group = new InjectionGroup("/MenuBar/FileMenu/SetBackgroundPlaceholder"); + bg_group.add_menu_item("SetBackground"); + + groups += bg_group; + + return groups; + } + + protected override void register_radio_actions(Gtk.ActionGroup action_group) { + // RAW developer. + //get_config_photos_sort(out sort_order, out sort_by); // TODO: fetch default from config + + Gtk.RadioActionEntry[] developer_actions = new Gtk.RadioActionEntry[0]; + + Gtk.RadioActionEntry dev_shotwell = { "RawDeveloperShotwell", null, TRANSLATABLE, null, TRANSLATABLE, + RawDeveloper.SHOTWELL }; + string label_shotwell = RawDeveloper.SHOTWELL.get_label(); + dev_shotwell.label = label_shotwell; + developer_actions += dev_shotwell; + + Gtk.RadioActionEntry dev_camera = { "RawDeveloperCamera", null, TRANSLATABLE, null, TRANSLATABLE, + RawDeveloper.CAMERA }; + string label_camera = RawDeveloper.CAMERA.get_label(); + dev_camera.label = label_camera; + developer_actions += dev_camera; + + action_group.add_radio_actions(developer_actions, RawDeveloper.SHOTWELL, on_raw_developer_changed); + + base.register_radio_actions(action_group); + } + + private void on_display_ratings(Gtk.Action action) { + bool display = ((Gtk.ToggleAction) action).get_active(); + + set_display_ratings(display); + + Config.Facade.get_instance().set_display_photo_ratings(display); + repaint(); + } + + private void set_display_ratings(bool display) { + Gtk.ToggleAction? action = get_action("ViewRatings") as Gtk.ToggleAction; + if (action != null) + action.set_active(display); + } + + protected override void update_actions(int selected_count, int count) { + bool multiple = get_view().get_count() > 1; + bool rotate_possible = has_photo() ? is_rotate_available(get_photo()) : false; + bool is_raw = has_photo() && get_photo().get_master_file_format() == PhotoFileFormat.RAW; + + set_action_sensitive("ExternalEdit", + has_photo() && Config.Facade.get_instance().get_external_photo_app() != ""); + + set_action_sensitive("Revert", has_photo() ? + (get_photo().has_transformations() || get_photo().has_editable()) : false); + + if (has_photo() && !get_photo_missing()) { + update_rating_menu_item_sensitivity(); + update_development_menu_item_sensitivity(); + } + + set_action_sensitive("SetBackground", has_photo()); + + set_action_sensitive("CopyColorAdjustments", (has_photo() && get_photo().has_color_adjustments())); + set_action_sensitive("PasteColorAdjustments", PixelTransformationBundle.has_copied_color_adjustments()); + + set_action_sensitive("PrevPhoto", multiple); + set_action_sensitive("NextPhoto", multiple); + set_action_sensitive("RotateClockwise", rotate_possible); + set_action_sensitive("RotateCounterclockwise", rotate_possible); + set_action_sensitive("FlipHorizontally", rotate_possible); + set_action_sensitive("FlipVertically", rotate_possible); + + if (has_photo()) { + set_action_sensitive("Crop", EditingTools.CropTool.is_available(get_photo(), Scaling.for_original())); + set_action_sensitive("RedEye", EditingTools.RedeyeTool.is_available(get_photo(), + Scaling.for_original())); + } + + update_flag_action(); + + set_action_visible("ExternalEditRAW", + is_raw && Config.Facade.get_instance().get_external_raw_app() != ""); + + base.update_actions(selected_count, count); + } + + private void on_photos_altered() { + set_action_sensitive("Revert", has_photo() ? + (get_photo().has_transformations() || get_photo().has_editable()) : false); + update_flag_action(); + } + + private void on_raw_developer_changed(Gtk.Action action, Gtk.Action current) { + developer_changed((RawDeveloper) ((Gtk.RadioAction) current).get_current_value()); + } + + protected virtual void developer_changed(RawDeveloper rd) { + if (get_view().get_selected_count() != 1) + return; + + Photo? photo = get_view().get_selected().get(0).get_source() as Photo; + if (photo == null || rd.is_equivalent(photo.get_raw_developer())) + return; + + // Check if any photo has edits + // Display warning only when edits could be destroyed + if (!photo.has_transformations() || Dialogs.confirm_warn_developer_changed(1)) { + SetRawDeveloperCommand command = new SetRawDeveloperCommand(get_view().get_selected(), + rd); + get_command_manager().execute(command); + + update_development_menu_item_sensitivity(); + } + } + + private void update_flag_action() { + if (has_photo()) { + Gtk.Action? action = get_action("Flag"); + assert(action != null); + + bool is_flagged = ((LibraryPhoto) get_photo()).is_flagged(); + + action.label = is_flagged ? Resources.UNFLAG_MENU : Resources.FLAG_MENU; + action.sensitive = true; + } else { + set_action_sensitive("Flag", false); + } + } + + // Displays a photo from a specific CollectionPage. When the user exits this view, + // they will be sent back to the return_page. The optional view paramters is for using + // a ViewCollection other than the one inside return_page; this is necessary if the + // view and return_page have different filters. + public void display_for_collection(CollectionPage return_page, Photo photo, + ViewCollection? view = null) { + this.return_page = return_page; + return_page.destroy.connect(on_page_destroyed); + + display_copy_of(view != null ? view : return_page.get_view(), photo); + } + + public void on_page_destroyed() { + // The parent page was removed, so drop the reference to the page and + // its view collection. + return_page = null; + unset_view_collection(); + } + + public CollectionPage? get_controller_page() { + return return_page; + } + + public override void switched_to() { + // since LibraryPhotoPages often rest in the background, their stored photo can be deleted by + // another page. this checks to make sure a display photo has been established before the + // switched_to call. + assert(get_photo() != null); + + base.switched_to(); + + update_zoom_menu_item_sensitivity(); + update_rating_menu_item_sensitivity(); + + set_display_ratings(Config.Facade.get_instance().get_display_photo_ratings()); + } + + protected override Gdk.Pixbuf? get_bottom_left_trinket(int scale) { + if (!has_photo() || !Config.Facade.get_instance().get_display_photo_ratings()) + return null; + + return Resources.get_rating_trinket(((LibraryPhoto) get_photo()).get_rating(), scale); + } + + protected override Gdk.Pixbuf? get_top_right_trinket(int scale) { + if (!has_photo() || !((LibraryPhoto) get_photo()).is_flagged()) + return null; + + return Resources.get_icon(Resources.ICON_FLAGGED_TRINKET); + } + + private void on_slideshow() { + LibraryPhoto? photo = (LibraryPhoto?) get_photo(); + if (photo == null) + return; + + AppWindow.get_instance().go_fullscreen(new SlideshowPage(LibraryPhoto.global, get_view(), + photo)); + } + + private void update_zoom_menu_item_sensitivity() { + set_action_sensitive("IncreaseSize", !get_zoom_state().is_max() && !get_photo_missing()); + set_action_sensitive("DecreaseSize", !get_zoom_state().is_default() && !get_photo_missing()); + } + + protected override void on_increase_size() { + base.on_increase_size(); + + update_zoom_menu_item_sensitivity(); + } + + protected override void on_decrease_size() { + base.on_decrease_size(); + + update_zoom_menu_item_sensitivity(); + } + + protected override bool on_zoom_slider_key_press(Gdk.EventKey event) { + if (base.on_zoom_slider_key_press(event)) + return true; + + if (Gdk.keyval_name(event.keyval) == "Escape") { + return_to_collection(); + return true; + } else { + return false; + } + } + + protected override void update_ui(bool missing) { + bool sensitivity = !missing; + + set_action_sensitive("SendTo", sensitivity); + set_action_sensitive("Publish", sensitivity); + set_action_sensitive("Print", sensitivity); + set_action_sensitive("CommonJumpToFile", sensitivity); + + set_action_sensitive("CommonUndo", sensitivity); + set_action_sensitive("CommonRedo", sensitivity); + + set_action_sensitive("IncreaseSize", sensitivity); + set_action_sensitive("DecreaseSize", sensitivity); + set_action_sensitive("ZoomFit", sensitivity); + set_action_sensitive("Zoom100", sensitivity); + set_action_sensitive("Zoom200", sensitivity); + set_action_sensitive("Slideshow", sensitivity); + + set_action_sensitive("RotateClockwise", sensitivity); + set_action_sensitive("RotateCounterclockwise", sensitivity); + set_action_sensitive("FlipHorizontally", sensitivity); + set_action_sensitive("FlipVertically", sensitivity); + set_action_sensitive("Enhance", sensitivity); + set_action_sensitive("Crop", sensitivity); + set_action_sensitive("RedEye", sensitivity); + set_action_sensitive("Adjust", sensitivity); + set_action_sensitive("EditTitle", sensitivity); + set_action_sensitive("AdjustDateTime", sensitivity); + set_action_sensitive("ExternalEdit", sensitivity); + set_action_sensitive("ExternalEditRAW", sensitivity); + set_action_sensitive("Revert", sensitivity); + + set_action_sensitive("Rate", sensitivity); + set_action_sensitive("Flag", sensitivity); + set_action_sensitive("AddTags", sensitivity); + set_action_sensitive("ModifyTags", sensitivity); + + set_action_sensitive("SetBackground", sensitivity); + + base.update_ui(missing); + } + + protected override void notify_photo_backing_missing(Photo photo, bool missing) { + if (missing) + ((LibraryPhoto) photo).mark_offline(); + else + ((LibraryPhoto) photo).mark_online(); + + base.notify_photo_backing_missing(photo, missing); + } + + public override bool key_press_event(Gdk.EventKey event) { + if (base.key_press_event != null && base.key_press_event(event) == true) + return true; + + bool handled = true; + switch (Gdk.keyval_name(event.keyval)) { + case "Escape": + case "Return": + case "KP_Enter": + if (!(get_container() is FullscreenWindow)) + return_to_collection(); + break; + + case "Delete": + // although bound as an accelerator in the menu, accelerators are currently + // unavailable in fullscreen mode (a variant of #324), so we do this manually + // here + activate_action("MoveToTrash"); + break; + + case "period": + case "greater": + activate_action("IncreaseRating"); + break; + + case "comma": + case "less": + activate_action("DecreaseRating"); + break; + + case "KP_1": + activate_action("RateOne"); + break; + + case "KP_2": + activate_action("RateTwo"); + break; + + case "KP_3": + activate_action("RateThree"); + break; + + case "KP_4": + activate_action("RateFour"); + break; + + case "KP_5": + activate_action("RateFive"); + break; + + case "KP_0": + activate_action("RateUnrated"); + break; + + case "KP_9": + activate_action("RateRejected"); + break; + + case "bracketright": + activate_action("RotateClockwise"); + break; + + case "bracketleft": + activate_action("RotateCounterclockwise"); + break; + + case "slash": + activate_action("Flag"); + break; + + default: + handled = false; + break; + } + + return handled; + } + + protected override bool on_double_click(Gdk.EventButton event) { + if (!(get_container() is FullscreenWindow)) { + return_to_collection_on_release = true; + + return true; + } + + AppWindow.get_instance().end_fullscreen(); + + return base.on_double_click(event); + } + + protected override bool on_left_released(Gdk.EventButton event) { + if (return_to_collection_on_release) { + return_to_collection_on_release = false; + return_to_collection(); + + return true; + } + + return base.on_left_released(event); + } + + private Gtk.Menu get_context_menu() { + Gtk.Menu menu = (Gtk.Menu) ui.get_widget("/PhotoContextMenu"); + assert(menu != null); + return menu; + } + + protected override bool on_context_buttonpress(Gdk.EventButton event) { + popup_context_menu(get_context_menu(), event); + + return true; + } + + protected override bool on_context_keypress() { + popup_context_menu(get_context_menu()); + + return true; + } + + private void return_to_collection() { + // Return to the previous page if it exists. + if (null != return_page) + LibraryWindow.get_app().switch_to_page(return_page); + else + LibraryWindow.get_app().switch_to_library_page(); + } + + private void on_remove_from_library() { + LibraryPhoto photo = (LibraryPhoto) get_photo(); + + Gee.Collection<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>(); + photos.add(photo); + + remove_from_app(photos, _("Remove From Library"), _("Removing Photo From Library")); + } + + private void on_move_to_trash() { + if (!has_photo()) + return; + + // Temporarily prevent the application from switching pages if we're viewing + // the current photo from within an Event page. This is needed because the act of + // trashing images from an Event causes it to be renamed, which causes it to change + // positions in the sidebar, and the selection moves with it, causing the app to + // inappropriately switch to the Event page. + if (return_page is EventPage) { + LibraryWindow.get_app().set_page_switching_enabled(false); + } + + LibraryPhoto photo = (LibraryPhoto) get_photo(); + + Gee.Collection<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>(); + photos.add(photo); + + // move on to next photo before executing + on_next_photo(); + + // this indicates there is only one photo in the controller, or about to be zero, so switch + // to the library page, which is guaranteed to be there when this disappears + if (photo.equals(get_photo())) { + // If this is the last photo in an Event, then trashing it + // _should_ cause us to switch pages, so re-enable it here. + LibraryWindow.get_app().set_page_switching_enabled(true); + + if (get_container() is FullscreenWindow) + ((FullscreenWindow) get_container()).close(); + + LibraryWindow.get_app().switch_to_library_page(); + } + + get_command_manager().execute(new TrashUntrashPhotosCommand(photos, true)); + LibraryWindow.get_app().set_page_switching_enabled(true); + } + + private void on_flag_unflag() { + if (has_photo()) { + Gee.ArrayList<DataSource> photo_list = new Gee.ArrayList<DataSource>(); + photo_list.add(get_photo()); + get_command_manager().execute(new FlagUnflagCommand(photo_list, + !((LibraryPhoto) get_photo()).is_flagged())); + } + } + + private void on_photo_destroyed(DataSource source) { + on_photo_removed((LibraryPhoto) source); + } + + private void on_photo_removed(LibraryPhoto photo) { + // only interested in current photo + if (photo == null || !photo.equals(get_photo())) + return; + + // move on to the next one in the collection + on_next_photo(); + if (photo.equals(get_photo())) { + // this indicates there is only one photo in the controller, or now zero, so switch + // to the Photos page, which is guaranteed to be there + LibraryWindow.get_app().switch_to_library_page(); + } + } + + private void on_print() { + if (get_view().get_selected_count() > 0) { + PrintManager.get_instance().spool_photo( + (Gee.Collection<Photo>) get_view().get_selected_sources_of_type(typeof(Photo))); + } + } + + private void on_external_app_changed() { + set_action_sensitive("ExternalEdit", has_photo() && + Config.Facade.get_instance().get_external_photo_app() != ""); + } + + private void on_external_edit() { + if (!has_photo()) + return; + + try { + AppWindow.get_instance().set_busy_cursor(); + get_photo().open_with_external_editor(); + AppWindow.get_instance().set_normal_cursor(); + } catch (Error err) { + AppWindow.get_instance().set_normal_cursor(); + open_external_editor_error_dialog(err, get_photo()); + } + + } + + private void on_external_edit_raw() { + if (!has_photo()) + return; + + if (get_photo().get_master_file_format() != PhotoFileFormat.RAW) + return; + + try { + AppWindow.get_instance().set_busy_cursor(); + get_photo().open_with_raw_external_editor(); + AppWindow.get_instance().set_normal_cursor(); + } catch (Error err) { + AppWindow.get_instance().set_normal_cursor(); + AppWindow.error_message(Resources.launch_editor_failed(err)); + } + } + + private void on_send_to() { + if (has_photo()) + DesktopIntegration.send_to((Gee.Collection<Photo>) get_view().get_selected_sources()); + } + + private void on_export() { + if (!has_photo()) + return; + + ExportDialog export_dialog = new ExportDialog(_("Export Photo")); + + int scale; + ScaleConstraint constraint; + ExportFormatParameters export_params = ExportFormatParameters.last(); + if (!export_dialog.execute(out scale, out constraint, ref export_params)) + return; + + File save_as = + ExportUI.choose_file(get_photo().get_export_basename_for_parameters(export_params)); + if (save_as == null) + return; + + Scaling scaling = Scaling.for_constraint(constraint, scale, false); + + try { + get_photo().export(save_as, scaling, export_params.quality, + get_photo().get_export_format_for_parameters(export_params), + export_params.mode == ExportFormatMode.UNMODIFIED, export_params.export_metadata); + } catch (Error err) { + AppWindow.error_message(_("Unable to export %s: %s").printf(save_as.get_path(), err.message)); + } + } + + private void on_publish() { + if (get_view().get_count() > 0) + PublishingUI.PublishingDialog.go( + (Gee.Collection<MediaSource>) get_view().get_selected_sources()); + } + + private void on_view_menu() { + update_zoom_menu_item_sensitivity(); + } + + private void on_increase_rating() { + if (!has_photo() || get_photo_missing()) + return; + + SetRatingSingleCommand command = new SetRatingSingleCommand.inc_dec(get_photo(), true); + get_command_manager().execute(command); + + update_rating_menu_item_sensitivity(); + } + + private void on_decrease_rating() { + if (!has_photo() || get_photo_missing()) + return; + + SetRatingSingleCommand command = new SetRatingSingleCommand.inc_dec(get_photo(), false); + get_command_manager().execute(command); + + update_rating_menu_item_sensitivity(); + } + + private void on_set_rating(Rating rating) { + if (!has_photo() || get_photo_missing()) + return; + + SetRatingSingleCommand command = new SetRatingSingleCommand(get_photo(), rating); + get_command_manager().execute(command); + + update_rating_menu_item_sensitivity(); + } + + private void on_rate_rejected() { + on_set_rating(Rating.REJECTED); + } + + private void on_rate_unrated() { + on_set_rating(Rating.UNRATED); + } + + private void on_rate_one() { + on_set_rating(Rating.ONE); + } + + private void on_rate_two() { + on_set_rating(Rating.TWO); + } + + private void on_rate_three() { + on_set_rating(Rating.THREE); + } + + private void on_rate_four() { + on_set_rating(Rating.FOUR); + } + + private void on_rate_five() { + on_set_rating(Rating.FIVE); + } + + private void update_rating_menu_item_sensitivity() { + set_action_sensitive("RateRejected", get_photo().get_rating() != Rating.REJECTED); + set_action_sensitive("RateUnrated", get_photo().get_rating() != Rating.UNRATED); + set_action_sensitive("RateOne", get_photo().get_rating() != Rating.ONE); + set_action_sensitive("RateTwo", get_photo().get_rating() != Rating.TWO); + set_action_sensitive("RateThree", get_photo().get_rating() != Rating.THREE); + set_action_sensitive("RateFour", get_photo().get_rating() != Rating.FOUR); + set_action_sensitive("RateFive", get_photo().get_rating() != Rating.FIVE); + set_action_sensitive("IncreaseRating", get_photo().get_rating().can_increase()); + set_action_sensitive("DecreaseRating", get_photo().get_rating().can_decrease()); + } + + private void update_development_menu_item_sensitivity() { + PhotoFileFormat format = get_photo().get_master_file_format() ; + set_action_sensitive("RawDeveloper", format == PhotoFileFormat.RAW); + + if (format == PhotoFileFormat.RAW) { + // Set which developers are available. + set_action_sensitive("RawDeveloperShotwell", + get_photo().is_raw_developer_available(RawDeveloper.SHOTWELL)); + set_action_sensitive("RawDeveloperCamera", + get_photo().is_raw_developer_available(RawDeveloper.EMBEDDED) || + get_photo().is_raw_developer_available(RawDeveloper.CAMERA));; + + // Set active developer in menu. + switch (get_photo().get_raw_developer()) { + case RawDeveloper.SHOTWELL: + activate_action("RawDeveloperShotwell"); + break; + + case RawDeveloper.CAMERA: + case RawDeveloper.EMBEDDED: + activate_action("RawDeveloperCamera"); + break; + + default: + assert_not_reached(); + } + } + } + + private void on_metadata_altered(Gee.Map<DataObject, Alteration> map) { + if (map.has_key(get_photo()) && map.get(get_photo()).has_subject("metadata")) + repaint(); + } + + private void on_add_tags() { + AddTagsDialog dialog = new AddTagsDialog(); + string[]? names = dialog.execute(); + if (names != null) { + get_command_manager().execute(new AddTagsCommand( + HierarchicalTagIndex.get_global_index().get_paths_for_names_array(names), + (Gee.Collection<LibraryPhoto>) get_view().get_selected_sources())); + } + } + + private void on_modify_tags() { + LibraryPhoto photo = (LibraryPhoto) get_view().get_selected_at(0).get_source(); + + ModifyTagsDialog dialog = new ModifyTagsDialog(photo); + Gee.ArrayList<Tag>? new_tags = dialog.execute(); + + if (new_tags == null) + return; + + get_command_manager().execute(new ModifyTagsCommand(photo, new_tags)); + } + +} + |