1/*
2* Copyright (c) 2018 (https://github.com/phase1geo/Minder)
3*
4* This program is free software; you can redistribute it and/or
5* modify it under the terms of the GNU General Public
6* License as published by the Free Software Foundation; either
7* version 2 of the License, or (at your option) any later version.
8*
9* This program is distributed in the hope that it will be useful,
10* but WITHOUT ANY WARRANTY; without even the implied warranty of
11* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
12* General Public License for more details.
13*
14* You should have received a copy of the GNU General Public
15* License along with this program; if not, write to the
16* Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
17* Boston, MA 02110-1301 USA
18*
19* Authored by: Trevor Williams <phase1geo@gmail.com>
20*/
21
22using Gtk;
23using Gdk;
24using Cairo;
25
26class ImageEditor {
27
28  private const double MIN_WIDTH  = 50;
29  private const int    CROP_WIDTH = 8;
30  private const Gtk.TargetEntry[] DRAG_TARGETS = {
31    {"text/uri-list", 0, 0}
32  };
33
34  private Popover         _popover;
35  private ImageManager    _im;
36  private DrawingArea     _da;
37  private Node            _node;
38  private NodeImage       _image;
39  private Button          _paste;
40  private int             _crop_target = -1;
41  private double          _last_x;
42  private double          _last_y;
43  private Gdk.Rectangle[] _crop_points;
44  private CursorType[]    _crop_cursors;
45  private Label           _status_cursor;
46  private Label           _status_crop;
47
48  public signal void changed( NodeImage? orig_image );
49
50  /* Default constructor */
51  public ImageEditor( DrawArea da ) {
52
53    _im = da.image_manager;
54
55    /* Allocate crop points */
56    _crop_points  = new Gdk.Rectangle[9];
57    _crop_cursors = new CursorType[8];
58
59    /* Initialize the crop points */
60    for( int i=0; i<_crop_points.length; i++ ) {
61      _crop_points[i] = {0, 0, CROP_WIDTH, CROP_WIDTH};
62    }
63
64    /* Setup cursor types */
65    _crop_cursors[0] = CursorType.TOP_LEFT_CORNER;
66    _crop_cursors[1] = CursorType.SB_V_DOUBLE_ARROW;
67    _crop_cursors[2] = CursorType.TOP_RIGHT_CORNER;
68    _crop_cursors[3] = CursorType.SB_H_DOUBLE_ARROW;
69    _crop_cursors[4] = CursorType.SB_H_DOUBLE_ARROW;
70    _crop_cursors[5] = CursorType.TOP_RIGHT_CORNER;
71    _crop_cursors[6] = CursorType.SB_V_DOUBLE_ARROW;
72    _crop_cursors[7] = CursorType.TOP_LEFT_CORNER;
73
74    /* Create the user interface of the editor window */
75    create_ui( da, da.image_manager );
76
77  }
78
79  /* Opens an image editor popup containing the image of the specified node */
80  public void edit_image( ImageManager im, Node node, double x, double y ) {
81
82    var int_x = (int)x;
83    var int_y = (int)y;
84    Gdk.Rectangle rect = {int_x, int_y, 1, 1};
85    _popover.pointing_to = rect;
86
87    /* Set the defaults */
88    _node  = node;
89    _image = new NodeImage( im, node.image.id, _node.style.node_width );
90
91    if( _image.valid ) {
92
93      _image.crop_x = node.image.crop_x;
94      _image.crop_y = node.image.crop_y;
95      _image.crop_w = node.image.crop_w;
96      _image.crop_h = node.image.crop_h;
97
98      /* Load the image and draw it */
99      _da.width_request      = node.image.get_surface().get_width();
100      _da.height_request     = node.image.get_surface().get_height();
101      _crop_points[8].width  = _image.crop_w;
102      _crop_points[8].height = _image.crop_h;
103      set_crop_points();
104      _da.queue_draw();
105
106      /* Display ourselves */
107      Utils.show_popover( _popover );
108
109    }
110
111  }
112
113  /* Initializes the image editor with the give image filename */
114  private bool initialize( NodeImage ni ) {
115
116    /* Create a new image from the given filename */
117    _image = ni;
118
119    /* Load the image and draw it */
120    if( _image.valid ) {
121      _da.width_request      = _image.get_surface().get_width();
122      _da.height_request     = _image.get_surface().get_height();
123      _crop_points[8].width  = _image.crop_w;
124      _crop_points[8].height = _image.crop_h;
125      set_crop_points();
126      set_cursor_location( 0, 0 );
127      _da.queue_draw();
128    }
129
130    return( _image.valid );
131
132  }
133
134  /* Set the crop point positions to the values on the current crop region */
135  private void set_crop_points() {
136
137    var x0 = _image.crop_x;
138    var x1 = (_image.crop_x + (_image.crop_w / 2) - (CROP_WIDTH / 2));
139    var x2 = ((_image.crop_x + _image.crop_w) - CROP_WIDTH);
140    var y0 = _image.crop_y;
141    var y1 = (_image.crop_y + (_image.crop_h / 2) - (CROP_WIDTH / 2));
142    var y2 = ((_image.crop_y + _image.crop_h) - CROP_WIDTH);
143
144    _crop_points[0].x = x0;
145    _crop_points[0].y = y0;
146    _crop_points[1].x = x1;
147    _crop_points[1].y = y0;
148    _crop_points[2].x = x2;
149    _crop_points[2].y = y0;
150    _crop_points[3].x = x0;
151    _crop_points[3].y = y1;
152    _crop_points[4].x = x2;
153    _crop_points[4].y = y1;
154    _crop_points[5].x = x0;
155    _crop_points[5].y = y2;
156    _crop_points[6].x = x1;
157    _crop_points[6].y = y2;
158    _crop_points[7].x = x2;
159    _crop_points[7].y = y2;
160
161    _crop_points[8].x      = x0;
162    _crop_points[8].y      = y0;
163    _crop_points[8].width  = _image.crop_w;
164    _crop_points[8].height = _image.crop_h;
165
166    _status_crop.label = _( "Crop Area: %d,%d %3dx%3d" ).printf( _crop_points[8].x, _crop_points[8].y, _crop_points[8].width, _crop_points[8].height );
167
168  }
169
170  /* Set the crop target based on the position of the cursor */
171  private void set_crop_target( double x, double y ) {
172    var int_x = (int)x;
173    var int_y = (int)y;
174    Gdk.Rectangle cursor = {int_x, int_y, 1, 1};
175    Gdk.Rectangle tmp;
176    int           i      = 0;
177    foreach (Gdk.Rectangle crop_point in _crop_points) {
178      if( crop_point.intersect( cursor, out tmp ) ) {
179        _crop_target = i;
180        return;
181      }
182      i++;
183    }
184    _crop_target = -1;
185  }
186
187  /* Adjusts the crop points by the given cursor difference */
188  private void adjust_crop_points( int diffx, int diffy ) {
189    if( _crop_target != -1 ) {
190      var x = _image.crop_x;
191      var y = _image.crop_y;
192      var w = _image.crop_w;
193      var h = _image.crop_h;
194      switch( _crop_target ) {
195        case 0 :  x += diffx;  y += diffy;  w -= diffx;  h -= diffy;  break;
196        case 1 :               y += diffy;               h -= diffy;  break;
197        case 2 :               y += diffy;  w += diffx;  h -= diffy;  break;
198        case 3 :  x += diffx;               w -= diffx;               break;
199        case 4 :                            w += diffx;               break;
200        case 5 :  x += diffx;               w -= diffx;  h += diffy;  break;
201        case 6 :                                         h += diffy;  break;
202        case 7 :                            w += diffx;  h += diffy;  break;
203        case 8 :  x += diffx;  y += diffy;                            break;
204      }
205      if( (x >= 0) && ((x + w) <= _da.width_request) && (w >= MIN_WIDTH) ) {
206        _image.crop_x = x;
207        _image.crop_w = w;
208      }
209      if( (y >= 0) && ((y + h) <= _da.height_request) && (h >= MIN_WIDTH) ) {
210        _image.crop_y = y;
211        _image.crop_h = h;
212      }
213      set_crop_points();
214    }
215  }
216
217  /* Creates the user interface */
218  public void create_ui( DrawArea da, ImageManager im ) {
219
220    _popover = new Popover( da );
221    _popover.modal = true;
222
223    var box = new Box( Orientation.VERTICAL, 5 );
224
225    box.border_width = 5;
226
227    _da = create_drawing_area( im );
228    var status  = create_status_area();
229    var buttons = create_buttons( da, im );
230
231    /* Pack the widgets into the window */
232    box.pack_start( _da,     true,  true );
233    box.pack_start( status,  false, false );
234    box.pack_start( buttons, false, true );
235
236    box.show_all();
237
238    /* Add the box to the popover */
239    _popover.add( box );
240
241    /* Set the stage for keyboard shortcuts */
242    _popover.key_press_event.connect( (e) => {
243      var control = (bool)(e.state & ModifierType.CONTROL_MASK);
244      if( control ) {
245        switch( e.keyval ) {
246          case 99    :  action_copy();    break;
247          case 118   :  action_paste();   break;
248          case 120   :  action_cut();     break;
249          default    :  return( false );
250        }
251      } else {
252        switch( e.keyval ) {
253          case 65293 :  action_apply();   break;
254          case 65307 :  action_cancel();  break;
255          case 65535 :  action_delete();  break;
256          default    :  return( false );
257        }
258      }
259      return( true );
260    });
261
262    /* Update the UI state whenever the mouse enters the popover area */
263    _popover.enter_notify_event.connect( (e) => {
264      update_ui();
265      return( true );
266    });
267
268    /* Initialize the past button state */
269    update_ui();
270
271  }
272
273  /* Create the image editing area */
274  public DrawingArea create_drawing_area( ImageManager im ) {
275
276    var da = new DrawingArea();
277
278    da.width_request  = NodeImage.EDIT_WIDTH;
279    da.height_request = NodeImage.EDIT_HEIGHT;
280
281    /* Make sure the above events are listened for */
282    da.add_events(
283      EventMask.BUTTON_PRESS_MASK |
284      EventMask.BUTTON_RELEASE_MASK |
285      EventMask.BUTTON1_MOTION_MASK |
286      EventMask.POINTER_MOTION_MASK
287    );
288
289    /*
290     Make sure that we add a CSS class name to ourselves so we can color
291     our background with the theme.
292    */
293    da.get_style_context().add_class( "canvas" );
294
295    /* Add event listeners */
296    da.draw.connect((ctx) => {
297      draw_image( ctx );
298      return( false );
299    });
300
301    da.button_press_event.connect((e) => {
302      set_crop_target( e.x, e.y );
303      if( _crop_target == 8 ) {
304        var win = _da.get_window();
305        win.set_cursor( new Cursor.from_name( _popover.get_display(), "grabbing" ) );
306      }
307      _last_x = e.x;
308      _last_y = e.y;
309      return( false );
310    });
311
312    da.motion_notify_event.connect((e) => {
313      if( _crop_target == -1 ) {
314        set_crop_target( e.x, e.y );
315        if( (_crop_target >= 0) && (_crop_target < 8) ) {
316          set_cursor( _crop_cursors[_crop_target] );
317        } else {
318          set_cursor( null );
319        }
320        _crop_target = -1;
321      } else {
322        adjust_crop_points( (int)(e.x - _last_x), (int)(e.y - _last_y) );
323        da.queue_draw();
324      }
325      _last_x = e.x;
326      _last_y = e.y;
327      var int_x = (int)e.x;
328      var int_y = (int)e.y;
329      set_cursor_location( int_x, int_y );
330      return( false );
331    });
332
333    da.button_release_event.connect((e) => {
334      _crop_target = -1;
335      set_cursor( null );
336      return( false );
337    });
338
339    /* Set ourselves up to be a drag target */
340    Gtk.drag_dest_set( da, DestDefaults.MOTION | DestDefaults.DROP, DRAG_TARGETS, Gdk.DragAction.COPY );
341
342    da.drag_data_received.connect((ctx, x, y, data, info, t) => {
343      if( data.get_uris().length == 1 ) {
344        NodeImage? ni = new NodeImage.from_uri( im, data.get_uris()[0], _node.style.node_width );
345        if( (ni != null) && initialize( ni ) ) {
346          Gtk.drag_finish( ctx, true, false, t );
347        }
348      }
349    });
350
351    return( da );
352
353  }
354
355  /* Creates the status area */
356  private Box create_status_area() {
357
358    var box = new Box( Orientation.HORIZONTAL, 10 );
359
360    box.homogeneous = true;
361
362    _status_cursor = new Label( null );
363    _status_crop   = new Label( null );
364
365    box.pack_start( _status_cursor, false, false );
366    box.pack_start( _status_crop,   false, false );
367
368    return( box );
369
370  }
371
372  /* Updates the cursor location status with the given values */
373  private void set_cursor_location( int x, int y ) {
374    _status_cursor.label = _( "Cursor: %3d,%3d" ).printf( x, y );
375  }
376
377  /* Creates the button bar at the bottom of the window */
378  private Box create_buttons( DrawArea da, ImageManager im ) {
379
380    var box    = new Box( Orientation.HORIZONTAL, 5 );
381    var cancel = new Button.with_label( _( "Cancel" ) );
382    var apply  = new Button.with_label( _( "Apply" ) );
383    var open   = new Button.from_icon_name( "folder-open-symbolic", IconSize.SMALL_TOOLBAR );
384    var copy   = new Button.from_icon_name( "edit-copy-symbolic",   IconSize.SMALL_TOOLBAR );
385    var cut    = new Button.from_icon_name( "edit-cut-symbolic",    IconSize.SMALL_TOOLBAR );
386    var paste  = new Button.from_icon_name( "edit-paste-symbolic",  IconSize.SMALL_TOOLBAR );
387    var del    = new Button.from_icon_name( "edit-delete-symbolic", IconSize.SMALL_TOOLBAR );
388
389    _paste = paste;
390
391    /* Create tooltips for all buttons */
392    open.set_tooltip_markup(  Utils.tooltip_with_accel( _( "Change Image" ),               "<Control>o" ) );
393    copy.set_tooltip_markup(  Utils.tooltip_with_accel( _( "Copy Image to Clipboard" ),    "<Control>c" ) );
394    cut.set_tooltip_markup(   Utils.tooltip_with_accel( _( "Cut Image to Clipboard" ),     "<Control>x" ) );
395    paste.set_tooltip_markup( Utils.tooltip_with_accel( _( "Paste Image from Clipboard" ), "<Control>v" ) );
396    del.set_tooltip_markup(   Utils.tooltip_with_accel( _( "Remove Image" ),              "Delete" ) );
397
398    open.clicked.connect(() => {
399      var win = (Gtk.Window)da.get_toplevel();
400      var id  = im.choose_image( win );
401      if( id != -1 ) {
402        var ni = new NodeImage( im, id, _node.style.node_width );
403        if( ni != null ) {
404          initialize( ni );
405        }
406      }
407    });
408
409    cancel.clicked.connect( action_cancel );
410    apply.clicked.connect(  action_apply );
411    copy.clicked.connect( action_copy );
412    cut.clicked.connect(  action_cut );
413    paste.clicked.connect( action_paste );
414    del.clicked.connect( action_delete );
415
416    box.pack_start( open,   false, false );
417    box.pack_start( paste,  false, false );
418    box.pack_start( del,    false, false );
419    box.pack_start( copy,   false, false );
420    box.pack_start( cut,    false, false );
421    box.pack_end(   apply,  false, false );
422    box.pack_end(   cancel, false, false );
423
424    return( box );
425
426  }
427
428  /* Sets the cursor of the drawing area */
429  private void set_cursor( CursorType? type = null ) {
430
431    var     win    = _da.get_window();
432    Cursor? cursor = win.get_cursor();
433
434    if( type == null ) {
435      win.set_cursor( null );
436    } else if( (cursor == null) || (cursor.cursor_type != type) ) {
437      win.set_cursor( new Cursor.for_display( _popover.get_display(), type ) );
438    }
439
440  }
441
442  /* Add the image */
443  private void draw_image( Context ctx ) {
444
445    /* Draw the cropped portion of the image */
446    ctx.set_source_surface( _image.get_surface(), 0, 0 );
447    ctx.paint();
448
449    /* On top of that, draw the crop transparency */
450    ctx.set_source_rgba( 0, 0, 0, 0.8 );
451    ctx.rectangle( 0, 0, _da.width_request, _da.height_request );
452    ctx.fill();
453
454    /* Cut out the area for the image */
455    ctx.set_operator( Operator.CLEAR );
456    ctx.rectangle( _image.crop_x, _image.crop_y, _image.crop_w, _image.crop_h );
457    ctx.fill();
458
459    /* Finally, draw the portion of the image this not cropped */
460    ctx.set_operator( Operator.OVER );
461    ctx.set_source_surface( _image.get_surface(), 0, 0 );
462    ctx.rectangle( _image.crop_x, _image.crop_y, _image.crop_w, _image.crop_h );
463    ctx.fill();
464
465    /* Draw the crop points */
466    ctx.set_line_width( 1 );
467    for( int i=0; i<8; i++ ) {
468      draw_crop_point( ctx, _crop_points[i] );
469    }
470
471  }
472
473  /* Draws a single crop point at the given point with the given width/height */
474  private void draw_crop_point( Context ctx, Gdk.Rectangle crop ) {
475
476    ctx.set_source_rgb( 1, 1, 1 );
477    ctx.rectangle( crop.x, crop.y, crop.width, crop.width );
478    ctx.fill();
479
480    ctx.set_source_rgb( 0, 0, 0 );
481    ctx.rectangle( crop.x, crop.y, crop.width, crop.width );
482    ctx.stroke();
483
484  }
485
486  /* Removes the current image for the node */
487  private void remove_image( ImageManager im ) {
488
489    /* Create a copy of the current image before changing it */
490    var orig_image = _node.image;
491
492    /* Clear the node image */
493    _node.set_image( im, null );
494
495    /* Indicate that the image changed */
496    changed( orig_image );
497
498    /* Hide the popover */
499    Utils.hide_popover( _popover );
500
501  }
502
503  /* Sets the node image to the edited image */
504  private void set_image( ImageManager im ) {
505
506    /* Create a copy of the current image before changing it */
507    var orig_image = _node.image;
508
509    /* Set the image width to match the node's max width */
510    _image.set_width( _node.style.node_width );
511
512    /* Set the node image */
513    _node.set_image( im, _image );
514
515    /* Indicate that the image changed */
516    changed( orig_image );
517
518    /* Close the popover */
519    Utils.hide_popover( _popover );
520
521  }
522
523  /* Returns true if an image is pasteable from the clipboard */
524  private bool image_pasteable() {
525    var clipboard = Clipboard.get_default( _popover.get_display() );
526    return( clipboard.wait_is_image_available() );
527  }
528
529  /* Updates the state of the UI */
530  private void update_ui() {
531    _paste.set_sensitive( image_pasteable() );
532  }
533
534  /* Copies the current image to the clipboard */
535  private void action_copy() {
536    var fname = _im.get_file( _node.image.id );
537    if( fname != null ) {
538      try {
539        var buf       = new Gdk.Pixbuf.from_file( fname );
540        var clipboard = Clipboard.get_default( _popover.get_display() );
541        clipboard.clear();
542        clipboard.set_image( buf );
543        update_ui();
544      } catch( Error e ) {}
545    }
546  }
547
548  /* Copies the image to the clipboard and removes the current image */
549  private void action_cut() {
550    action_copy();
551    remove_image( _im );
552  }
553
554  /* Pastes the image from the clipboard */
555  private void action_paste() {
556    if( image_pasteable() ) {
557      var clipboard = Clipboard.get_default( _popover.get_display() );
558      var buf       = clipboard.wait_for_image();
559      var image     = new NodeImage.from_pixbuf( _im, buf, _node.style.node_width );
560      image.crop_x = _image.crop_x;
561      image.crop_y = _image.crop_y;
562      image.crop_w = _image.crop_w;
563      image.crop_h = _image.crop_h;
564      _image       = image;
565      _da.queue_draw();
566    } else {
567      update_ui();
568    }
569  }
570
571  /* Deletes the current image */
572  private void action_delete() {
573    remove_image( _im );
574  }
575
576  /* Cancels this editing session */
577  private void action_cancel() {
578    Utils.hide_popover( _popover );
579  }
580
581  /* Applies the current edits and closes the window */
582  private void action_apply() {
583    set_image( _im );
584  }
585
586}
587
588