diff options
Diffstat (limited to 'src/editing_tools')
-rw-r--r-- | src/editing_tools/EditingTools.vala | 2963 | ||||
-rw-r--r-- | src/editing_tools/StraightenTool.vala | 559 | ||||
-rw-r--r-- | src/editing_tools/mk/editing_tools.mk | 28 |
3 files changed, 3550 insertions, 0 deletions
diff --git a/src/editing_tools/EditingTools.vala b/src/editing_tools/EditingTools.vala new file mode 100644 index 0000000..f5fb144 --- /dev/null +++ b/src/editing_tools/EditingTools.vala @@ -0,0 +1,2963 @@ +/* Copyright 2011-2014 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +/* This file is the master unit file for the EditingTools unit. It should be edited to include + * whatever code is deemed necessary. + * + * The init() and terminate() methods are mandatory. + * + * If the unit needs to be configured prior to initialization, add the proper parameters to + * the preconfigure() method, implement it, and ensure in init() that it's been called. + */ + +namespace EditingTools { + +// preconfigure may be deleted if not used. +public void preconfigure() { +} + +public void init() throws Error { +} + +public void terminate() { +} + +public abstract class EditingToolWindow : Gtk.Window { + private const int FRAME_BORDER = 6; + + private Gtk.Frame layout_frame = new Gtk.Frame(null); + private bool user_moved = false; + + public EditingToolWindow(Gtk.Window container) { + // needed so that windows will appear properly in fullscreen mode + type_hint = Gdk.WindowTypeHint.UTILITY; + + set_decorated(false); + set_transient_for(container); + + Gtk.Frame outer_frame = new Gtk.Frame(null); + outer_frame.set_border_width(0); + outer_frame.set_shadow_type(Gtk.ShadowType.OUT); + + layout_frame.set_border_width(FRAME_BORDER); + layout_frame.set_shadow_type(Gtk.ShadowType.NONE); + + outer_frame.add(layout_frame); + base.add(outer_frame); + + add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.KEY_PRESS_MASK); + focus_on_map = true; + set_accept_focus(true); + set_can_focus(true); + set_has_resize_grip(false); + + // Needed to prevent the (spurious) 'This event was synthesised outside of GDK' + // warnings after a keypress. + Log.set_handler("Gdk", LogLevelFlags.LEVEL_WARNING, suppress_warnings); + } + + ~EditingToolWindow() { + Log.set_handler("Gdk", LogLevelFlags.LEVEL_WARNING, Log.default_handler); + } + + public override void add(Gtk.Widget widget) { + layout_frame.add(widget); + } + + public bool has_user_moved() { + return user_moved; + } + + public override bool key_press_event(Gdk.EventKey event) { + if (base.key_press_event(event)) { + return true; + } + return AppWindow.get_instance().key_press_event(event); + } + + public override bool button_press_event(Gdk.EventButton event) { + // LMB only + if (event.button != 1) + return (base.button_press_event != null) ? base.button_press_event(event) : true; + + begin_move_drag((int) event.button, (int) event.x_root, (int) event.y_root, event.time); + user_moved = true; + + return true; + } + + public override void realize() { + set_opacity(Resources.TRANSIENT_WINDOW_OPACITY); + + base.realize(); + } +} + +// The PhotoCanvas is an interface object between an EditingTool and its host. It provides objects +// and primitives for an EditingTool to obtain information about the image, to draw on the host's +// canvas, and to be signalled when the canvas and its pixbuf changes (is resized). +public abstract class PhotoCanvas { + private Gtk.Window container; + private Gdk.Window drawing_window; + private Photo photo; + private Cairo.Context default_ctx; + private Dimensions surface_dim; + private Cairo.Surface scaled; + private Gdk.Pixbuf scaled_pixbuf; + private Gdk.Rectangle scaled_position; + + public PhotoCanvas(Gtk.Window container, Gdk.Window drawing_window, Photo photo, + Cairo.Context default_ctx, Dimensions surface_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) { + this.container = container; + this.drawing_window = drawing_window; + this.photo = photo; + this.default_ctx = default_ctx; + this.surface_dim = surface_dim; + this.scaled_position = scaled_position; + this.scaled_pixbuf = scaled; + this.scaled = pixbuf_to_surface(default_ctx, scaled, scaled_position); + } + + public signal void new_surface(Cairo.Context ctx, Dimensions dim); + + public signal void resized_scaled_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled, + Gdk.Rectangle scaled_position); + + public Gdk.Rectangle unscaled_to_raw_rect(Gdk.Rectangle rectangle) { + return photo.unscaled_to_raw_rect(rectangle); + } + + public Gdk.Point active_to_unscaled_point(Gdk.Point active_point) { + Gdk.Rectangle scaled_position = get_scaled_pixbuf_position(); + Dimensions unscaled_dims = photo.get_dimensions(); + + double scale_factor_x = ((double) unscaled_dims.width) / + ((double) scaled_position.width); + double scale_factor_y = ((double) unscaled_dims.height) / + ((double) scaled_position.height); + + Gdk.Point result = {0}; + result.x = (int)(((double) active_point.x) * scale_factor_x + 0.5); + result.y = (int)(((double) active_point.y) * scale_factor_y + 0.5); + + return result; + } + + public Gdk.Rectangle active_to_unscaled_rect(Gdk.Rectangle active_rect) { + Gdk.Point upper_left = {0}; + Gdk.Point lower_right = {0}; + upper_left.x = active_rect.x; + upper_left.y = active_rect.y; + lower_right.x = upper_left.x + active_rect.width; + lower_right.y = upper_left.y + active_rect.height; + + upper_left = active_to_unscaled_point(upper_left); + lower_right = active_to_unscaled_point(lower_right); + + Gdk.Rectangle unscaled_rect = Gdk.Rectangle(); + unscaled_rect.x = upper_left.x; + unscaled_rect.y = upper_left.y; + unscaled_rect.width = lower_right.x - upper_left.x; + unscaled_rect.height = lower_right.y - upper_left.y; + + return unscaled_rect; + } + + public Gdk.Point user_to_active_point(Gdk.Point user_point) { + Gdk.Rectangle active_offsets = get_scaled_pixbuf_position(); + + Gdk.Point result = {0}; + result.x = user_point.x - active_offsets.x; + result.y = user_point.y - active_offsets.y; + + return result; + } + + public Gdk.Rectangle user_to_active_rect(Gdk.Rectangle user_rect) { + Gdk.Point upper_left = {0}; + Gdk.Point lower_right = {0}; + upper_left.x = user_rect.x; + upper_left.y = user_rect.y; + lower_right.x = upper_left.x + user_rect.width; + lower_right.y = upper_left.y + user_rect.height; + + upper_left = user_to_active_point(upper_left); + lower_right = user_to_active_point(lower_right); + + Gdk.Rectangle active_rect = Gdk.Rectangle(); + active_rect.x = upper_left.x; + active_rect.y = upper_left.y; + active_rect.width = lower_right.x - upper_left.x; + active_rect.height = lower_right.y - upper_left.y; + + return active_rect; + } + + public Photo get_photo() { + return photo; + } + + public Gtk.Window get_container() { + return container; + } + + public Gdk.Window get_drawing_window() { + return drawing_window; + } + + public Cairo.Context get_default_ctx() { + return default_ctx; + } + + public Dimensions get_surface_dim() { + return surface_dim; + } + + public Scaling get_scaling() { + return Scaling.for_viewport(surface_dim, false); + } + + public void set_surface(Cairo.Context default_ctx, Dimensions surface_dim) { + this.default_ctx = default_ctx; + this.surface_dim = surface_dim; + + new_surface(default_ctx, surface_dim); + } + + public Cairo.Surface get_scaled_surface() { + return scaled; + } + + public Gdk.Pixbuf get_scaled_pixbuf() { + return scaled_pixbuf; + } + + public Gdk.Rectangle get_scaled_pixbuf_position() { + return scaled_position; + } + + public void resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) { + this.scaled = pixbuf_to_surface(default_ctx, scaled, scaled_position); + this.scaled_pixbuf = scaled; + this.scaled_position = scaled_position; + + resized_scaled_pixbuf(old_dim, scaled, scaled_position); + } + + public abstract void repaint(); + + // Because the editing tool should not have any need to draw on the gutters outside the photo, + // and it's a pain to constantly calculate where it's laid out on the drawable, these convenience + // methods automatically adjust for its position. + // + // If these methods are not used, all painting to the drawable should be offet by + // get_scaled_pixbuf_position().x and get_scaled_pixbuf_position().y + public void paint_pixbuf(Gdk.Pixbuf pixbuf) { + default_ctx.save(); + + // paint black background + set_source_color_from_string(default_ctx, "#000"); + default_ctx.rectangle(0, 0, surface_dim.width, surface_dim.height); + default_ctx.fill(); + + // paint the actual image + Gdk.cairo_set_source_pixbuf(default_ctx, pixbuf, scaled_position.x, scaled_position.y); + default_ctx.rectangle(scaled_position.x, scaled_position.y, + pixbuf.get_width(), pixbuf.get_height()); + default_ctx.fill(); + default_ctx.restore(); + } + + public void paint_pixbuf_area(Gdk.Pixbuf pixbuf, Box source_area) { + default_ctx.save(); + if (pixbuf.get_has_alpha()) { + set_source_color_from_string(default_ctx, "#000"); + default_ctx.rectangle(scaled_position.x + source_area.left, + scaled_position.y + source_area.top, + source_area.get_width(), source_area.get_height()); + default_ctx.fill(); + + } + Gdk.cairo_set_source_pixbuf(default_ctx, pixbuf, scaled_position.x, + scaled_position.y); + default_ctx.rectangle(scaled_position.x + source_area.left, + scaled_position.y + source_area.top, + source_area.get_width(), source_area.get_height()); + default_ctx.fill(); + default_ctx.restore(); + } + + // Paint a surface on top of the photo + public void paint_surface(Cairo.Surface surface, bool over) { + default_ctx.save(); + if (over == false) + default_ctx.set_operator(Cairo.Operator.SOURCE); + else + default_ctx.set_operator(Cairo.Operator.OVER); + + default_ctx.set_source_surface(scaled, scaled_position.x, scaled_position.y); + default_ctx.paint(); + default_ctx.set_source_surface(surface, scaled_position.x, scaled_position.y); + default_ctx.paint(); + default_ctx.restore(); + } + + public void paint_surface_area(Cairo.Surface surface, Box source_area, bool over) { + default_ctx.save(); + if (over == false) + default_ctx.set_operator(Cairo.Operator.SOURCE); + else + default_ctx.set_operator(Cairo.Operator.OVER); + + default_ctx.set_source_surface(scaled, scaled_position.x, scaled_position.y); + default_ctx.rectangle(scaled_position.x + source_area.left, + scaled_position.y + source_area.top, + source_area.get_width(), source_area.get_height()); + default_ctx.fill(); + + default_ctx.set_source_surface(surface, scaled_position.x, scaled_position.y); + default_ctx.rectangle(scaled_position.x + source_area.left, + scaled_position.y + source_area.top, + source_area.get_width(), source_area.get_height()); + default_ctx.fill(); + default_ctx.restore(); + } + + public void draw_box(Cairo.Context ctx, Box box) { + Gdk.Rectangle rect = box.get_rectangle(); + rect.x += scaled_position.x; + rect.y += scaled_position.y; + + ctx.rectangle(rect.x + 0.5, rect.y + 0.5, rect.width - 1, rect.height - 1); + ctx.stroke(); + } + + public void draw_text(Cairo.Context ctx, string text, int x, int y, bool use_scaled_pos = true) { + if (use_scaled_pos) { + x += scaled_position.x; + y += scaled_position.y; + } + Cairo.TextExtents extents; + ctx.text_extents(text, out extents); + x -= (int) extents.width / 2; + + set_source_color_from_string(ctx, Resources.ONIMAGE_FONT_BACKGROUND); + + int pane_border = 5; // border around edge of pane in pixels + ctx.rectangle(x - pane_border, y - pane_border - extents.height, + extents.width + 2 * pane_border, + extents.height + 2 * pane_border); + ctx.fill(); + + ctx.move_to(x, y); + set_source_color_from_string(ctx, Resources.ONIMAGE_FONT_COLOR); + ctx.show_text(text); + } + + /** + * Draw a horizontal line into the specified Cairo context at the specified position, taking + * into account the scaled position of the image unless directed otherwise. + * + * @param ctx The drawing context of the surface we're drawing to. + * @param x The horizontal position to place the line at. + * @param y The vertical position to place the line at. + * @param width The length of the line. + * @param use_scaled_pos Whether to use absolute window positioning or take into account the + * position of the scaled image. + */ + public void draw_horizontal_line(Cairo.Context ctx, int x, int y, int width, bool use_scaled_pos = true) { + if (use_scaled_pos) { + x += scaled_position.x; + y += scaled_position.y; + } + + ctx.move_to(x + 0.5, y + 0.5); + ctx.line_to(x + width - 1, y + 0.5); + ctx.stroke(); + } + + /** + * Draw a vertical line into the specified Cairo context at the specified position, taking + * into account the scaled position of the image unless directed otherwise. + * + * @param ctx The drawing context of the surface we're drawing to. + * @param x The horizontal position to place the line at. + * @param y The vertical position to place the line at. + * @param width The length of the line. + * @param use_scaled_pos Whether to use absolute window positioning or take into account the + * position of the scaled image. + */ + public void draw_vertical_line(Cairo.Context ctx, int x, int y, int height, bool use_scaled_pos = true) { + if (use_scaled_pos) { + x += scaled_position.x; + y += scaled_position.y; + } + + ctx.move_to(x + 0.5, y + 0.5); + ctx.line_to(x + 0.5, y + height - 1); + ctx.stroke(); + } + + public void erase_horizontal_line(int x, int y, int width) { + default_ctx.save(); + + default_ctx.set_operator(Cairo.Operator.SOURCE); + default_ctx.set_source_surface(scaled, scaled_position.x, scaled_position.y); + default_ctx.rectangle(scaled_position.x + x, scaled_position.y + y, + width - 1, 1); + default_ctx.fill(); + + default_ctx.restore(); + } + + public void draw_circle(Cairo.Context ctx, int active_center_x, int active_center_y, + int radius) { + int center_x = active_center_x + scaled_position.x; + int center_y = active_center_y + scaled_position.y; + + ctx.arc(center_x, center_y, radius, 0, 2 * GLib.Math.PI); + ctx.stroke(); + } + + public void erase_vertical_line(int x, int y, int height) { + default_ctx.save(); + + // Ticket #3146 - artifacting when moving the crop box or + // enlarging it from the lower right. + // We now no longer subtract one from the height before choosing + // a region to erase. + default_ctx.set_operator(Cairo.Operator.SOURCE); + default_ctx.set_source_surface(scaled, scaled_position.x, scaled_position.y); + default_ctx.rectangle(scaled_position.x + x, scaled_position.y + y, + 1, height); + default_ctx.fill(); + + default_ctx.restore(); + } + + public void erase_box(Box box) { + erase_horizontal_line(box.left, box.top, box.get_width()); + erase_horizontal_line(box.left, box.bottom, box.get_width()); + + erase_vertical_line(box.left, box.top, box.get_height()); + erase_vertical_line(box.right, box.top, box.get_height()); + } + + public void invalidate_area(Box area) { + Gdk.Rectangle rect = area.get_rectangle(); + rect.x += scaled_position.x; + rect.y += scaled_position.y; + + drawing_window.invalidate_rect(rect, false); + } + + private Cairo.Surface pixbuf_to_surface(Cairo.Context default_ctx, Gdk.Pixbuf pixbuf, + Gdk.Rectangle pos) { + Cairo.Surface surface = new Cairo.Surface.similar(default_ctx.get_target(), + Cairo.Content.COLOR_ALPHA, pos.width, pos.height); + Cairo.Context ctx = new Cairo.Context(surface); + Gdk.cairo_set_source_pixbuf(ctx, pixbuf, 0, 0); + ctx.paint(); + return surface; + } +} + +public abstract class EditingTool { + public PhotoCanvas canvas = null; + + private EditingToolWindow tool_window = null; + protected Cairo.Surface surface; + public string name; + + [CCode (has_target=false)] + public delegate EditingTool Factory(); + + public signal void activated(); + + public signal void deactivated(); + + public signal void applied(Command? command, Gdk.Pixbuf? new_pixbuf, Dimensions new_max_dim, + bool needs_improvement); + + public signal void cancelled(); + + public signal void aborted(); + + public EditingTool(string name) { + this.name = name; + } + + // base.activate() should always be called by an overriding member to ensure the base class + // gets to set up and store the PhotoCanvas in the canvas member field. More importantly, + // the activated signal is called here, and should only be called once the tool is completely + // initialized. + public virtual void activate(PhotoCanvas canvas) { + // multiple activates are not tolerated + assert(this.canvas == null); + assert(tool_window == null); + + this.canvas = canvas; + + tool_window = get_tool_window(); + if (tool_window != null) + tool_window.key_press_event.connect(on_keypress); + + activated(); + } + + // Like activate(), this should always be called from an overriding subclass. + public virtual void deactivate() { + // multiple deactivates are tolerated + if (canvas == null && tool_window == null) + return; + + canvas = null; + + if (tool_window != null) { + tool_window.key_press_event.disconnect(on_keypress); + tool_window = null; + } + + deactivated(); + } + + public bool is_activated() { + return canvas != null; + } + + public virtual EditingToolWindow? get_tool_window() { + return null; + } + + // This allows the EditingTool to specify which pixbuf to display during the tool's + // operation. Returning null means the host should use the pixbuf associated with the current + // Photo. Note: This will be called before activate(), primarily to display the pixbuf before + // the tool is on the screen, and before paint_full() is hooked in. It also means the PhotoCanvas + // will have this pixbuf rather than one from the Photo class. + // + // If returns non-null, should also fill max_dim with the maximum dimensions of the original + // image, as the editing host may not always scale images up to fit the viewport. + // + // Note this this method doesn't need to be returning the "proper" pixbuf on-the-fly (i.e. + // a pixbuf with unsaved tool edits in it). That can be handled in the paint() virtual method. + public virtual Gdk.Pixbuf? get_display_pixbuf(Scaling scaling, Photo photo, + out Dimensions max_dim) throws Error { + max_dim = Dimensions(); + + return null; + } + + public virtual void on_left_click(int x, int y) { + } + + public virtual void on_left_released(int x, int y) { + } + + public virtual void on_motion(int x, int y, Gdk.ModifierType mask) { + } + + public virtual bool on_leave_notify_event(){ + return false; + } + + public virtual bool on_keypress(Gdk.EventKey event) { + // check for an escape/abort first + if (Gdk.keyval_name(event.keyval) == "Escape") { + notify_cancel(); + + return true; + } + + return false; + } + + public virtual void paint(Cairo.Context ctx) { + } + + // Helper function that fires the cancelled signal. (Can be connected to other signals.) + protected void notify_cancel() { + cancelled(); + } +} + +public class CropTool : EditingTool { + private const double CROP_INIT_X_PCT = 0.15; + private const double CROP_INIT_Y_PCT = 0.15; + + private const int CROP_MIN_SIZE = 8; + + private const float CROP_EXTERIOR_SATURATION = 0.00f; + private const int CROP_EXTERIOR_RED_SHIFT = -32; + private const int CROP_EXTERIOR_GREEN_SHIFT = -32; + private const int CROP_EXTERIOR_BLUE_SHIFT = -32; + private const int CROP_EXTERIOR_ALPHA_SHIFT = 0; + + private const float ANY_ASPECT_RATIO = -1.0f; + private const float SCREEN_ASPECT_RATIO = -2.0f; + private const float ORIGINAL_ASPECT_RATIO = -3.0f; + private const float CUSTOM_ASPECT_RATIO = -4.0f; + private const float COMPUTE_FROM_BASIS = -5.0f; + private const float SEPARATOR = -6.0f; + private const float MIN_ASPECT_RATIO = 1.0f / 64.0f; + private const float MAX_ASPECT_RATIO = 64.0f; + + private class ConstraintDescription { + public string name; + public int basis_width; + public int basis_height; + public bool is_pivotable; + public float aspect_ratio; + + public ConstraintDescription(string new_name, int new_basis_width, int new_basis_height, + bool new_pivotable, float new_aspect_ratio = COMPUTE_FROM_BASIS) { + name = new_name; + basis_width = new_basis_width; + basis_height = new_basis_height; + if (new_aspect_ratio == COMPUTE_FROM_BASIS) + aspect_ratio = ((float) basis_width) / ((float) basis_height); + else + aspect_ratio = new_aspect_ratio; + is_pivotable = new_pivotable; + } + + public bool is_separator() { + return !is_pivotable && aspect_ratio == SEPARATOR; + } + } + + private enum ReticleOrientation { + LANDSCAPE, + PORTRAIT; + + public ReticleOrientation toggle() { + return (this == ReticleOrientation.LANDSCAPE) ? ReticleOrientation.PORTRAIT : + ReticleOrientation.LANDSCAPE; + } + } + + private enum ConstraintMode { + NORMAL, + CUSTOM + } + + private class CropToolWindow : EditingToolWindow { + private const int CONTROL_SPACING = 8; + + public Gtk.Button ok_button = new Gtk.Button.with_label(Resources.CROP_LABEL); + public Gtk.Button cancel_button = new Gtk.Button.from_stock(Gtk.Stock.CANCEL); + public Gtk.ComboBox constraint_combo; + public Gtk.Button pivot_reticle_button = new Gtk.Button(); + public Gtk.Entry custom_width_entry = new Gtk.Entry(); + public Gtk.Entry custom_height_entry = new Gtk.Entry(); + public Gtk.Label custom_mulsign_label = new Gtk.Label.with_mnemonic("x"); + public Gtk.Entry most_recently_edited = null; + public Gtk.Box response_layout = null; + public Gtk.Box layout = null; + public int normal_width = -1; + public int normal_height = -1; + + public CropToolWindow(Gtk.Window container) { + base(container); + + cancel_button.set_tooltip_text(_("Return to current photo dimensions")); + cancel_button.set_image_position(Gtk.PositionType.LEFT); + + ok_button.set_tooltip_text(_("Set the crop for this photo")); + ok_button.set_image_position(Gtk.PositionType.LEFT); + + constraint_combo = new Gtk.ComboBox(); + Gtk.CellRendererText combo_text_renderer = new Gtk.CellRendererText(); + constraint_combo.pack_start(combo_text_renderer, true); + constraint_combo.add_attribute(combo_text_renderer, "text", 0); + constraint_combo.set_row_separator_func(constraint_combo_separator_func); + constraint_combo.set_active(0); + + pivot_reticle_button.set_image(new Gtk.Image.from_stock(Resources.CROP_PIVOT_RETICLE, + Gtk.IconSize.SMALL_TOOLBAR)); + pivot_reticle_button.set_tooltip_text(_("Pivot the crop rectangle between portrait and landscape orientations")); + + custom_width_entry.set_width_chars(4); + custom_width_entry.editable = true; + custom_height_entry.set_width_chars(4); + custom_height_entry.editable = true; + + response_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING); + response_layout.homogeneous = true; + response_layout.add(cancel_button); + response_layout.add(ok_button); + + layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING); + layout.add(constraint_combo); + layout.add(pivot_reticle_button); + layout.add(response_layout); + + add(layout); + } + + private static bool constraint_combo_separator_func(Gtk.TreeModel model, Gtk.TreeIter iter) { + Value val; + model.get_value(iter, 0, out val); + + return (val.dup_string() == "-"); + } + } + + private CropToolWindow crop_tool_window = null; + private Gdk.CursorType current_cursor_type = Gdk.CursorType.LEFT_PTR; + private BoxLocation in_manipulation = BoxLocation.OUTSIDE; + private Cairo.Context wide_black_ctx = null; + private Cairo.Context wide_white_ctx = null; + private Cairo.Context thin_white_ctx = null; + private Cairo.Context text_ctx = null; + + // This is where we draw our crop tool + private Cairo.Surface crop_surface = null; + + // these are kept in absolute coordinates, not relative to photo's position on canvas + private Box scaled_crop; + private int last_grab_x = -1; + private int last_grab_y = -1; + + private ConstraintDescription[] constraints = create_constraints(); + private Gtk.ListStore constraint_list = create_constraint_list(create_constraints()); + private ReticleOrientation reticle_orientation = ReticleOrientation.LANDSCAPE; + private ConstraintMode constraint_mode = ConstraintMode.NORMAL; + private bool entry_insert_in_progress = false; + private float custom_aspect_ratio = 1.0f; + private int custom_width = -1; + private int custom_height = -1; + private int custom_init_width = -1; + private int custom_init_height = -1; + private float pre_aspect_ratio = ANY_ASPECT_RATIO; + + private CropTool() { + base("CropTool"); + } + + public static CropTool factory() { + return new CropTool(); + } + + public static bool is_available(Photo photo, Scaling scaling) { + Dimensions dim = scaling.get_scaled_dimensions(photo.get_original_dimensions()); + + return dim.width > CROP_MIN_SIZE && dim.height > CROP_MIN_SIZE; + } + + private static ConstraintDescription[] create_constraints() { + ConstraintDescription[] result = new ConstraintDescription[0]; + + result += new ConstraintDescription(_("Unconstrained"), 0, 0, false, ANY_ASPECT_RATIO); + result += new ConstraintDescription(_("Square"), 1, 1, false); + result += new ConstraintDescription(_("Screen"), 0, 0, true, SCREEN_ASPECT_RATIO); + result += new ConstraintDescription(_("Original Size"), 0, 0, true, ORIGINAL_ASPECT_RATIO); + result += new ConstraintDescription(_("-"), 0, 0, false, SEPARATOR); + result += new ConstraintDescription(_("SD Video (4 : 3)"), 4, 3, true); + result += new ConstraintDescription(_("HD Video (16 : 9)"), 16, 9, true); + result += new ConstraintDescription(_("-"), 0, 0, false, SEPARATOR); + result += new ConstraintDescription(_("Wallet (2 x 3 in.)"), 3, 2, true); + result += new ConstraintDescription(_("Notecard (3 x 5 in.)"), 5, 3, true); + result += new ConstraintDescription(_("4 x 6 in."), 6, 4, true); + result += new ConstraintDescription(_("5 x 7 in."), 7, 5, true); + result += new ConstraintDescription(_("8 x 10 in."), 10, 8, true); + result += new ConstraintDescription(_("Letter (8.5 x 11 in.)"), 85, 110, true); + result += new ConstraintDescription(_("11 x 14 in."), 14, 11, true); + result += new ConstraintDescription(_("Tabloid (11 x 17 in.)"), 17, 11, true); + result += new ConstraintDescription(_("16 x 20 in."), 20, 16, true); + result += new ConstraintDescription(_("-"), 0, 0, false, SEPARATOR); + result += new ConstraintDescription(_("Metric Wallet (9 x 13 cm)"), 13, 9, true); + result += new ConstraintDescription(_("Postcard (10 x 15 cm)"), 15, 10, true); + result += new ConstraintDescription(_("13 x 18 cm"), 18, 13, true); + result += new ConstraintDescription(_("18 x 24 cm"), 24, 18, true); + result += new ConstraintDescription(_("A4 (210 x 297 mm)"), 210, 297, true); + result += new ConstraintDescription(_("20 x 30 cm"), 30, 20, true); + result += new ConstraintDescription(_("24 x 40 cm"), 40, 24, true); + result += new ConstraintDescription(_("30 x 40 cm"), 40, 30, true); + result += new ConstraintDescription(_("A3 (297 x 420 mm)"), 420, 297, true); + result += new ConstraintDescription(_("-"), 0, 0, false, SEPARATOR); + result += new ConstraintDescription(_("Custom"), 0, 0, true, CUSTOM_ASPECT_RATIO); + + return result; + } + + private static Gtk.ListStore create_constraint_list(ConstraintDescription[] constraint_data) { + Gtk.ListStore result = new Gtk.ListStore(1, typeof(string), typeof(string)); + + Gtk.TreeIter iter; + foreach (ConstraintDescription constraint in constraint_data) { + result.append(out iter); + result.set_value(iter, 0, constraint.name); + } + + return result; + } + + private void update_pivot_button_state() { + crop_tool_window.pivot_reticle_button.set_sensitive( + get_selected_constraint().is_pivotable); + } + + private ConstraintDescription get_selected_constraint() { + ConstraintDescription result = constraints[crop_tool_window.constraint_combo.get_active()]; + + if (result.aspect_ratio == ORIGINAL_ASPECT_RATIO) { + result.basis_width = canvas.get_scaled_pixbuf_position().width; + result.basis_height = canvas.get_scaled_pixbuf_position().height; + } else if (result.aspect_ratio == SCREEN_ASPECT_RATIO) { + Gdk.Screen screen = Gdk.Screen.get_default(); + result.basis_width = screen.get_width(); + result.basis_height = screen.get_height(); + } + + return result; + } + + private bool on_width_entry_focus_out(Gdk.EventFocus event) { + crop_tool_window.most_recently_edited = crop_tool_window.custom_width_entry; + return on_custom_entry_focus_out(event); + } + + private bool on_height_entry_focus_out(Gdk.EventFocus event) { + crop_tool_window.most_recently_edited = crop_tool_window.custom_height_entry; + return on_custom_entry_focus_out(event); + } + + private bool on_custom_entry_focus_out(Gdk.EventFocus event) { + int width = int.parse(crop_tool_window.custom_width_entry.text); + int height = int.parse(crop_tool_window.custom_height_entry.text); + + if(width < 1) { + width = 1; + crop_tool_window.custom_width_entry.set_text("%d".printf(width)); + } + + if(height < 1) { + height = 1; + crop_tool_window.custom_height_entry.set_text("%d".printf(height)); + } + + if ((width == custom_width) && (height == custom_height)) + return false; + + custom_aspect_ratio = ((float) width) / ((float) height); + + if (custom_aspect_ratio < MIN_ASPECT_RATIO) { + if (crop_tool_window.most_recently_edited == crop_tool_window.custom_height_entry) { + height = (int) (width / MIN_ASPECT_RATIO); + crop_tool_window.custom_height_entry.set_text("%d".printf(height)); + } else { + width = (int) (height * MIN_ASPECT_RATIO); + crop_tool_window.custom_width_entry.set_text("%d".printf(width)); + } + } else if (custom_aspect_ratio > MAX_ASPECT_RATIO) { + if (crop_tool_window.most_recently_edited == crop_tool_window.custom_height_entry) { + height = (int) (width / MAX_ASPECT_RATIO); + crop_tool_window.custom_height_entry.set_text("%d".printf(height)); + } else { + width = (int) (height * MAX_ASPECT_RATIO); + crop_tool_window.custom_width_entry.set_text("%d".printf(width)); + } + } + + custom_aspect_ratio = ((float) width) / ((float) height); + + Box new_crop = constrain_crop(scaled_crop); + + crop_resized(new_crop); + scaled_crop = new_crop; + canvas.invalidate_area(new_crop); + canvas.repaint(); + + custom_width = width; + custom_height = height; + + return false; + } + + private void on_width_insert_text(string text, int length, ref int position) { + on_entry_insert_text(crop_tool_window.custom_width_entry, text, length, ref position); + } + + private void on_height_insert_text(string text, int length, ref int position) { + on_entry_insert_text(crop_tool_window.custom_height_entry, text, length, ref position); + } + + private void on_entry_insert_text(Gtk.Entry sender, string text, int length, ref int position) { + if (entry_insert_in_progress) + return; + + entry_insert_in_progress = true; + + if (length == -1) + length = (int) text.length; + + // only permit numeric text + string new_text = ""; + for (int ctr = 0; ctr < length; ctr++) { + if (text[ctr].isdigit()) { + new_text += ((char) text[ctr]).to_string(); + } + } + + if (new_text.length > 0) + sender.insert_text(new_text, (int) new_text.length, ref position); + + Signal.stop_emission_by_name(sender, "insert-text"); + + entry_insert_in_progress = false; + } + + private float get_constraint_aspect_ratio() { + float result = get_selected_constraint().aspect_ratio; + + if (result == ORIGINAL_ASPECT_RATIO) { + result = ((float) canvas.get_scaled_pixbuf_position().width) / + ((float) canvas.get_scaled_pixbuf_position().height); + } else if (result == SCREEN_ASPECT_RATIO) { + Gdk.Screen screen = Gdk.Screen.get_default(); + result = ((float) screen.get_width()) / ((float) screen.get_height()); + } else if (result == CUSTOM_ASPECT_RATIO) { + result = custom_aspect_ratio; + } + if (reticle_orientation == ReticleOrientation.PORTRAIT) + result = 1.0f / result; + + return result; + } + + private float get_constraint_aspect_ratio_for_constraint(ConstraintDescription constraint, Photo photo) { + float result = constraint.aspect_ratio; + + if (result == ORIGINAL_ASPECT_RATIO) { + Dimensions orig_dim = photo.get_original_dimensions(); + result = ((float) orig_dim.width) / ((float) orig_dim.height); + } else if (result == SCREEN_ASPECT_RATIO) { + Gdk.Screen screen = Gdk.Screen.get_default(); + result = ((float) screen.get_width()) / ((float) screen.get_height()); + } else if (result == CUSTOM_ASPECT_RATIO) { + result = custom_aspect_ratio; + } + if (reticle_orientation == ReticleOrientation.PORTRAIT) + result = 1.0f / result; + + return result; + + } + + private void constraint_changed() { + ConstraintDescription selected_constraint = get_selected_constraint(); + if (selected_constraint.aspect_ratio == CUSTOM_ASPECT_RATIO) { + set_custom_constraint_mode(); + } else { + set_normal_constraint_mode(); + + if (selected_constraint.aspect_ratio != ANY_ASPECT_RATIO) { + // user may have switched away from 'Custom' without + // accepting, so set these to default back to saved + // values. + custom_init_width = Config.Facade.get_instance().get_last_crop_width(); + custom_init_height = Config.Facade.get_instance().get_last_crop_height(); + custom_aspect_ratio = ((float) custom_init_width) / ((float) custom_init_height); + } + } + + update_pivot_button_state(); + + if (!get_selected_constraint().is_pivotable) + reticle_orientation = ReticleOrientation.LANDSCAPE; + + if (get_constraint_aspect_ratio() != pre_aspect_ratio) { + Box new_crop = constrain_crop(scaled_crop); + + crop_resized(new_crop); + scaled_crop = new_crop; + canvas.invalidate_area(new_crop); + canvas.repaint(); + + pre_aspect_ratio = get_constraint_aspect_ratio(); + } + } + + private void set_custom_constraint_mode() { + if (constraint_mode == ConstraintMode.CUSTOM) + return; + + if ((crop_tool_window.normal_width == -1) || (crop_tool_window.normal_height == -1)) + crop_tool_window.get_size(out crop_tool_window.normal_width, + out crop_tool_window.normal_height); + + int window_x_pos = 0; + int window_y_pos = 0; + crop_tool_window.get_position(out window_x_pos, out window_y_pos); + + crop_tool_window.hide(); + + crop_tool_window.layout.remove(crop_tool_window.constraint_combo); + crop_tool_window.layout.remove(crop_tool_window.pivot_reticle_button); + crop_tool_window.layout.remove(crop_tool_window.response_layout); + + crop_tool_window.layout.add(crop_tool_window.constraint_combo); + crop_tool_window.layout.add(crop_tool_window.custom_width_entry); + crop_tool_window.layout.add(crop_tool_window.custom_mulsign_label); + crop_tool_window.layout.add(crop_tool_window.custom_height_entry); + crop_tool_window.layout.add(crop_tool_window.pivot_reticle_button); + crop_tool_window.layout.add(crop_tool_window.response_layout); + + if (reticle_orientation == ReticleOrientation.LANDSCAPE) { + crop_tool_window.custom_width_entry.set_text("%d".printf(custom_init_width)); + crop_tool_window.custom_height_entry.set_text("%d".printf(custom_init_height)); + } else { + crop_tool_window.custom_width_entry.set_text("%d".printf(custom_init_height)); + crop_tool_window.custom_height_entry.set_text("%d".printf(custom_init_width)); + } + custom_aspect_ratio = ((float) custom_init_width) / ((float) custom_init_height); + + crop_tool_window.move(window_x_pos, window_y_pos); + crop_tool_window.show_all(); + + constraint_mode = ConstraintMode.CUSTOM; + } + + private void set_normal_constraint_mode() { + if (constraint_mode == ConstraintMode.NORMAL) + return; + + int window_x_pos = 0; + int window_y_pos = 0; + crop_tool_window.get_position(out window_x_pos, out window_y_pos); + + crop_tool_window.hide(); + + crop_tool_window.layout.remove(crop_tool_window.constraint_combo); + crop_tool_window.layout.remove(crop_tool_window.custom_width_entry); + crop_tool_window.layout.remove(crop_tool_window.custom_mulsign_label); + crop_tool_window.layout.remove(crop_tool_window.custom_height_entry); + crop_tool_window.layout.remove(crop_tool_window.pivot_reticle_button); + crop_tool_window.layout.remove(crop_tool_window.response_layout); + + crop_tool_window.layout.add(crop_tool_window.constraint_combo); + crop_tool_window.layout.add(crop_tool_window.pivot_reticle_button); + crop_tool_window.layout.add(crop_tool_window.response_layout); + + crop_tool_window.resize(crop_tool_window.normal_width, + crop_tool_window.normal_height); + + crop_tool_window.move(window_x_pos, window_y_pos); + crop_tool_window.show_all(); + + constraint_mode = ConstraintMode.NORMAL; + } + + private Box constrain_crop(Box crop) { + float user_aspect_ratio = get_constraint_aspect_ratio(); + if (user_aspect_ratio == ANY_ASPECT_RATIO) + return crop; + + // PHASE 1: Scale to the desired aspect ratio, preserving area and center. + float old_area = (float) (crop.get_width() * crop.get_height()); + crop.adjust_height((int) Math.sqrt(old_area / user_aspect_ratio)); + crop.adjust_width((int) Math.sqrt(old_area * user_aspect_ratio)); + + // PHASE 2: Crop to the image boundary. + Dimensions image_size = get_photo_dimensions(); + double angle; + canvas.get_photo().get_straighten(out angle); + crop = clamp_inside_rotated_image(crop, image_size.width, image_size.height, angle, false); + + // PHASE 3: Crop down to the aspect ratio if necessary. + if (crop.get_width() >= crop.get_height() * user_aspect_ratio) // possibly too wide + crop.adjust_width((int) (crop.get_height() * user_aspect_ratio)); + else // possibly too tall + crop.adjust_height((int) (crop.get_width() / user_aspect_ratio)); + + return crop; + } + + private ConstraintDescription? get_last_constraint(out int index) { + index = Config.Facade.get_instance().get_last_crop_menu_choice(); + + return (index < constraints.length) ? constraints[index] : null; + } + + public override void activate(PhotoCanvas canvas) { + bind_canvas_handlers(canvas); + + prepare_ctx(canvas.get_default_ctx(), canvas.get_surface_dim()); + + if (crop_surface != null) + crop_surface = null; + + crop_surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, + canvas.get_scaled_pixbuf_position().width, + canvas.get_scaled_pixbuf_position().height); + + Cairo.Context ctx = new Cairo.Context(crop_surface); + ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0); + ctx.paint(); + + // create the crop tool window, where the user can apply or cancel the crop + crop_tool_window = new CropToolWindow(canvas.get_container()); + + // set up the constraint combo box + crop_tool_window.constraint_combo.set_model(constraint_list); + if(!canvas.get_photo().has_crop()) { + int index; + ConstraintDescription? desc = get_last_constraint(out index); + if (desc != null && !desc.is_separator()) + crop_tool_window.constraint_combo.set_active(index); + } + else { + // get aspect ratio of current photo + Photo photo = canvas.get_photo(); + Dimensions cropped_dim = photo.get_dimensions(); + float ratio = (float) cropped_dim.width / (float) cropped_dim.height; + for (int index = 1; index < constraints.length; index++) { + if (Math.fabs(ratio - get_constraint_aspect_ratio_for_constraint(constraints[index], photo)) < 0.005) + crop_tool_window.constraint_combo.set_active(index); + } + } + + // set up the pivot reticle button + update_pivot_button_state(); + reticle_orientation = ReticleOrientation.LANDSCAPE; + + bind_window_handlers(); + + // obtain crop dimensions and paint against the uncropped photo + Dimensions uncropped_dim = canvas.get_photo().get_dimensions(Photo.Exception.CROP); + + Box crop; + if (!canvas.get_photo().get_crop(out crop)) { + int xofs = (int) (uncropped_dim.width * CROP_INIT_X_PCT); + int yofs = (int) (uncropped_dim.height * CROP_INIT_Y_PCT); + + // initialize the actual crop in absolute coordinates, not relative + // to the photo's position on the canvas + crop = Box(xofs, yofs, uncropped_dim.width - xofs, uncropped_dim.height - yofs); + } + + // scale the crop to the scaled photo's size ... the scaled crop is maintained in + // coordinates not relative to photo's position on canvas + scaled_crop = crop.get_scaled_similar(uncropped_dim, + Dimensions.for_rectangle(canvas.get_scaled_pixbuf_position())); + + // get the custom width and height from the saved config and + // set up the initial custom values with it. + custom_width = Config.Facade.get_instance().get_last_crop_width(); + custom_height = Config.Facade.get_instance().get_last_crop_height(); + custom_init_width = custom_width; + custom_init_height = custom_height; + pre_aspect_ratio = ((float) custom_init_width) / ((float) custom_init_height); + + constraint_mode = ConstraintMode.NORMAL; + + base.activate(canvas); + + // make sure the window has its regular size before going into + // custom mode, which will resize it and needs to save the old + // size first. + crop_tool_window.show_all(); + crop_tool_window.hide(); + + // was 'custom' the most-recently-chosen menu item? + if(!canvas.get_photo().has_crop()) { + ConstraintDescription? desc = get_last_constraint(null); + if (desc != null && !desc.is_separator() && desc.aspect_ratio == CUSTOM_ASPECT_RATIO) + set_custom_constraint_mode(); + } + + // since we no longer just run with the default, but rather + // a saved value, we'll behave as if the saved constraint has + // just been changed to so that everything gets updated and + // the canvas stays in sync. + Box new_crop = constrain_crop(scaled_crop); + + crop_resized(new_crop); + scaled_crop = new_crop; + canvas.invalidate_area(new_crop); + canvas.repaint(); + + pre_aspect_ratio = get_constraint_aspect_ratio(); + } + + private void bind_canvas_handlers(PhotoCanvas canvas) { + canvas.new_surface.connect(prepare_ctx); + canvas.resized_scaled_pixbuf.connect(on_resized_pixbuf); + } + + private void unbind_canvas_handlers(PhotoCanvas canvas) { + canvas.new_surface.disconnect(prepare_ctx); + canvas.resized_scaled_pixbuf.disconnect(on_resized_pixbuf); + } + + private void bind_window_handlers() { + crop_tool_window.key_press_event.connect(on_keypress); + crop_tool_window.ok_button.clicked.connect(on_crop_ok); + crop_tool_window.cancel_button.clicked.connect(notify_cancel); + crop_tool_window.constraint_combo.changed.connect(constraint_changed); + crop_tool_window.pivot_reticle_button.clicked.connect(on_pivot_button_clicked); + + // set up the custom width and height entry boxes + crop_tool_window.custom_width_entry.focus_out_event.connect(on_width_entry_focus_out); + crop_tool_window.custom_height_entry.focus_out_event.connect(on_height_entry_focus_out); + crop_tool_window.custom_width_entry.insert_text.connect(on_width_insert_text); + crop_tool_window.custom_height_entry.insert_text.connect(on_height_insert_text); + } + + private void unbind_window_handlers() { + crop_tool_window.key_press_event.disconnect(on_keypress); + crop_tool_window.ok_button.clicked.disconnect(on_crop_ok); + crop_tool_window.cancel_button.clicked.disconnect(notify_cancel); + crop_tool_window.constraint_combo.changed.disconnect(constraint_changed); + crop_tool_window.pivot_reticle_button.clicked.disconnect(on_pivot_button_clicked); + + // set up the custom width and height entry boxes + crop_tool_window.custom_width_entry.focus_out_event.disconnect(on_width_entry_focus_out); + crop_tool_window.custom_height_entry.focus_out_event.disconnect(on_height_entry_focus_out); + crop_tool_window.custom_width_entry.insert_text.disconnect(on_width_insert_text); + } + + public override bool on_keypress(Gdk.EventKey event) { + if ((Gdk.keyval_name(event.keyval) == "KP_Enter") || + (Gdk.keyval_name(event.keyval) == "Enter") || + (Gdk.keyval_name(event.keyval) == "Return")) { + on_crop_ok(); + return true; + } + + return base.on_keypress(event); + } + + private void on_pivot_button_clicked() { + if (get_selected_constraint().aspect_ratio == CUSTOM_ASPECT_RATIO) { + string width_text = crop_tool_window.custom_width_entry.get_text(); + string height_text = crop_tool_window.custom_height_entry.get_text(); + crop_tool_window.custom_width_entry.set_text(height_text); + crop_tool_window.custom_height_entry.set_text(width_text); + + int temp = custom_width; + custom_width = custom_height; + custom_height = temp; + } + reticle_orientation = reticle_orientation.toggle(); + constraint_changed(); + } + + public override void deactivate() { + if (canvas != null) + unbind_canvas_handlers(canvas); + + if (crop_tool_window != null) { + unbind_window_handlers(); + crop_tool_window.hide(); + crop_tool_window.destroy(); + crop_tool_window = null; + } + + // make sure the cursor isn't set to a modify indicator + if (canvas != null) + canvas.get_drawing_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + + crop_surface = null; + + base.deactivate(); + } + + public override EditingToolWindow? get_tool_window() { + return crop_tool_window; + } + + public override Gdk.Pixbuf? get_display_pixbuf(Scaling scaling, Photo photo, + out Dimensions max_dim) throws Error { + max_dim = photo.get_dimensions(Photo.Exception.CROP); + + return photo.get_pixbuf_with_options(scaling, Photo.Exception.CROP); + } + + private void prepare_ctx(Cairo.Context ctx, Dimensions dim) { + wide_black_ctx = new Cairo.Context(ctx.get_target()); + set_source_color_from_string(wide_black_ctx, "#000"); + wide_black_ctx.set_line_width(1); + + wide_white_ctx = new Cairo.Context(ctx.get_target()); + set_source_color_from_string(wide_white_ctx, "#FFF"); + wide_white_ctx.set_line_width(1); + + thin_white_ctx = new Cairo.Context(ctx.get_target()); + set_source_color_from_string(thin_white_ctx, "#FFF"); + thin_white_ctx.set_line_width(0.5); + + text_ctx = new Cairo.Context(ctx.get_target()); + text_ctx.select_font_face("Sans", Cairo.FontSlant.NORMAL, Cairo.FontWeight.NORMAL); + } + + private void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) { + Dimensions new_dim = Dimensions.for_pixbuf(scaled); + Dimensions uncropped_dim = canvas.get_photo().get_dimensions(Photo.Exception.CROP); + + // rescale to full crop + Box crop = scaled_crop.get_scaled_similar(old_dim, uncropped_dim); + + // rescale back to new size + scaled_crop = crop.get_scaled_similar(uncropped_dim, new_dim); + if (crop_surface != null) + crop_surface = null; + + crop_surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, scaled.width, scaled.height); + Cairo.Context ctx = new Cairo.Context(crop_surface); + ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0); + ctx.paint(); + + } + + public override void on_left_click(int x, int y) { + Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position(); + + // scaled_crop is not maintained relative to photo's position on canvas + Box offset_scaled_crop = scaled_crop.get_offset(scaled_pixbuf_pos.x, scaled_pixbuf_pos.y); + + // determine where the mouse down landed and store for future events + in_manipulation = offset_scaled_crop.approx_location(x, y); + last_grab_x = x -= scaled_pixbuf_pos.x; + last_grab_y = y -= scaled_pixbuf_pos.y; + + // repaint because the crop changes on a mouse down + canvas.repaint(); + } + + public override void on_left_released(int x, int y) { + // nothing to do if released outside of the crop box + if (in_manipulation == BoxLocation.OUTSIDE) + return; + + // end manipulation + in_manipulation = BoxLocation.OUTSIDE; + last_grab_x = -1; + last_grab_y = -1; + + update_cursor(x, y); + + // repaint because crop changes when released + canvas.repaint(); + } + + public override void on_motion(int x, int y, Gdk.ModifierType mask) { + // only deal with manipulating the crop tool when click-and-dragging one of the edges + // or the interior + if (in_manipulation != BoxLocation.OUTSIDE) + on_canvas_manipulation(x, y); + + update_cursor(x, y); + canvas.repaint(); + } + + public override void paint(Cairo.Context default_ctx) { + // fill region behind the crop surface with neutral color + int w = canvas.get_drawing_window().get_width(); + int h = canvas.get_drawing_window().get_height(); + + default_ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0); + default_ctx.rectangle(0, 0, w, h); + default_ctx.fill(); + default_ctx.paint(); + + Cairo.Context ctx = new Cairo.Context(crop_surface); + ctx.set_operator(Cairo.Operator.SOURCE); + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.5); + ctx.paint(); + + // paint exposed (cropped) part of pixbuf minus crop border + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.0); + ctx.rectangle(scaled_crop.left, scaled_crop.top, scaled_crop.get_width(), + scaled_crop.get_height()); + ctx.fill(); + canvas.paint_surface(crop_surface, true); + + // paint crop tool last + paint_crop_tool(scaled_crop); + } + + private void on_crop_ok() { + // user's clicked OK, save the combobox choice and width/height. + // safe to do, even if not in 'custom' mode - the previous values + // will just get saved again. + Config.Facade.get_instance().set_last_crop_menu_choice( + crop_tool_window.constraint_combo.get_active()); + Config.Facade.get_instance().set_last_crop_width(custom_width); + Config.Facade.get_instance().set_last_crop_height(custom_height); + + // scale screen-coordinate crop to photo's coordinate system + Box crop = scaled_crop.get_scaled_similar( + Dimensions.for_rectangle(canvas.get_scaled_pixbuf_position()), + canvas.get_photo().get_dimensions(Photo.Exception.CROP)); + + // crop the current pixbuf and offer it to the editing host + Gdk.Pixbuf cropped = new Gdk.Pixbuf.subpixbuf(canvas.get_scaled_pixbuf(), scaled_crop.left, + scaled_crop.top, scaled_crop.get_width(), scaled_crop.get_height()); + + // signal host; we have a cropped image, but it will be scaled upward, and so a better one + // should be fetched + applied(new CropCommand(canvas.get_photo(), crop, Resources.CROP_LABEL, + Resources.CROP_TOOLTIP), cropped, crop.get_dimensions(), true); + } + + private void update_cursor(int x, int y) { + // scaled_crop is not maintained relative to photo's position on canvas + Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position(); + Box offset_scaled_crop = scaled_crop.get_offset(scaled_pos.x, scaled_pos.y); + + Gdk.CursorType cursor_type = Gdk.CursorType.LEFT_PTR; + switch (offset_scaled_crop.approx_location(x, y)) { + case BoxLocation.LEFT_SIDE: + cursor_type = Gdk.CursorType.LEFT_SIDE; + break; + + case BoxLocation.TOP_SIDE: + cursor_type = Gdk.CursorType.TOP_SIDE; + break; + + case BoxLocation.RIGHT_SIDE: + cursor_type = Gdk.CursorType.RIGHT_SIDE; + break; + + case BoxLocation.BOTTOM_SIDE: + cursor_type = Gdk.CursorType.BOTTOM_SIDE; + break; + + case BoxLocation.TOP_LEFT: + cursor_type = Gdk.CursorType.TOP_LEFT_CORNER; + break; + + case BoxLocation.BOTTOM_LEFT: + cursor_type = Gdk.CursorType.BOTTOM_LEFT_CORNER; + break; + + case BoxLocation.TOP_RIGHT: + cursor_type = Gdk.CursorType.TOP_RIGHT_CORNER; + break; + + case BoxLocation.BOTTOM_RIGHT: + cursor_type = Gdk.CursorType.BOTTOM_RIGHT_CORNER; + break; + + case BoxLocation.INSIDE: + cursor_type = Gdk.CursorType.FLEUR; + break; + + default: + // use Gdk.CursorType.LEFT_PTR + break; + } + + if (cursor_type != current_cursor_type) { + Gdk.Cursor cursor = new Gdk.Cursor(cursor_type); + canvas.get_drawing_window().set_cursor(cursor); + current_cursor_type = cursor_type; + } + } + + private int eval_radial_line(double center_x, double center_y, double bounds_x, + double bounds_y, double user_x) { + double decision_slope = (bounds_y - center_y) / (bounds_x - center_x); + double decision_intercept = bounds_y - (decision_slope * bounds_x); + + return (int) (decision_slope * user_x + decision_intercept); + } + + // Return the dimensions of the uncropped source photo scaled to canvas coordinates. + private Dimensions get_photo_dimensions() { + Dimensions photo_dims = canvas.get_photo().get_dimensions(Photo.Exception.CROP); + Dimensions surface_dims = canvas.get_surface_dim(); + double scale_factor = double.min((double) surface_dims.width / photo_dims.width, + (double) surface_dims.height / photo_dims.height); + scale_factor = double.min(scale_factor, 1.0); + + photo_dims = canvas.get_photo().get_dimensions( + Photo.Exception.CROP | Photo.Exception.STRAIGHTEN); + + return { (int) (photo_dims.width * scale_factor), + (int) (photo_dims.height * scale_factor) }; + } + + private bool on_canvas_manipulation(int x, int y) { + Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position(); + + // scaled_crop is maintained in coordinates non-relative to photo's position on canvas ... + // but bound tool to photo itself + x -= scaled_pos.x; + if (x < 0) + x = 0; + else if (x >= scaled_pos.width) + x = scaled_pos.width - 1; + + y -= scaled_pos.y; + if (y < 0) + y = 0; + else if (y >= scaled_pos.height) + y = scaled_pos.height - 1; + + // need to make manipulations outside of box structure, because its methods do sanity + // checking + int left = scaled_crop.left; + int top = scaled_crop.top; + int right = scaled_crop.right; + int bottom = scaled_crop.bottom; + + // get extra geometric information needed to enforce constraints + int center_x = (left + right) / 2; + int center_y = (top + bottom) / 2; + + switch (in_manipulation) { + case BoxLocation.LEFT_SIDE: + left = x; + if (get_constraint_aspect_ratio() != ANY_ASPECT_RATIO) { + float new_height = ((float) (right - left)) / get_constraint_aspect_ratio(); + bottom = top + ((int) new_height); + } + break; + + case BoxLocation.TOP_SIDE: + top = y; + if (get_constraint_aspect_ratio() != ANY_ASPECT_RATIO) { + float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio(); + right = left + ((int) new_width); + } + break; + + case BoxLocation.RIGHT_SIDE: + right = x; + if (get_constraint_aspect_ratio() != ANY_ASPECT_RATIO) { + float new_height = ((float) (right - left)) / get_constraint_aspect_ratio(); + bottom = top + ((int) new_height); + } + break; + + case BoxLocation.BOTTOM_SIDE: + bottom = y; + if (get_constraint_aspect_ratio() != ANY_ASPECT_RATIO) { + float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio(); + right = left + ((int) new_width); + } + break; + + case BoxLocation.TOP_LEFT: + if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) { + top = y; + left = x; + } else { + if (y < eval_radial_line(center_x, center_y, left, top, x)) { + top = y; + float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio(); + left = right - ((int) new_width); + } else { + left = x; + float new_height = ((float) (right - left)) / get_constraint_aspect_ratio(); + top = bottom - ((int) new_height); + } + } + break; + + case BoxLocation.BOTTOM_LEFT: + if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) { + bottom = y; + left = x; + } else { + if (y < eval_radial_line(center_x, center_y, left, bottom, x)) { + left = x; + float new_height = ((float) (right - left)) / get_constraint_aspect_ratio(); + bottom = top + ((int) new_height); + } else { + bottom = y; + float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio(); + left = right - ((int) new_width); + } + } + break; + + case BoxLocation.TOP_RIGHT: + if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) { + top = y; + right = x; + } else { + if (y < eval_radial_line(center_x, center_y, right, top, x)) { + top = y; + float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio(); + right = left + ((int) new_width); + } else { + right = x; + float new_height = ((float) (right - left)) / get_constraint_aspect_ratio(); + top = bottom - ((int) new_height); + } + } + break; + + case BoxLocation.BOTTOM_RIGHT: + if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) { + bottom = y; + right = x; + } else { + if (y < eval_radial_line(center_x, center_y, right, bottom, x)) { + right = x; + float new_height = ((float) (right - left)) / get_constraint_aspect_ratio(); + bottom = top + ((int) new_height); + } else { + bottom = y; + float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio(); + right = left + ((int) new_width); + } + } + break; + + case BoxLocation.INSIDE: + assert(last_grab_x >= 0); + assert(last_grab_y >= 0); + + int delta_x = (x - last_grab_x); + int delta_y = (y - last_grab_y); + + last_grab_x = x; + last_grab_y = y; + + int width = right - left + 1; + int height = bottom - top + 1; + + left += delta_x; + top += delta_y; + right += delta_x; + bottom += delta_y; + + // bound crop inside of photo + if (left < 0) + left = 0; + + if (top < 0) + top = 0; + + if (right >= scaled_pos.width) + right = scaled_pos.width - 1; + + if (bottom >= scaled_pos.height) + bottom = scaled_pos.height - 1; + + int adj_width = right - left + 1; + int adj_height = bottom - top + 1; + + // don't let adjustments affect the size of the crop + if (adj_width != width) { + if (delta_x < 0) + right = left + width - 1; + else + left = right - width + 1; + } + + if (adj_height != height) { + if (delta_y < 0) + bottom = top + height - 1; + else + top = bottom - height + 1; + } + break; + + default: + // do nothing, not even a repaint + return false; + } + + // Check if the mouse has gone out of bounds, and if it has, make sure that the + // crop reticle's edges stay within the photo bounds. This bounds check works + // differently in constrained versus unconstrained mode. In unconstrained mode, + // we need only to bounds clamp the one or two edge(s) that are actually out-of-bounds. + // In constrained mode however, we need to bounds clamp the entire box, because the + // positions of edges are all interdependent (so as to enforce the aspect ratio + // constraint). + int width = right - left + 1; + int height = bottom - top + 1; + + Dimensions photo_dims = get_photo_dimensions(); + double angle; + canvas.get_photo().get_straighten(out angle); + + Box new_crop; + if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) { + width = right - left + 1; + height = bottom - top + 1; + + switch (in_manipulation) { + case BoxLocation.LEFT_SIDE: + case BoxLocation.TOP_LEFT: + case BoxLocation.BOTTOM_LEFT: + if (width < CROP_MIN_SIZE) + left = right - CROP_MIN_SIZE; + break; + + case BoxLocation.RIGHT_SIDE: + case BoxLocation.TOP_RIGHT: + case BoxLocation.BOTTOM_RIGHT: + if (width < CROP_MIN_SIZE) + right = left + CROP_MIN_SIZE; + break; + + default: + break; + } + + switch (in_manipulation) { + case BoxLocation.TOP_SIDE: + case BoxLocation.TOP_LEFT: + case BoxLocation.TOP_RIGHT: + if (height < CROP_MIN_SIZE) + top = bottom - CROP_MIN_SIZE; + break; + + case BoxLocation.BOTTOM_SIDE: + case BoxLocation.BOTTOM_LEFT: + case BoxLocation.BOTTOM_RIGHT: + if (height < CROP_MIN_SIZE) + bottom = top + CROP_MIN_SIZE; + break; + + default: + break; + } + + // preliminary crop region has been chosen, now clamp it inside the + // image as needed. + + new_crop = clamp_inside_rotated_image( + Box(left, top, right, bottom), + photo_dims.width, photo_dims.height, angle, + in_manipulation == BoxLocation.INSIDE); + + } else { + // one of the constrained modes is active; revert instead of clamping so + // that aspect ratio stays intact + + new_crop = Box(left, top, right, bottom); + Box adjusted = clamp_inside_rotated_image(new_crop, + photo_dims.width, photo_dims.height, angle, + in_manipulation == BoxLocation.INSIDE); + + if (adjusted != new_crop || width < CROP_MIN_SIZE || height < CROP_MIN_SIZE) { + new_crop = scaled_crop; // revert crop move + } + } + + if (in_manipulation != BoxLocation.INSIDE) + crop_resized(new_crop); + else + crop_moved(new_crop); + + // load new values + scaled_crop = new_crop; + + if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) { + custom_init_width = scaled_crop.get_width(); + custom_init_height = scaled_crop.get_height(); + custom_aspect_ratio = ((float) custom_init_width) / ((float) custom_init_height); + } + + return false; + } + + private void crop_resized(Box new_crop) { + if(scaled_crop.equals(new_crop)) { + // no change + return; + } + + canvas.invalidate_area(scaled_crop); + + Box horizontal; + bool horizontal_enlarged; + Box vertical; + bool vertical_enlarged; + BoxComplements complements = scaled_crop.resized_complements(new_crop, out horizontal, + out horizontal_enlarged, out vertical, out vertical_enlarged); + + // this should never happen ... this means that the operation wasn't a resize + assert(complements != BoxComplements.NONE); + + if (complements == BoxComplements.HORIZONTAL || complements == BoxComplements.BOTH) + set_area_alpha(horizontal, horizontal_enlarged ? 0.0 : 0.5); + + if (complements == BoxComplements.VERTICAL || complements == BoxComplements.BOTH) + set_area_alpha(vertical, vertical_enlarged ? 0.0 : 0.5); + + paint_crop_tool(new_crop); + canvas.invalidate_area(new_crop); + } + + private void crop_moved(Box new_crop) { + if (scaled_crop.equals(new_crop)) { + // no change + return; + } + + canvas.invalidate_area(scaled_crop); + + set_area_alpha(scaled_crop, 0.5); + set_area_alpha(new_crop, 0.0); + + + // paint crop in new location + paint_crop_tool(new_crop); + canvas.invalidate_area(new_crop); + } + + private void set_area_alpha(Box area, double alpha) { + Cairo.Context ctx = new Cairo.Context(crop_surface); + ctx.set_operator(Cairo.Operator.SOURCE); + ctx.set_source_rgba(0.0, 0.0, 0.0, alpha); + ctx.rectangle(area.left, area.top, area.get_width(), area.get_height()); + ctx.fill(); + canvas.paint_surface_area(crop_surface, area, true); + } + + private void paint_crop_tool(Box crop) { + // paint rule-of-thirds lines and current dimensions if user is manipulating the crop + if (in_manipulation != BoxLocation.OUTSIDE) { + int one_third_x = crop.get_width() / 3; + int one_third_y = crop.get_height() / 3; + + canvas.draw_horizontal_line(thin_white_ctx, crop.left, crop.top + one_third_y, crop.get_width()); + canvas.draw_horizontal_line(thin_white_ctx, crop.left, crop.top + (one_third_y * 2), crop.get_width()); + + canvas.draw_vertical_line(thin_white_ctx, crop.left + one_third_x, crop.top, crop.get_height()); + canvas.draw_vertical_line(thin_white_ctx, crop.left + (one_third_x * 2), crop.top, crop.get_height()); + + // current dimensions + // scale screen-coordinate crop to photo's coordinate system + Box adj_crop = scaled_crop.get_scaled_similar( + Dimensions.for_rectangle(canvas.get_scaled_pixbuf_position()), + canvas.get_photo().get_dimensions(Photo.Exception.CROP)); + string text = adj_crop.get_width().to_string() + "x" + adj_crop.get_height().to_string(); + int x = crop.left + crop.get_width() / 2; + int y = crop.top + crop.get_height() / 2; + canvas.draw_text(text_ctx, text, x, y); + } + + // outer rectangle ... outer line in black, inner in white, corners fully black + canvas.draw_box(wide_black_ctx, crop); + canvas.draw_box(wide_white_ctx, crop.get_reduced(1)); + canvas.draw_box(wide_white_ctx, crop.get_reduced(2)); + } + +} + +public struct RedeyeInstance { + public const int MIN_RADIUS = 4; + public const int MAX_RADIUS = 32; + public const int DEFAULT_RADIUS = 10; + + public Gdk.Point center; + public int radius; + + RedeyeInstance() { + Gdk.Point default_center = Gdk.Point(); + center = default_center; + radius = DEFAULT_RADIUS; + } + + public static Gdk.Rectangle to_bounds_rect(EditingTools.RedeyeInstance inst) { + Gdk.Rectangle result = Gdk.Rectangle(); + result.x = inst.center.x - inst.radius; + result.y = inst.center.y - inst.radius; + result.width = 2 * inst.radius; + result.height = result.width; + + return result; + } + + public static RedeyeInstance from_bounds_rect(Gdk.Rectangle rect) { + Gdk.Rectangle in_rect = rect; + + RedeyeInstance result = RedeyeInstance(); + result.radius = (in_rect.width + in_rect.height) / 4; + result.center.x = in_rect.x + result.radius; + result.center.y = in_rect.y + result.radius; + + return result; + } +} + +public class RedeyeTool : EditingTool { + private class RedeyeToolWindow : EditingToolWindow { + private const int CONTROL_SPACING = 8; + + private Gtk.Label slider_label = new Gtk.Label.with_mnemonic(_("Size:")); + + public Gtk.Button apply_button = + new Gtk.Button.from_stock(Gtk.Stock.APPLY); + public Gtk.Button close_button = + new Gtk.Button.from_stock(Gtk.Stock.CLOSE); + public Gtk.Scale slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL, + RedeyeInstance.MIN_RADIUS, RedeyeInstance.MAX_RADIUS, 1.0); + + public RedeyeToolWindow(Gtk.Window container) { + base(container); + + slider.set_size_request(80, -1); + slider.set_draw_value(false); + + close_button.set_tooltip_text(_("Close the red-eye tool")); + close_button.set_image_position(Gtk.PositionType.LEFT); + + apply_button.set_tooltip_text(_("Remove any red-eye effects in the selected region")); + apply_button.set_image_position(Gtk.PositionType.LEFT); + + Gtk.Box layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING); + layout.add(slider_label); + layout.add(slider); + layout.add(close_button); + layout.add(apply_button); + + add(layout); + } + } + + private Cairo.Context thin_white_ctx = null; + private Cairo.Context wider_gray_ctx = null; + private RedeyeToolWindow redeye_tool_window = null; + private RedeyeInstance user_interaction_instance; + private bool is_reticle_move_in_progress = false; + private Gdk.Point reticle_move_mouse_start_point; + private Gdk.Point reticle_move_anchor; + private Gdk.Cursor cached_arrow_cursor; + private Gdk.Cursor cached_grab_cursor; + private Gdk.Rectangle old_scaled_pixbuf_position; + private Gdk.Pixbuf current_pixbuf = null; + + private RedeyeTool() { + base("RedeyeTool"); + } + + public static RedeyeTool factory() { + return new RedeyeTool(); + } + + public static bool is_available(Photo photo, Scaling scaling) { + Dimensions dim = scaling.get_scaled_dimensions(photo.get_dimensions()); + + return dim.width >= (RedeyeInstance.MAX_RADIUS * 2) + && dim.height >= (RedeyeInstance.MAX_RADIUS * 2); + } + + private RedeyeInstance new_interaction_instance(PhotoCanvas canvas) { + Gdk.Rectangle photo_bounds = canvas.get_scaled_pixbuf_position(); + Gdk.Point photo_center = {0}; + photo_center.x = photo_bounds.x + (photo_bounds.width / 2); + photo_center.y = photo_bounds.y + (photo_bounds.height / 2); + + RedeyeInstance result = RedeyeInstance(); + result.center.x = photo_center.x; + result.center.y = photo_center.y; + result.radius = RedeyeInstance.DEFAULT_RADIUS; + + return result; + } + + private void prepare_ctx(Cairo.Context ctx, Dimensions dim) { + wider_gray_ctx = new Cairo.Context(ctx.get_target()); + set_source_color_from_string(wider_gray_ctx, "#111"); + wider_gray_ctx.set_line_width(3); + + thin_white_ctx = new Cairo.Context(ctx.get_target()); + set_source_color_from_string(thin_white_ctx, "#FFF"); + thin_white_ctx.set_line_width(1); + } + + private void draw_redeye_instance(RedeyeInstance inst) { + canvas.draw_circle(wider_gray_ctx, inst.center.x, inst.center.y, + inst.radius); + canvas.draw_circle(thin_white_ctx, inst.center.x, inst.center.y, + inst.radius); + } + + private bool on_size_slider_adjust(Gtk.ScrollType type) { + user_interaction_instance.radius = + (int) redeye_tool_window.slider.get_value(); + + canvas.repaint(); + + return false; + } + + private void on_apply() { + Gdk.Rectangle bounds_rect_user = + RedeyeInstance.to_bounds_rect(user_interaction_instance); + + Gdk.Rectangle bounds_rect_active = + canvas.user_to_active_rect(bounds_rect_user); + Gdk.Rectangle bounds_rect_unscaled = + canvas.active_to_unscaled_rect(bounds_rect_active); + Gdk.Rectangle bounds_rect_raw = + canvas.unscaled_to_raw_rect(bounds_rect_unscaled); + + RedeyeInstance instance_raw = + RedeyeInstance.from_bounds_rect(bounds_rect_raw); + + // transform screen coords back to image coords, + // taking into account straightening angle. + Dimensions dimensions = canvas.get_photo().get_dimensions( + Photo.Exception.STRAIGHTEN | Photo.Exception.CROP); + + double theta = 0.0; + + canvas.get_photo().get_straighten(out theta); + + instance_raw.center = derotate_point_arb(instance_raw.center, + dimensions.width, dimensions.height, theta); + + RedeyeCommand command = new RedeyeCommand(canvas.get_photo(), instance_raw, + Resources.RED_EYE_LABEL, Resources.RED_EYE_TOOLTIP); + AppWindow.get_command_manager().execute(command); + } + + private void on_photos_altered(Gee.Map<DataObject, Alteration> map) { + if (!map.has_key(canvas.get_photo())) + return; + + try { + current_pixbuf = canvas.get_photo().get_pixbuf(canvas.get_scaling()); + } catch (Error err) { + warning("%s", err.message); + aborted(); + + return; + } + + canvas.repaint(); + } + + private void on_close() { + applied(null, current_pixbuf, canvas.get_photo().get_dimensions(), false); + } + + private void on_canvas_resize() { + Gdk.Rectangle scaled_pixbuf_position = + canvas.get_scaled_pixbuf_position(); + + user_interaction_instance.center.x -= old_scaled_pixbuf_position.x; + user_interaction_instance.center.y -= old_scaled_pixbuf_position.y; + + double scale_factor = ((double) scaled_pixbuf_position.width) / + ((double) old_scaled_pixbuf_position.width); + + user_interaction_instance.center.x = + (int)(((double) user_interaction_instance.center.x) * + scale_factor + 0.5); + user_interaction_instance.center.y = + (int)(((double) user_interaction_instance.center.y) * + scale_factor + 0.5); + + user_interaction_instance.center.x += scaled_pixbuf_position.x; + user_interaction_instance.center.y += scaled_pixbuf_position.y; + + old_scaled_pixbuf_position = scaled_pixbuf_position; + + current_pixbuf = null; + } + + public override void activate(PhotoCanvas canvas) { + user_interaction_instance = new_interaction_instance(canvas); + + prepare_ctx(canvas.get_default_ctx(), canvas.get_surface_dim()); + + bind_canvas_handlers(canvas); + + old_scaled_pixbuf_position = canvas.get_scaled_pixbuf_position(); + current_pixbuf = canvas.get_scaled_pixbuf(); + + redeye_tool_window = new RedeyeToolWindow(canvas.get_container()); + redeye_tool_window.slider.set_value(user_interaction_instance.radius); + + bind_window_handlers(); + + cached_arrow_cursor = new Gdk.Cursor(Gdk.CursorType.LEFT_PTR); + cached_grab_cursor = new Gdk.Cursor(Gdk.CursorType.FLEUR); + + DataCollection? owner = canvas.get_photo().get_membership(); + if (owner != null) + owner.items_altered.connect(on_photos_altered); + + base.activate(canvas); + } + + public override void deactivate() { + if (canvas != null) { + DataCollection? owner = canvas.get_photo().get_membership(); + if (owner != null) + owner.items_altered.disconnect(on_photos_altered); + + unbind_canvas_handlers(canvas); + } + + if (redeye_tool_window != null) { + unbind_window_handlers(); + redeye_tool_window.hide(); + redeye_tool_window.destroy(); + redeye_tool_window = null; + } + + base.deactivate(); + } + + private void bind_canvas_handlers(PhotoCanvas canvas) { + canvas.new_surface.connect(prepare_ctx); + canvas.resized_scaled_pixbuf.connect(on_canvas_resize); + } + + private void unbind_canvas_handlers(PhotoCanvas canvas) { + canvas.new_surface.disconnect(prepare_ctx); + canvas.resized_scaled_pixbuf.disconnect(on_canvas_resize); + } + + private void bind_window_handlers() { + redeye_tool_window.apply_button.clicked.connect(on_apply); + redeye_tool_window.close_button.clicked.connect(on_close); + redeye_tool_window.slider.change_value.connect(on_size_slider_adjust); + } + + private void unbind_window_handlers() { + redeye_tool_window.apply_button.clicked.disconnect(on_apply); + redeye_tool_window.close_button.clicked.disconnect(on_close); + redeye_tool_window.slider.change_value.disconnect(on_size_slider_adjust); + } + + public override EditingToolWindow? get_tool_window() { + return redeye_tool_window; + } + + public override void paint(Cairo.Context ctx) { + canvas.paint_pixbuf((current_pixbuf != null) ? current_pixbuf : canvas.get_scaled_pixbuf()); + + /* user_interaction_instance has its radius in user coords, and + draw_redeye_instance expects active region coords */ + RedeyeInstance active_inst = user_interaction_instance; + active_inst.center = + canvas.user_to_active_point(user_interaction_instance.center); + draw_redeye_instance(active_inst); + } + + public override void on_left_click(int x, int y) { + Gdk.Rectangle bounds_rect = + RedeyeInstance.to_bounds_rect(user_interaction_instance); + + if (coord_in_rectangle(x, y, bounds_rect)) { + is_reticle_move_in_progress = true; + reticle_move_mouse_start_point.x = x; + reticle_move_mouse_start_point.y = y; + reticle_move_anchor = user_interaction_instance.center; + } + } + + public override void on_left_released(int x, int y) { + is_reticle_move_in_progress = false; + } + + public override void on_motion(int x, int y, Gdk.ModifierType mask) { + if (is_reticle_move_in_progress) { + + Gdk.Rectangle active_region_rect = + canvas.get_scaled_pixbuf_position(); + + int x_clamp_low = + active_region_rect.x + user_interaction_instance.radius + 1; + int y_clamp_low = + active_region_rect.y + user_interaction_instance.radius + 1; + int x_clamp_high = + active_region_rect.x + active_region_rect.width - + user_interaction_instance.radius - 1; + int y_clamp_high = + active_region_rect.y + active_region_rect.height - + user_interaction_instance.radius - 1; + + int delta_x = x - reticle_move_mouse_start_point.x; + int delta_y = y - reticle_move_mouse_start_point.y; + + user_interaction_instance.center.x = reticle_move_anchor.x + + delta_x; + user_interaction_instance.center.y = reticle_move_anchor.y + + delta_y; + + user_interaction_instance.center.x = + (reticle_move_anchor.x + delta_x).clamp(x_clamp_low, + x_clamp_high); + user_interaction_instance.center.y = + (reticle_move_anchor.y + delta_y).clamp(y_clamp_low, + y_clamp_high); + + canvas.repaint(); + } else { + Gdk.Rectangle bounds = + RedeyeInstance.to_bounds_rect(user_interaction_instance); + + if (coord_in_rectangle(x, y, bounds)) { + canvas.get_drawing_window().set_cursor(cached_grab_cursor); + } else { + canvas.get_drawing_window().set_cursor(cached_arrow_cursor); + } + } + } + + public override bool on_keypress(Gdk.EventKey event) { + if ((Gdk.keyval_name(event.keyval) == "KP_Enter") || + (Gdk.keyval_name(event.keyval) == "Enter") || + (Gdk.keyval_name(event.keyval) == "Return")) { + on_close(); + return true; + } + + return base.on_keypress(event); + } +} + +public class AdjustTool : EditingTool { + private const int SLIDER_WIDTH = 160; + private const uint SLIDER_DELAY_MSEC = 100; + + private class AdjustToolWindow : EditingToolWindow { + public Gtk.Scale exposure_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL, + ExposureTransformation.MIN_PARAMETER, ExposureTransformation.MAX_PARAMETER, + 1.0); + public Gtk.Scale saturation_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL, + SaturationTransformation.MIN_PARAMETER, SaturationTransformation.MAX_PARAMETER, + 1.0); + public Gtk.Scale tint_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL, + TintTransformation.MIN_PARAMETER, TintTransformation.MAX_PARAMETER, 1.0); + public Gtk.Scale temperature_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL, + TemperatureTransformation.MIN_PARAMETER, TemperatureTransformation.MAX_PARAMETER, + 1.0); + + public Gtk.Scale shadows_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL, + ShadowDetailTransformation.MIN_PARAMETER, ShadowDetailTransformation.MAX_PARAMETER, + 1.0); + + public Gtk.Scale highlights_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL, + HighlightDetailTransformation.MIN_PARAMETER, HighlightDetailTransformation.MAX_PARAMETER, + 1.0); + + public Gtk.Button ok_button = new Gtk.Button.from_stock(Gtk.Stock.OK); + public Gtk.Button reset_button = new Gtk.Button.with_mnemonic(_("_Reset")); + public Gtk.Button cancel_button = new Gtk.Button.from_stock(Gtk.Stock.CANCEL); + public RGBHistogramManipulator histogram_manipulator = new RGBHistogramManipulator(); + + public AdjustToolWindow(Gtk.Window container) { + base(container); + + Gtk.Grid slider_organizer = new Gtk.Grid(); + slider_organizer.set_column_homogeneous(false); + slider_organizer.set_row_spacing(12); + slider_organizer.set_column_spacing(12); + slider_organizer.set_margin_left(12); + slider_organizer.set_margin_bottom(12); + + Gtk.Label exposure_label = new Gtk.Label.with_mnemonic(_("Exposure:")); + exposure_label.set_alignment(0.0f, 0.5f); + slider_organizer.attach(exposure_label, 0, 0, 1, 1); + slider_organizer.attach(exposure_slider, 1, 0, 1, 1); + exposure_slider.set_size_request(SLIDER_WIDTH, -1); + exposure_slider.set_draw_value(false); + exposure_slider.set_margin_right(0); + + Gtk.Label saturation_label = new Gtk.Label.with_mnemonic(_("Saturation:")); + saturation_label.set_alignment(0.0f, 0.5f); + slider_organizer.attach(saturation_label, 0, 1, 1, 1); + slider_organizer.attach(saturation_slider, 1, 1, 1, 1); + saturation_slider.set_size_request(SLIDER_WIDTH, -1); + saturation_slider.set_draw_value(false); + saturation_slider.set_margin_right(0); + + Gtk.Label tint_label = new Gtk.Label.with_mnemonic(_("Tint:")); + tint_label.set_alignment(0.0f, 0.5f); + slider_organizer.attach(tint_label, 0, 2, 1, 1); + slider_organizer.attach(tint_slider, 1, 2, 1, 1); + tint_slider.set_size_request(SLIDER_WIDTH, -1); + tint_slider.set_draw_value(false); + tint_slider.set_margin_right(0); + + Gtk.Label temperature_label = + new Gtk.Label.with_mnemonic(_("Temperature:")); + temperature_label.set_alignment(0.0f, 0.5f); + slider_organizer.attach(temperature_label, 0, 3, 1, 1); + slider_organizer.attach(temperature_slider, 1, 3, 1, 1); + temperature_slider.set_size_request(SLIDER_WIDTH, -1); + temperature_slider.set_draw_value(false); + temperature_slider.set_margin_right(0); + + Gtk.Label shadows_label = new Gtk.Label.with_mnemonic(_("Shadows:")); + shadows_label.set_alignment(0.0f, 0.5f); + slider_organizer.attach(shadows_label, 0, 4, 1, 1); + slider_organizer.attach(shadows_slider, 1, 4, 1, 1); + shadows_slider.set_size_request(SLIDER_WIDTH, -1); + shadows_slider.set_draw_value(false); + shadows_slider.set_margin_right(0); + + Gtk.Label highlights_label = new Gtk.Label.with_mnemonic(_("Highlights:")); + highlights_label.set_alignment(0.0f, 0.5f); + slider_organizer.attach(highlights_label, 0, 5, 1, 1); + slider_organizer.attach(highlights_slider, 1, 5, 1, 1); + highlights_slider.set_size_request(SLIDER_WIDTH, -1); + highlights_slider.set_draw_value(false); + + Gtk.Box button_layouter = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8); + button_layouter.set_homogeneous(true); + button_layouter.pack_start(cancel_button, true, true, 1); + button_layouter.pack_start(reset_button, true, true, 1); + button_layouter.pack_start(ok_button, true, true, 1); + + Gtk.Alignment histogram_aligner = new Gtk.Alignment(0.0f, 0.0f, 0.0f, 0.0f); + histogram_aligner.add(histogram_manipulator); + histogram_aligner.set_padding(12, 8, 12, 12); + + Gtk.Box pane_layouter = new Gtk.Box(Gtk.Orientation.VERTICAL, 8); + pane_layouter.add(histogram_aligner); + pane_layouter.add(slider_organizer); + pane_layouter.add(button_layouter); + pane_layouter.set_child_packing(histogram_aligner, true, true, 0, Gtk.PackType.START); + + add(pane_layouter); + } + } + + private abstract class AdjustToolCommand : Command { + protected weak AdjustTool owner; + + public AdjustToolCommand(AdjustTool owner, string name, string explanation) { + base (name, explanation); + + this.owner = owner; + owner.deactivated.connect(on_owner_deactivated); + } + + ~AdjustToolCommand() { + if (owner != null) + owner.deactivated.disconnect(on_owner_deactivated); + } + + private void on_owner_deactivated() { + // This reset call is by design. See notes on ticket #1946 if this is undesirable or if + // you are planning to change it. + AppWindow.get_command_manager().reset(); + } + } + + private class AdjustResetCommand : AdjustToolCommand { + private PixelTransformationBundle original; + private PixelTransformationBundle reset; + + public AdjustResetCommand(AdjustTool owner, PixelTransformationBundle current) { + base (owner, _("Reset Colors"), _("Reset all color adjustments to original")); + + original = current.copy(); + reset = new PixelTransformationBundle(); + reset.set_to_identity(); + } + + public override void execute() { + owner.set_adjustments(reset); + } + + public override void undo() { + owner.set_adjustments(original); + } + + public override bool compress(Command command) { + AdjustResetCommand reset_command = command as AdjustResetCommand; + if (reset_command == null) + return false; + + if (reset_command.owner != owner) + return false; + + // multiple successive resets on the same photo as good as a single + return true; + } + } + + private class SliderAdjustmentCommand : AdjustToolCommand { + private PixelTransformationType transformation_type; + private PixelTransformation new_transformation; + private PixelTransformation old_transformation; + + public SliderAdjustmentCommand(AdjustTool owner, PixelTransformation old_transformation, + PixelTransformation new_transformation, string name) { + base(owner, name, name); + + this.old_transformation = old_transformation; + this.new_transformation = new_transformation; + transformation_type = old_transformation.get_transformation_type(); + assert(new_transformation.get_transformation_type() == transformation_type); + } + + public override void execute() { + // don't update slider; it's been moved by the user + owner.update_transformation(new_transformation); + owner.canvas.repaint(); + } + + public override void undo() { + owner.update_transformation(old_transformation); + + owner.unbind_window_handlers(); + owner.update_slider(old_transformation); + owner.bind_window_handlers(); + + owner.canvas.repaint(); + } + + public override void redo() { + owner.update_transformation(new_transformation); + + owner.unbind_window_handlers(); + owner.update_slider(new_transformation); + owner.bind_window_handlers(); + + owner.canvas.repaint(); + } + + public override bool compress(Command command) { + SliderAdjustmentCommand slider_adjustment = command as SliderAdjustmentCommand; + if (slider_adjustment == null) + return false; + + // same photo + if (slider_adjustment.owner != owner) + return false; + + // same adjustment + if (slider_adjustment.transformation_type != transformation_type) + return false; + + // execute the command + slider_adjustment.execute(); + + // save it's transformation as ours + new_transformation = slider_adjustment.new_transformation; + + return true; + } + } + + private class AdjustEnhanceCommand : AdjustToolCommand { + private Photo photo; + private PixelTransformationBundle original; + private PixelTransformationBundle enhanced = null; + + public AdjustEnhanceCommand(AdjustTool owner, Photo photo) { + base(owner, Resources.ENHANCE_LABEL, Resources.ENHANCE_TOOLTIP); + + this.photo = photo; + original = photo.get_color_adjustments(); + } + + public override void execute() { + if (enhanced == null) + enhanced = photo.get_enhance_transformations(); + + owner.set_adjustments(enhanced); + } + + public override void undo() { + owner.set_adjustments(original); + } + + public override bool compress(Command command) { + // can compress both normal enhance and one with the adjust tool running + EnhanceSingleCommand enhance_single = command as EnhanceSingleCommand; + if (enhance_single != null) { + Photo photo = (Photo) enhance_single.get_source(); + + // multiple successive enhances are as good as a single, as long as it's on the + // same photo + return photo.equals(owner.canvas.get_photo()); + } + + AdjustEnhanceCommand enhance_command = command as AdjustEnhanceCommand; + if (enhance_command == null) + return false; + + if (enhance_command.owner != owner) + return false; + + // multiple successive as good as a single + return true; + } + } + + private AdjustToolWindow adjust_tool_window = null; + private bool suppress_effect_redraw = false; + private Gdk.Pixbuf draw_to_pixbuf = null; + private Gdk.Pixbuf histogram_pixbuf = null; + private Gdk.Pixbuf virgin_histogram_pixbuf = null; + private PixelTransformer transformer = null; + private PixelTransformer histogram_transformer = null; + private PixelTransformationBundle transformations = null; + private float[] fp_pixel_cache = null; + private bool disable_histogram_refresh = false; + private OneShotScheduler? temperature_scheduler = null; + private OneShotScheduler? tint_scheduler = null; + private OneShotScheduler? saturation_scheduler = null; + private OneShotScheduler? exposure_scheduler = null; + private OneShotScheduler? shadows_scheduler = null; + private OneShotScheduler? highlights_scheduler = null; + + private AdjustTool() { + base("AdjustTool"); + } + + public static AdjustTool factory() { + return new AdjustTool(); + } + + public static bool is_available(Photo photo, Scaling scaling) { + return true; + } + + public override void activate(PhotoCanvas canvas) { + adjust_tool_window = new AdjustToolWindow(canvas.get_container()); + + Photo photo = canvas.get_photo(); + transformations = photo.get_color_adjustments(); + transformer = transformations.generate_transformer(); + + // the histogram transformer uses all transformations but contrast expansion + histogram_transformer = new PixelTransformer(); + + /* set up expansion */ + ExpansionTransformation expansion_trans = (ExpansionTransformation) + transformations.get_transformation(PixelTransformationType.TONE_EXPANSION); + adjust_tool_window.histogram_manipulator.set_left_nub_position( + expansion_trans.get_black_point()); + adjust_tool_window.histogram_manipulator.set_right_nub_position( + expansion_trans.get_white_point()); + + /* set up shadows */ + ShadowDetailTransformation shadows_trans = (ShadowDetailTransformation) + transformations.get_transformation(PixelTransformationType.SHADOWS); + histogram_transformer.attach_transformation(shadows_trans); + adjust_tool_window.shadows_slider.set_value(shadows_trans.get_parameter()); + + /* set up highlights */ + HighlightDetailTransformation highlights_trans = (HighlightDetailTransformation) + transformations.get_transformation(PixelTransformationType.HIGHLIGHTS); + histogram_transformer.attach_transformation(highlights_trans); + adjust_tool_window.highlights_slider.set_value(highlights_trans.get_parameter()); + + /* set up temperature & tint */ + TemperatureTransformation temp_trans = (TemperatureTransformation) + transformations.get_transformation(PixelTransformationType.TEMPERATURE); + histogram_transformer.attach_transformation(temp_trans); + adjust_tool_window.temperature_slider.set_value(temp_trans.get_parameter()); + + TintTransformation tint_trans = (TintTransformation) + transformations.get_transformation(PixelTransformationType.TINT); + histogram_transformer.attach_transformation(tint_trans); + adjust_tool_window.tint_slider.set_value(tint_trans.get_parameter()); + + /* set up saturation */ + SaturationTransformation sat_trans = (SaturationTransformation) + transformations.get_transformation(PixelTransformationType.SATURATION); + histogram_transformer.attach_transformation(sat_trans); + adjust_tool_window.saturation_slider.set_value(sat_trans.get_parameter()); + + /* set up exposure */ + ExposureTransformation exposure_trans = (ExposureTransformation) + transformations.get_transformation(PixelTransformationType.EXPOSURE); + histogram_transformer.attach_transformation(exposure_trans); + adjust_tool_window.exposure_slider.set_value(exposure_trans.get_parameter()); + + bind_canvas_handlers(canvas); + bind_window_handlers(); + + draw_to_pixbuf = canvas.get_scaled_pixbuf().copy(); + init_fp_pixel_cache(canvas.get_scaled_pixbuf()); + + /* if we have an 1x1 pixel image, then there's no need to deal with recomputing the + histogram, because a histogram for a 1x1 image is meaningless. The histogram shows the + distribution of color over all the many pixels in an image, but if an image only has + one pixel, the notion of a "distribution over pixels" makes no sense. */ + if (draw_to_pixbuf.width == 1 && draw_to_pixbuf.height == 1) + disable_histogram_refresh = true; + + /* don't sample the original image to create the histogram if the original image is + sufficiently large -- if it's over 8k pixels, then we'll get pretty much the same + histogram if we sample from a half-size image */ + if (((draw_to_pixbuf.width * draw_to_pixbuf.height) > 8192) && (draw_to_pixbuf.width > 1) && + (draw_to_pixbuf.height > 1)) { + histogram_pixbuf = draw_to_pixbuf.scale_simple(draw_to_pixbuf.width / 2, + draw_to_pixbuf.height / 2, Gdk.InterpType.HYPER); + } else { + histogram_pixbuf = draw_to_pixbuf.copy(); + } + virgin_histogram_pixbuf = histogram_pixbuf.copy(); + + DataCollection? owner = canvas.get_photo().get_membership(); + if (owner != null) + owner.items_altered.connect(on_photos_altered); + + base.activate(canvas); + } + + public override EditingToolWindow? get_tool_window() { + return adjust_tool_window; + } + + public override void deactivate() { + if (canvas != null) { + DataCollection? owner = canvas.get_photo().get_membership(); + if (owner != null) + owner.items_altered.disconnect(on_photos_altered); + + unbind_canvas_handlers(canvas); + } + + if (adjust_tool_window != null) { + unbind_window_handlers(); + adjust_tool_window.hide(); + adjust_tool_window.destroy(); + adjust_tool_window = null; + } + + draw_to_pixbuf = null; + fp_pixel_cache = null; + + base.deactivate(); + } + + public override void paint(Cairo.Context ctx) { + if (!suppress_effect_redraw) { + transformer.transform_from_fp(ref fp_pixel_cache, draw_to_pixbuf); + histogram_transformer.transform_to_other_pixbuf(virgin_histogram_pixbuf, + histogram_pixbuf); + if (!disable_histogram_refresh) + adjust_tool_window.histogram_manipulator.update_histogram(histogram_pixbuf); + } + + canvas.paint_pixbuf(draw_to_pixbuf); + } + + public override Gdk.Pixbuf? get_display_pixbuf(Scaling scaling, Photo photo, + out Dimensions max_dim) throws Error { + if (!photo.has_color_adjustments()) { + max_dim = Dimensions(); + + return null; + } + + max_dim = photo.get_dimensions(); + + return photo.get_pixbuf_with_options(scaling, Photo.Exception.ADJUST); + } + + private void on_reset() { + AdjustResetCommand command = new AdjustResetCommand(this, transformations); + AppWindow.get_command_manager().execute(command); + } + + private void on_ok() { + suppress_effect_redraw = true; + + get_tool_window().hide(); + + applied(new AdjustColorsSingleCommand(canvas.get_photo(), transformations, + Resources.ADJUST_LABEL, Resources.ADJUST_TOOLTIP), draw_to_pixbuf, + canvas.get_photo().get_dimensions(), false); + } + + private void update_transformations(PixelTransformationBundle new_transformations) { + foreach (PixelTransformation transformation in new_transformations.get_transformations()) + update_transformation(transformation); + } + + private void update_transformation(PixelTransformation new_transformation) { + PixelTransformation old_transformation = transformations.get_transformation( + new_transformation.get_transformation_type()); + + transformer.replace_transformation(old_transformation, new_transformation); + if (new_transformation.get_transformation_type() != PixelTransformationType.TONE_EXPANSION) + histogram_transformer.replace_transformation(old_transformation, new_transformation); + + transformations.set(new_transformation); + } + + private void slider_updated(PixelTransformation new_transformation, string name) { + PixelTransformation old_transformation = transformations.get_transformation( + new_transformation.get_transformation_type()); + SliderAdjustmentCommand command = new SliderAdjustmentCommand(this, old_transformation, + new_transformation, name); + AppWindow.get_command_manager().execute(command); + } + + private void on_temperature_adjustment() { + if (temperature_scheduler == null) + temperature_scheduler = new OneShotScheduler("temperature", on_delayed_temperature_adjustment); + + temperature_scheduler.after_timeout(SLIDER_DELAY_MSEC, true); + } + + private void on_delayed_temperature_adjustment() { + TemperatureTransformation new_temp_trans = new TemperatureTransformation( + (float) adjust_tool_window.temperature_slider.get_value()); + slider_updated(new_temp_trans, _("Temperature")); + } + + private void on_tint_adjustment() { + if (tint_scheduler == null) + tint_scheduler = new OneShotScheduler("tint", on_delayed_tint_adjustment); + + tint_scheduler.after_timeout(SLIDER_DELAY_MSEC, true); + } + + private void on_delayed_tint_adjustment() { + TintTransformation new_tint_trans = new TintTransformation( + (float) adjust_tool_window.tint_slider.get_value()); + slider_updated(new_tint_trans, _("Tint")); + } + + private void on_saturation_adjustment() { + if (saturation_scheduler == null) + saturation_scheduler = new OneShotScheduler("saturation", on_delayed_saturation_adjustment); + + saturation_scheduler.after_timeout(SLIDER_DELAY_MSEC, true); + } + + private void on_delayed_saturation_adjustment() { + SaturationTransformation new_sat_trans = new SaturationTransformation( + (float) adjust_tool_window.saturation_slider.get_value()); + slider_updated(new_sat_trans, _("Saturation")); + } + + private void on_exposure_adjustment() { + if (exposure_scheduler == null) + exposure_scheduler = new OneShotScheduler("exposure", on_delayed_exposure_adjustment); + + exposure_scheduler.after_timeout(SLIDER_DELAY_MSEC, true); + } + + private void on_delayed_exposure_adjustment() { + ExposureTransformation new_exp_trans = new ExposureTransformation( + (float) adjust_tool_window.exposure_slider.get_value()); + slider_updated(new_exp_trans, _("Exposure")); + } + + private void on_shadows_adjustment() { + if (shadows_scheduler == null) + shadows_scheduler = new OneShotScheduler("shadows", on_delayed_shadows_adjustment); + + shadows_scheduler.after_timeout(SLIDER_DELAY_MSEC, true); + } + + private void on_delayed_shadows_adjustment() { + ShadowDetailTransformation new_shadows_trans = new ShadowDetailTransformation( + (float) adjust_tool_window.shadows_slider.get_value()); + slider_updated(new_shadows_trans, _("Shadows")); + } + + private void on_highlights_adjustment() { + if (highlights_scheduler == null) + highlights_scheduler = new OneShotScheduler("highlights", on_delayed_highlights_adjustment); + + highlights_scheduler.after_timeout(SLIDER_DELAY_MSEC, true); + } + + private void on_delayed_highlights_adjustment() { + HighlightDetailTransformation new_highlights_trans = new HighlightDetailTransformation( + (float) adjust_tool_window.highlights_slider.get_value()); + slider_updated(new_highlights_trans, _("Highlights")); + } + + private void on_histogram_constraint() { + int expansion_black_point = + adjust_tool_window.histogram_manipulator.get_left_nub_position(); + int expansion_white_point = + adjust_tool_window.histogram_manipulator.get_right_nub_position(); + ExpansionTransformation new_exp_trans = + new ExpansionTransformation.from_extrema(expansion_black_point, expansion_white_point); + slider_updated(new_exp_trans, _("Contrast Expansion")); + } + + private void on_canvas_resize() { + draw_to_pixbuf = canvas.get_scaled_pixbuf().copy(); + init_fp_pixel_cache(canvas.get_scaled_pixbuf()); + } + + private bool on_hscale_reset(Gtk.Widget widget, Gdk.EventButton event) { + Gtk.Scale source = (Gtk.Scale) widget; + + if (event.button == 1 && event.type == Gdk.EventType.BUTTON_PRESS + && has_only_key_modifier(event.state, Gdk.ModifierType.CONTROL_MASK)) { + // Left Mouse Button and CTRL pressed + source.set_value(0); + + return true; + } + + return false; + } + + private void bind_canvas_handlers(PhotoCanvas canvas) { + canvas.resized_scaled_pixbuf.connect(on_canvas_resize); + } + + private void unbind_canvas_handlers(PhotoCanvas canvas) { + canvas.resized_scaled_pixbuf.disconnect(on_canvas_resize); + } + + private void bind_window_handlers() { + adjust_tool_window.ok_button.clicked.connect(on_ok); + adjust_tool_window.reset_button.clicked.connect(on_reset); + adjust_tool_window.cancel_button.clicked.connect(notify_cancel); + adjust_tool_window.exposure_slider.value_changed.connect(on_exposure_adjustment); + adjust_tool_window.saturation_slider.value_changed.connect(on_saturation_adjustment); + adjust_tool_window.tint_slider.value_changed.connect(on_tint_adjustment); + adjust_tool_window.temperature_slider.value_changed.connect(on_temperature_adjustment); + adjust_tool_window.shadows_slider.value_changed.connect(on_shadows_adjustment); + adjust_tool_window.highlights_slider.value_changed.connect(on_highlights_adjustment); + adjust_tool_window.histogram_manipulator.nub_position_changed.connect(on_histogram_constraint); + + adjust_tool_window.saturation_slider.button_press_event.connect(on_hscale_reset); + adjust_tool_window.exposure_slider.button_press_event.connect(on_hscale_reset); + adjust_tool_window.tint_slider.button_press_event.connect(on_hscale_reset); + adjust_tool_window.temperature_slider.button_press_event.connect(on_hscale_reset); + adjust_tool_window.shadows_slider.button_press_event.connect(on_hscale_reset); + adjust_tool_window.highlights_slider.button_press_event.connect(on_hscale_reset); + } + + private void unbind_window_handlers() { + adjust_tool_window.ok_button.clicked.disconnect(on_ok); + adjust_tool_window.reset_button.clicked.disconnect(on_reset); + adjust_tool_window.cancel_button.clicked.disconnect(notify_cancel); + adjust_tool_window.exposure_slider.value_changed.disconnect(on_exposure_adjustment); + adjust_tool_window.saturation_slider.value_changed.disconnect(on_saturation_adjustment); + adjust_tool_window.tint_slider.value_changed.disconnect(on_tint_adjustment); + adjust_tool_window.temperature_slider.value_changed.disconnect(on_temperature_adjustment); + adjust_tool_window.shadows_slider.value_changed.disconnect(on_shadows_adjustment); + adjust_tool_window.highlights_slider.value_changed.disconnect(on_highlights_adjustment); + adjust_tool_window.histogram_manipulator.nub_position_changed.disconnect(on_histogram_constraint); + + adjust_tool_window.saturation_slider.button_press_event.disconnect(on_hscale_reset); + adjust_tool_window.exposure_slider.button_press_event.disconnect(on_hscale_reset); + adjust_tool_window.tint_slider.button_press_event.disconnect(on_hscale_reset); + adjust_tool_window.temperature_slider.button_press_event.disconnect(on_hscale_reset); + adjust_tool_window.shadows_slider.button_press_event.disconnect(on_hscale_reset); + adjust_tool_window.highlights_slider.button_press_event.disconnect(on_hscale_reset); + } + + public bool enhance() { + AdjustEnhanceCommand command = new AdjustEnhanceCommand(this, canvas.get_photo()); + AppWindow.get_command_manager().execute(command); + + return true; + } + + private void on_photos_altered(Gee.Map<DataObject, Alteration> map) { + if (!map.has_key(canvas.get_photo())) + return; + + PixelTransformationBundle adjustments = canvas.get_photo().get_color_adjustments(); + set_adjustments(adjustments); + } + + private void set_adjustments(PixelTransformationBundle new_adjustments) { + unbind_window_handlers(); + + update_transformations(new_adjustments); + + foreach (PixelTransformation adjustment in new_adjustments.get_transformations()) + update_slider(adjustment); + + bind_window_handlers(); + canvas.repaint(); + } + + // Note that window handlers should be unbound (unbind_window_handlers) prior to calling this + // if the caller doesn't want the widget's signals to fire with the change. + private void update_slider(PixelTransformation transformation) { + switch (transformation.get_transformation_type()) { + case PixelTransformationType.TONE_EXPANSION: + ExpansionTransformation expansion = (ExpansionTransformation) transformation; + + if (!disable_histogram_refresh) { + adjust_tool_window.histogram_manipulator.set_left_nub_position( + expansion.get_black_point()); + adjust_tool_window.histogram_manipulator.set_right_nub_position( + expansion.get_white_point()); + } + break; + + case PixelTransformationType.SHADOWS: + adjust_tool_window.shadows_slider.set_value( + ((ShadowDetailTransformation) transformation).get_parameter()); + break; + + case PixelTransformationType.HIGHLIGHTS: + adjust_tool_window.highlights_slider.set_value( + ((HighlightDetailTransformation) transformation).get_parameter()); + break; + + case PixelTransformationType.EXPOSURE: + adjust_tool_window.exposure_slider.set_value( + ((ExposureTransformation) transformation).get_parameter()); + break; + + case PixelTransformationType.SATURATION: + adjust_tool_window.saturation_slider.set_value( + ((SaturationTransformation) transformation).get_parameter()); + break; + + case PixelTransformationType.TINT: + adjust_tool_window.tint_slider.set_value( + ((TintTransformation) transformation).get_parameter()); + break; + + case PixelTransformationType.TEMPERATURE: + adjust_tool_window.temperature_slider.set_value( + ((TemperatureTransformation) transformation).get_parameter()); + break; + + default: + error("Unknown adjustment: %d", (int) transformation.get_transformation_type()); + } + } + + private void init_fp_pixel_cache(Gdk.Pixbuf source) { + int source_width = source.get_width(); + int source_height = source.get_height(); + int source_num_channels = source.get_n_channels(); + int source_rowstride = source.get_rowstride(); + unowned uchar[] source_pixels = source.get_pixels(); + + fp_pixel_cache = new float[3 * source_width * source_height]; + int cache_pixel_index = 0; + float INV_255 = 1.0f / 255.0f; + + for (int j = 0; j < source_height; j++) { + int row_start_index = j * source_rowstride; + int row_end_index = row_start_index + (source_width * source_num_channels); + for (int i = row_start_index; i < row_end_index; i += source_num_channels) { + fp_pixel_cache[cache_pixel_index++] = ((float) source_pixels[i]) * INV_255; + fp_pixel_cache[cache_pixel_index++] = ((float) source_pixels[i + 1]) * INV_255; + fp_pixel_cache[cache_pixel_index++] = ((float) source_pixels[i + 2]) * INV_255; + } + } + } + + public override bool on_keypress(Gdk.EventKey event) { + if ((Gdk.keyval_name(event.keyval) == "KP_Enter") || + (Gdk.keyval_name(event.keyval) == "Enter") || + (Gdk.keyval_name(event.keyval) == "Return")) { + on_ok(); + return true; + } + + return base.on_keypress(event); + } +} + + +} + diff --git a/src/editing_tools/StraightenTool.vala b/src/editing_tools/StraightenTool.vala new file mode 100644 index 0000000..8a778ec --- /dev/null +++ b/src/editing_tools/StraightenTool.vala @@ -0,0 +1,559 @@ + +/* Copyright 2009-2014 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +namespace EditingTools { + +/** + * An editing tool that allows one to introduce or remove a Dutch angle from + * a photograph. + */ +public class StraightenTool : EditingTool { + private const double MIN_ANGLE = -15.0; + private const double MAX_ANGLE = 15.0; + private const double INCREMENT = 0.1; + private const int MIN_SLIDER_SIZE = 160; + private const int MIN_LABEL_SIZE = 100; + private const int MIN_BUTTON_SIZE = 84; + private const int TEMP_PIXBUF_SIZE = 768; + private const double GUIDE_DASH[2] = {10, 10}; + private const int REPAINT_ON_STOP_DELAY_MSEC = 100; + + private class StraightenGuide { + private bool is_active = false; + private int x[2]; // start & end drag coords + private int y[2]; + private double angle0; // current angle + + public void reset(int x, int y, double angle) { + this.x = {x, x}; + this.y = {y, y}; + this.is_active = true; + this.angle0 = angle; + } + + public bool update(int x, int y) { + if (this.is_active) { + this.x[1] = x; + this.y[1] = y; + return true; + } + + return false; + } + + public void clear() { + this.is_active = false; + } + + public double? get_angle() { + double dx = x[1] - x[0]; + double dy = y[1] - y[0]; + + // minimum radius to consider: discard clicks + if (dy*dy + dx*dx < 40) + return null; + + // distinguish guides closer to horizontal or vertical + if (Math.fabs(dy) > Math.fabs(dx)) + return angle0 + Math.atan(dx / dy) / Math.PI * 180; + else + return angle0 - Math.atan(dy / dx) / Math.PI * 180; + } + + public void draw(Cairo.Context ctx) { + if (!is_active) + return; + + double angle = get_angle() ?? 0.0; + if (angle == 0.0) + return; + + double alpha = 1.0; + if (angle < MIN_ANGLE || angle > MAX_ANGLE) + alpha = 0.35; + + // b&w dashing so it will be more visible on + // different backgrounds. + ctx.set_source_rgba(0.0, 0.0, 0.0, alpha); + ctx.set_dash(GUIDE_DASH, GUIDE_DASH[0] / 2); + ctx.move_to(x[0] + 0.5, y[0] + 0.5); + ctx.line_to(x[1] + 0.5, y[1] + 0.5); + ctx.stroke(); + ctx.set_dash(GUIDE_DASH, -GUIDE_DASH[0] / 2); + ctx.set_source_rgba(1.0, 1.0, 1.0, alpha); + ctx.move_to(x[0] + 0.5, y[0] + 0.5); + ctx.line_to(x[1] + 0.5, y[1] + 0.5); + ctx.stroke(); + } + } + + private class StraightenToolWindow : EditingToolWindow { + public const int CONTROL_SPACING = 8; + + public Gtk.Scale angle_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL, MIN_ANGLE, MAX_ANGLE, INCREMENT); + public Gtk.Label angle_label = new Gtk.Label(""); + public Gtk.Label description_label = new Gtk.Label(_("Angle:")); + public Gtk.Button ok_button = new Gtk.Button.with_mnemonic(_("_Straighten")); + public Gtk.Button cancel_button = new Gtk.Button.from_stock(Gtk.Stock.CANCEL); + public Gtk.Button reset_button = new Gtk.Button.with_mnemonic(_("_Reset")); + + /** + * Prepare straighten tool's window for use and initialize all its controls. + * + * @param container The application's main window. + */ + public StraightenToolWindow(Gtk.Window container) { + base(container); + + angle_slider.set_min_slider_size(MIN_SLIDER_SIZE); + angle_slider.set_size_request(MIN_SLIDER_SIZE, -1); + angle_slider.set_value(0.0); + angle_slider.set_draw_value(false); + + description_label.set_padding(CONTROL_SPACING, 0); + angle_label.set_padding(0, 0); + angle_label.set_size_request(MIN_LABEL_SIZE,-1); + + Gtk.Box slider_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING); + slider_layout.pack_start(angle_slider, true, true, 0); + + Gtk.Box button_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING); + cancel_button.set_size_request(MIN_BUTTON_SIZE, -1); + reset_button.set_size_request(MIN_BUTTON_SIZE, -1); + ok_button.set_size_request(MIN_BUTTON_SIZE, -1); + button_layout.pack_start(cancel_button, true, true, 0); + button_layout.pack_start(reset_button, true, true, 0); + button_layout.pack_start(ok_button, true, true, 0); + + Gtk.Box main_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0); + main_layout.pack_start(description_label, true, true, 0); + main_layout.pack_start(slider_layout, true, true, 0); + main_layout.pack_start(angle_label, true, true, 0); + main_layout.pack_start(button_layout, true, true, 0); + + add(main_layout); + + reset_button.clicked.connect(on_reset_clicked); + + set_position(Gtk.WindowPosition.CENTER_ON_PARENT); + } + + private void on_reset_clicked() { + angle_slider.set_value(0.0); + } + } + + private StraightenToolWindow window; + + // the incoming image itself. + private Cairo.Surface photo_surf; + Dimensions image_dims; + + // temporary surface we'll draw the rotated image into. + private Cairo.Surface rotate_surf; + private Cairo.Context rotate_ctx; + + private Dimensions last_viewport; + private int view_width; + private int view_height; + private double photo_angle = 0.0; + + // should we use a nicer-but-more-expensive filter + // when repainting the rotated image? + private bool use_high_qual = true; + private OneShotScheduler? slider_sched = null; + + private Gdk.Point crop_center; // original center in image coordinates + private int crop_width; + private int crop_height; + + private StraightenGuide guide = new StraightenGuide(); + + // As the crop box rotates, we adjust its center and/or scale it so that it fits in the image. + private Gdk.Point rotated_center; // in image coordinates + private double rotate_scale; // always <= 1.0: rotation may shrink but not grow box + + private double preview_scale; + + private StraightenTool() { + base("StraightenTool"); + } + + public static StraightenTool factory() { + return new StraightenTool(); + } + + public static bool is_available(Photo photo, Scaling scaling) { + return true; + } + + /** + * @brief Signal handler for when the 'OK' button has been clicked. Computes where a previously- + * set crop region should have rotated to (to match the Photo's straightening angle). + * + * @note After this has been called against a Photo, it will always have a crop region; in the + * case of a previously-uncropped Photo, the crop region will be set to the original dimensions + * of the photo and centered at the Photo's center. + */ + private void on_ok_clicked() { + assert(canvas.get_photo() != null); + + // compute where the crop box should be now and set the image's + // current crop to it + double slider_val = window.angle_slider.get_value(); + + Gdk.Point new_crop_center = rotate_point_arb(rotated_center, + image_dims.width, image_dims.height, slider_val); + + StraightenCommand command = new StraightenCommand( + canvas.get_photo(), slider_val, + Box.from_center(new_crop_center, + (int) (rotate_scale * crop_width), (int) (rotate_scale * crop_height)), + Resources.STRAIGHTEN_LABEL, Resources.STRAIGHTEN_TOOLTIP); + applied(command, null, image_dims, true); + } + + private void high_qual_repaint(){ + use_high_qual = true; + update_rotated_surface(); + this.canvas.repaint(); + } + + private void on_slider_stopped_delayed() { + high_qual_repaint(); + } + + public override void on_left_click(int x, int y) { + guide.reset(x, y, photo_angle); + } + + public override void on_left_released(int x, int y) { + guide.update(x, y); + double? a = guide.get_angle(); + guide.clear(); + if (a != null) { + window.angle_slider.set_value(a); + high_qual_repaint(); + } + } + + public override void on_motion(int x, int y, Gdk.ModifierType mask) { + if (guide.update(x, y)) + canvas.repaint(); + } + + public override bool on_keypress(Gdk.EventKey event) { + if ((Gdk.keyval_name(event.keyval) == "KP_Enter") || + (Gdk.keyval_name(event.keyval) == "Enter") || + (Gdk.keyval_name(event.keyval) == "Return")) { + on_ok_clicked(); + return true; + } + + if (Gdk.keyval_name(event.keyval) == "Escape") { + notify_cancel(); + return true; + } + + return base.on_keypress(event); + } + + private void prepare_image() { + Dimensions canvas_dims = canvas.get_surface_dim(); + Dimensions viewport = canvas_dims.with_max(TEMP_PIXBUF_SIZE, TEMP_PIXBUF_SIZE); + if (viewport == last_viewport) + return; // no change + + last_viewport = viewport; + + Gdk.Pixbuf low_res_tmp = null; + try { + low_res_tmp = + canvas.get_photo().get_pixbuf_with_options(Scaling.for_viewport(viewport, false), + Photo.Exception.STRAIGHTEN | Photo.Exception.CROP); + } catch (Error e) { + warning("A pixbuf for %s couldn't be fetched.", canvas.get_photo().to_string()); + low_res_tmp = new Gdk.Pixbuf(Gdk.Colorspace.RGB, false, 8, 1, 1); + } + + preview_scale = low_res_tmp.width / (double) image_dims.width; + + // copy image data from photo into a cairo surface. + photo_surf = new Cairo.ImageSurface(Cairo.Format.ARGB32, low_res_tmp.width, low_res_tmp.height); + Cairo.Context ctx = new Cairo.Context(photo_surf); + Gdk.cairo_set_source_pixbuf(ctx, low_res_tmp, 0, 0); + ctx.rectangle(0, 0, low_res_tmp.width, low_res_tmp.height); + ctx.fill(); + ctx.paint(); + + // prepare rotation surface and context. we paint a rotated, + // low-res copy of the image into it, followed by a faint grid. + view_width = (int) (crop_width * preview_scale); + view_height = (int) (crop_height * preview_scale); + rotate_surf = new Cairo.ImageSurface(Cairo.Format.ARGB32, view_width, view_height); + rotate_ctx = new Cairo.Context(rotate_surf); + } + + // Adjust the rotated crop box so that it fits in the source image. + void adjust_for_rotation() { + double width, height; + compute_arb_rotated_size(crop_width, crop_height, photo_angle, out width, out height); + + // First compute a scaling factor that will let the rotated box fit in the image. + rotate_scale = double.min(image_dims.width / width, image_dims.height / height); + rotate_scale = double.min(rotate_scale, 1.0); + + // Now nudge the box into the image if necessary. + rotated_center = crop_center; + int radius_x = (int) (rotate_scale * width / 2); + int radius_y = (int) (rotate_scale * height / 2); + rotated_center.x = rotated_center.x.clamp(radius_x, image_dims.width - radius_x); + rotated_center.y = rotated_center.y.clamp(radius_y, image_dims.height - radius_y); + } + + /** + * @brief Spawn the tool window, set up the scratch surfaces and prepare the straightening + * tool for use. If a valid pixbuf of the incoming Photo can't be loaded for any + * reason, the tool will use a 1x1 temporary image instead to avoid crashing. + * + * @param canvas The PhotoCanvas the tool's output should be painted to. + */ + public override void activate(PhotoCanvas canvas) { + base.activate(canvas); + this.canvas = canvas; + bind_canvas_handlers(this.canvas); + + image_dims = canvas.get_photo().get_dimensions( + Photo.Exception.STRAIGHTEN | Photo.Exception.CROP); + + Box crop_region; + if (!canvas.get_photo().get_crop(out crop_region)) { + crop_region.left = 0; + crop_region.right = image_dims.width; + + crop_region.top = 0; + crop_region.bottom = image_dims.height; + } + + // read the photo's current angle and start the tool with the slider set to that value. we + // also use this to de-rotate the crop region + double incoming_angle = 0.0; + canvas.get_photo().get_straighten(out incoming_angle); + + // Translate the crop center to image coordinates. + crop_center = derotate_point_arb(crop_region.get_center(), + image_dims.width, image_dims.height, incoming_angle); + crop_width = crop_region.get_width(); + crop_height = crop_region.get_height(); + + adjust_for_rotation(); + + prepare_image(); + + // set crosshair cursor + canvas.get_drawing_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.CROSSHAIR)); + + window = new StraightenToolWindow(canvas.get_container()); + bind_window_handlers(); + + // prepare ths slider for display + window.angle_slider.set_value(incoming_angle); + photo_angle = incoming_angle; + + string tmp = "%2.1f°".printf(incoming_angle); + window.angle_label.set_text(tmp); + + high_qual_repaint(); + window.show_all(); + } + + /** + * Tears down the tool window and frees resources. + */ + public override void deactivate() { + if(window != null) { + + unbind_window_handlers(); + + window.hide(); + window = null; + } + + if (canvas != null) { + unbind_canvas_handlers(canvas); + canvas.get_drawing_window().set_cursor(null); + } + + base.deactivate(); + } + + private void bind_canvas_handlers(PhotoCanvas canvas) { + canvas.resized_scaled_pixbuf.connect(on_resized_pixbuf); + } + + private void unbind_canvas_handlers(PhotoCanvas canvas) { + canvas.resized_scaled_pixbuf.disconnect(on_resized_pixbuf); + } + + private void bind_window_handlers() { + window.key_press_event.connect(on_keypress); + window.ok_button.clicked.connect(on_ok_clicked); + window.cancel_button.clicked.connect(notify_cancel); + window.angle_slider.value_changed.connect(on_angle_changed); + } + + private void unbind_window_handlers() { + window.key_press_event.disconnect(on_keypress); + window.ok_button.clicked.disconnect(on_ok_clicked); + window.cancel_button.clicked.disconnect(notify_cancel); + window.angle_slider.value_changed.disconnect(on_angle_changed); + } + + private void on_angle_changed() { + photo_angle = window.angle_slider.get_value(); + string tmp = "%2.1f°".printf(window.angle_slider.get_value()); + window.angle_label.set_text(tmp); + + if (slider_sched == null) + slider_sched = new OneShotScheduler("straighten", on_slider_stopped_delayed); + slider_sched.after_timeout(REPAINT_ON_STOP_DELAY_MSEC, true); + + use_high_qual = false; + + adjust_for_rotation(); + update_rotated_surface(); + this.canvas.repaint(); + } + + /** + * @brief Called by the EditingHostPage when a resize event occurs. + */ + private void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) { + prepare_image(); + } + + /** + * Returns a reference to the current StraightenTool instance's tool window; + * the PhotoPage uses this to control the tool window's positioning, etc. + */ + public override EditingToolWindow? get_tool_window() { + return window; + } + + /** + * Draw the rotated photo and grid. + */ + private void update_rotated_surface() { + draw_rotated_source(photo_surf, rotate_ctx, view_width, view_height, photo_angle); + rotate_ctx.set_line_width(1.0); + draw_superimposed_grid(rotate_ctx, view_width, view_height); + } + + /** + * Render a smaller, rotated version of the image, with a grid superimposed over it. + * + * @param ctx The rendering context of a 'scratch' Cairo surface. The tool makes its own + * surfaces and contexts so it can have things set up exactly like it wants them, so + * it's not used. + */ + public override void paint(Cairo.Context ctx) { + int w = canvas.get_drawing_window().get_width(); + int h = canvas.get_drawing_window().get_height(); + + // fill region behind the rotation surface with neutral color. + canvas.get_default_ctx().identity_matrix(); + canvas.get_default_ctx().set_source_rgba(0.0, 0.0, 0.0, 1.0); + canvas.get_default_ctx().rectangle(0, 0, w, h); + canvas.get_default_ctx().fill(); + + // copy the composited result to the main window. + canvas.get_default_ctx().translate((w - view_width) / 2.0, (h - view_height) / 2.0); + canvas.get_default_ctx().set_source_surface(rotate_surf, 0, 0); + canvas.get_default_ctx().rectangle(0, 0, view_width, view_height); + canvas.get_default_ctx().fill(); + canvas.get_default_ctx().paint(); + + // reset the 'modelview' matrix, since when the canvas is not in + // 'tool' mode, it 'expects' things to be set up a certain way. + canvas.get_default_ctx().identity_matrix(); + + guide.draw(canvas.get_default_ctx()); + } + + /** + * Copy a rotated version of the source image onto the destination + * context. + * + * @param src_surf A Cairo surface containing the source image. + * @param dest_ctx The rendering context of the destination image. + * @param src_width The width of the image data in src_surf in pixels. + * @param src_height The height of the image data in src_surf in pixels. + * @param angle The angle the source image should be rotated by, in degrees. + */ + private void draw_rotated_source(Cairo.Surface src_surf, Cairo.Context dest_ctx, + int src_width, int src_height, double angle) { + double angle_internal = degrees_to_radians(angle); + + // fill area behind rotated image with neutral color to avoid 'ghosting'. + // this should be removed after #4612 has been addressed. + dest_ctx.identity_matrix(); + dest_ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0); + dest_ctx.rectangle(0, 0, view_width, view_height); + dest_ctx.fill(); + + // rotate the image, taking into account that the position of the + // upper left corner must change depending on rotation amount and direction + // and translate so center of preview crop region is now center of rotation + dest_ctx.identity_matrix(); + + dest_ctx.translate(view_width / 2, view_height / 2); + dest_ctx.scale(1.0 / rotate_scale, 1.0 / rotate_scale); + dest_ctx.rotate(angle_internal); + dest_ctx.translate(- rotated_center.x * preview_scale, - rotated_center.y * preview_scale); + + dest_ctx.set_source_surface(src_surf, 0, 0); + dest_ctx.get_source().set_filter(use_high_qual ? Cairo.Filter.BEST : Cairo.Filter.NEAREST); + dest_ctx.rectangle(0, 0, src_width, src_height); + dest_ctx.fill(); + dest_ctx.paint(); + } + + /** + * Superimpose a faint grid over the supplied image. + * + * @param width The total width the grid should be drawn to. + * @param height The total height the grid should be drawn to. + * @param dest_ctx The rendering context of the destination image. + */ + private void draw_superimposed_grid(Cairo.Context dest_ctx, int width, int height) { + int half_width = width / 2; + int quarter_width = width / 4; + + int half_height = height / 2; + int quarter_height = height / 4; + + dest_ctx.identity_matrix(); + dest_ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0); + + canvas.draw_horizontal_line(dest_ctx, 0, 0, width, false); + canvas.draw_horizontal_line(dest_ctx, 0, half_height, width, false); + canvas.draw_horizontal_line(dest_ctx, 0, view_height - 1, width, false); + + canvas.draw_vertical_line(dest_ctx, 0, 0, height + 1, false); + canvas.draw_vertical_line(dest_ctx, half_width, 0, height + 1, false); + canvas.draw_vertical_line(dest_ctx, width - 1, 0, height + 1, false); + + dest_ctx.set_source_rgba(1.0, 1.0, 1.0, 0.33); + + canvas.draw_horizontal_line(dest_ctx, 0, quarter_height, width, false); + canvas.draw_horizontal_line(dest_ctx, 0, half_height + quarter_height, width, false); + canvas.draw_vertical_line(dest_ctx, quarter_width, 0, height, false); + canvas.draw_vertical_line(dest_ctx, half_width + quarter_width, 0, height, false); + } +} + +} // end namespace diff --git a/src/editing_tools/mk/editing_tools.mk b/src/editing_tools/mk/editing_tools.mk new file mode 100644 index 0000000..424c525 --- /dev/null +++ b/src/editing_tools/mk/editing_tools.mk @@ -0,0 +1,28 @@ + +# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with +# a init() and terminate() function declared in the namespace. +UNIT_NAME := EditingTools + +# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all +# lowercase. The name of this file should be UNIT_DIR.mk. +UNIT_DIR := editing_tools + +# All Vala files in the unit should be listed here with no subdirectory prefix. +# +# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala. +UNIT_FILES := \ + StraightenTool.vala + +# Any unit this unit relies upon (and should be initialized before it's initialized) should +# be listed here using its Vala namespace. +# +# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here. +UNIT_USES := + +# List any additional files that are used in the build process as a part of this unit that should +# be packaged in the tarball. File names should be relative to the unit's home directory. +UNIT_RC := + +# unitize.mk must be called at the end of each UNIT_DIR.mk file. +include unitize.mk + |