summaryrefslogtreecommitdiff
path: root/src/editing_tools/StraightenTool.vala
diff options
context:
space:
mode:
Diffstat (limited to 'src/editing_tools/StraightenTool.vala')
-rw-r--r--src/editing_tools/StraightenTool.vala559
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