diff options
author | Jörg Frings-Fürst <debian@jff-webhosting.net> | 2014-07-23 09:06:59 +0200 |
---|---|---|
committer | Jörg Frings-Fürst <debian@jff-webhosting.net> | 2014-07-23 09:06:59 +0200 |
commit | 4ea2cc3bd4a7d9b1c54a9d33e6a1cf82e7c8c21d (patch) | |
tree | d2e54377d14d604356c86862a326f64ae64dadd6 /src/editing_tools/StraightenTool.vala |
Imported Upstream version 0.18.1upstream/0.18.1
Diffstat (limited to 'src/editing_tools/StraightenTool.vala')
-rw-r--r-- | src/editing_tools/StraightenTool.vala | 559 |
1 files changed, 559 insertions, 0 deletions
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 |