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