/* Copyright 2016 Software Freedom Conservancy Inc. * * This software is licensed under the GNU LGPL (version 2.1 or later). * See the COPYING file in this distribution. */ public 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 unowned 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.parent_page.add_weak_pointer(&this.parent_page); this.preview_image = preview_image; this.backing_photo = backing_photo; this.workers = new Workers(2, false); } ~ZoomBuffer() { if (this.parent_page != null) { this.parent_page.remove_weak_pointer(&this.parent_page); } } 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 != null) 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; if (parent_page != 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.ToggleToolButton faces_button = null; private Gtk.ToolButton enhance_button = null; private Gtk.Scale zoom_slider = null; private Gtk.ToolButton prev_button = new Gtk.ToolButton(null, Resources.PREVIOUS_LABEL); private Gtk.ToolButton next_button = new Gtk.ToolButton(null, Resources.NEXT_LABEL); 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 last_locations = new Gee.HashMap(); protected 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 (null, Resources.ROTATE_CW_LABEL); rotate_button.set_icon_name(Resources.CLOCKWISE); 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); unowned Gtk.BindingSet binding_set = Gtk.BindingSet.by_class(rotate_button.get_class()); Gtk.BindingEntry.add_signal(binding_set, Gdk.Key.KP_Space, Gdk.ModifierType.CONTROL_MASK, "clicked", 0); Gtk.BindingEntry.add_signal(binding_set, Gdk.Key.space, Gdk.ModifierType.CONTROL_MASK, "clicked", 0); // crop tool crop_button = new Gtk.ToggleToolButton (); crop_button.set_icon_name("image-crop-symbolic"); 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 (); straighten_button.set_icon_name(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 (); redeye_button.set_icon_name("stock-eye-symbolic"); 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(); adjust_button.set_icon_name(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(null, Resources.ENHANCE_LABEL); enhance_button.set_icon_name(Resources.ENHANCE); enhance_button.set_tooltip_text(Resources.ENHANCE_TOOLTIP); enhance_button.clicked.connect(on_enhance); enhance_button.is_important = true; toolbar.insert(enhance_button, -1); // faces tool insert_faces_button(toolbar); faces_button = new Gtk.ToggleToolButton(); //face_button // 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_icon_name("image-zoom-out-symbolic", Gtk.IconSize.SMALL_TOOLBAR); 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_icon_name("image-zoom-in-symbolic", Gtk.IconSize.SMALL_TOOLBAR); 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); separator = new Gtk.SeparatorToolItem(); separator.set_draw(false); toolbar.insert(separator, -1); // previous button prev_button.set_tooltip_text(_("Previous photo")); prev_button.set_icon_name("go-previous-symbolic"); prev_button.clicked.connect(on_previous_photo); toolbar.insert(prev_button, -1); // next button next_button.set_tooltip_text(_("Next photo")); next_button.set_icon_name("go-next-symbolic"); 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 true; } 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 true; } 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 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, owned 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 old_neighbors = (Gee.Set) old_controller.get_extended_neighbors(old_photo, Photo.TYPENAME); Gee.Set new_neighbors = (Gee.Set) 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) { set_pixbuf(new Gdk.Pixbuf(Gdk.Colorspace.RGB, false, 8, 1, 1), photo.get_dimensions()); 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) { pixbuf = get_placeholder_pixbuf(); 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() && has_photo()) 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; try { original = get_photo().get_original_orientation().rotate_pixbuf( get_photo().get_master_pixbuf(cache.get_scaling())); } catch (Error err) { 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 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) 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()) { SetBackgroundPhotoDialog dialog = new SetBackgroundPhotoDialog(); bool desktop, screensaver; if (dialog.execute(out desktop, out screensaver)) { AppWindow.get_instance().set_busy_cursor(); DesktopIntegration.set_background(get_photo(), desktop, screensaver); AppWindow.get_instance().set_normal_cursor(); } } } 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.set_transient_for(AppWindow.get_instance()); 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); var dimensions = Scaling.get_screen_dimensions(get_container()); x = dimensions.width; y = dimensions.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 var dimensions = Scaling.get_screen_dimensions(get_container()); x = x.clamp(0, dimensions.width - tool_alloc.width); y = y.clamp(0, dimensions.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; } // This method is intentionally empty --its purpose is to allow overriding // it in LibraryPhotoPage, since FacesTool must only be present in // LibraryMode, but it need to be called from constructor of EditingHostPage // to place it correctly in the toolbar. protected virtual void insert_faces_button(Gtk.Toolbar toolbar) { ; } } // // LibraryPhotoPage // public class LibraryPhotoPage : EditingHostPage { private class LibraryPhotoPageViewFilter : ViewFilter { public override bool predicate (DataView view) { return !((MediaSource) view.get_source()).is_trashed(); } } private Gtk.ToggleToolButton faces_button = null; 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 unlinking) { filter.refresh(); } private void on_photo_relinked(Gee.Collection relinked) { filter.refresh(); } protected override void init_collect_ui_filenames(Gee.List ui_filenames) { base.init_collect_ui_filenames(ui_filenames); ui_filenames.add("photo_context.ui"); ui_filenames.add("photo.ui"); } private const GLib.ActionEntry[] entries = { { "Export", on_export }, { "Print", on_print }, { "Publish", on_publish }, { "RemoveFromLibrary", on_remove_from_library }, { "MoveToTrash", on_move_to_trash }, { "PrevPhoto", on_previous_photo }, { "NextPhoto", on_next_photo }, { "RotateClockwise", on_rotate_clockwise }, { "RotateCounterclockwise", on_rotate_counterclockwise }, { "FlipHorizontally", on_flip_horizontally }, { "FlipVertically", on_flip_vertically }, { "Enhance", on_enhance }, { "CopyColorAdjustments", on_copy_adjustments }, { "PasteColorAdjustments", on_paste_adjustments }, { "Crop", toggle_crop }, { "Straighten", toggle_straighten }, { "RedEye", toggle_redeye }, { "Adjust", toggle_adjust }, { "Revert", on_revert }, { "EditTitle", on_edit_title }, { "EditComment", on_edit_comment }, { "AdjustDateTime", on_adjust_date_time }, { "ExternalEdit", on_external_edit }, { "ExternalEditRAW", on_external_edit_raw }, { "SendTo", on_send_to }, { "SetBackground", on_set_background }, { "Flag", on_flag_unflag }, { "IncreaseRating", on_increase_rating }, { "DecreaseRating", on_decrease_rating }, { "RateRejected", on_rate_rejected }, { "RateUnrated", on_rate_unrated }, { "RateOne", on_rate_one }, { "RateTwo", on_rate_two }, { "RateThree", on_rate_three }, { "RateFour", on_rate_four }, { "RateFive", on_rate_five }, { "IncreaseSize", on_increase_size }, { "DecreaseSize", on_decrease_size }, { "ZoomFit", snap_zoom_to_min }, { "Zoom100", snap_zoom_to_isomorphic }, { "Zoom200", snap_zoom_to_max }, { "AddTags", on_add_tags }, { "ModifyTags", on_modify_tags }, { "Slideshow", on_slideshow }, // Toggle actions { "ViewRatings", on_action_toggle, null, "false", on_display_ratings }, // Radio actions }; protected override void add_actions (GLib.ActionMap map) { base.add_actions (map); map.add_action_entries (entries, this); ((GLib.SimpleAction) get_action ("ViewRatings")).change_state (Config.Facade.get_instance ().get_display_photo_ratings ()); var d = Config.Facade.get_instance().get_default_raw_developer(); var action = new GLib.SimpleAction.stateful("RawDeveloper", GLib.VariantType.STRING, d == RawDeveloper.SHOTWELL ? "Shotwell" : "Camera"); action.change_state.connect(on_raw_developer_changed); action.set_enabled(true); map.add_action(action); } protected override void remove_actions(GLib.ActionMap map) { base.remove_actions(map); foreach (var entry in entries) { map.remove_action(entry.name); } } protected override InjectionGroup[] init_collect_injection_groups() { InjectionGroup[] groups = base.init_collect_injection_groups(); InjectionGroup print_group = new InjectionGroup("PrintPlaceholder"); print_group.add_menu_item(_("_Print"), "Print", "p"); groups += print_group; InjectionGroup publish_group = new InjectionGroup("PublishPlaceholder"); publish_group.add_menu_item(_("_Publish"), "Publish", "p"); groups += publish_group; InjectionGroup bg_group = new InjectionGroup("SetBackgroundPlaceholder"); bg_group.add_menu_item(_("Set as _Desktop Background"), "SetBackground", "b"); groups += bg_group; return groups; } private void on_display_ratings(GLib.SimpleAction action, Variant? value) { bool display = value.get_boolean (); set_display_ratings(display); Config.Facade.get_instance().set_display_photo_ratings(display); action.set_state (value); } private void set_display_ratings(bool display) { var action = get_action("ViewRatings") as GLib.SimpleAction; if (action != null) action.set_enabled(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_sensitive("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(GLib.SimpleAction action, Variant? value) { RawDeveloper developer = RawDeveloper.SHOTWELL; switch (value.get_string ()) { case "Shotwell": developer = RawDeveloper.SHOTWELL; break; case "Camera": developer = RawDeveloper.CAMERA; break; default: break; } developer_changed(developer); action.set_state (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() { set_action_sensitive ("Flag", has_photo()); } // 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 parameters 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()); } public override void switching_from() { base.switching_from(); foreach (var entry in entries) { AppWindow.get_instance().remove_action(entry.name); } } 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_flagged_trinket(scale); } 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) { FullscreenWindow? fs = get_container() as FullscreenWindow; if (fs == null) return_to_collection_on_release = true; else fs.close(); return true; } 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 context_menu; private Gtk.Menu get_context_menu() { if (context_menu == null) { var model = this.builder.get_object ("PhotoContextMenu") as GLib.MenuModel; context_menu = new Gtk.Menu.from_model (model); context_menu.attach_to_widget (this, null); } return this.context_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 photos = new Gee.ArrayList(); photos.add(photo); remove_from_app(photos, GLib.dpgettext2(null, "Dialog Title", "Remove From Library"), GLib.dpgettext2(null, "Dialog Title", "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 photos = new Gee.ArrayList(); 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()) { var photo_list = new Gee.ArrayList(); 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(); ViewCollection view = get_view(); view.remove_marked(view.mark(view.get_view_for_source(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) 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) get_view().get_selected_sources()); } private void on_export() { if (!has_photo()) return; ExportDialog export_dialog = new ExportDialog(GLib.dpgettext2(null, "Dialog Title", "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) get_view().get_selected_sources()); } 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) { // FIXME: Only enable radio actions that are actually possible.. // Set active developer in menu. switch (get_photo().get_raw_developer()) { case RawDeveloper.SHOTWELL: get_action ("RawDeveloper").change_state ("Shotwell"); break; case RawDeveloper.CAMERA: case RawDeveloper.EMBEDDED: get_action ("RawDeveloper").change_state ("Camera"); break; default: assert_not_reached(); } } } private void on_metadata_altered(Gee.Map 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) 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? new_tags = dialog.execute(); if (new_tags == null) return; get_command_manager().execute(new ModifyTagsCommand(photo, new_tags)); } private void on_faces_toggled() { on_tool_button_toggled(faces_button, FacesTool.factory); } protected void toggle_faces() { faces_button.set_active(!faces_button.get_active()); } protected override void insert_faces_button(Gtk.Toolbar toolbar) { faces_button = new Gtk.ToggleToolButton(); faces_button.set_icon_name(Resources.ICON_FACES); faces_button.set_label(Resources.FACES_LABEL); faces_button.set_tooltip_text(Resources.FACES_TOOLTIP); faces_button.toggled.connect(on_faces_toggled); faces_button.is_important = true; toolbar.insert(faces_button, -1); } }