1# This file is part of MyPaint. 2# -*- coding: utf-8 -*- 3# Copyright (C) 2015 by Andrew Chadwick <a.t.chadwick@gmail.com> 4# 5# This program is free software; you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation; either version 2 of the License, or 8# (at your option) any later version. 9 10 11"""UI behaviour for picking things from the canvas. 12 13The grab and button behaviour objects work like MVP presenters 14with a rather wide scope. 15 16""" 17 18## Imports 19from __future__ import division, print_function 20 21from gui.tileddrawwidget import TiledDrawWidget 22from gui.document import Document 23from lib.gettext import C_ 24import gui.cursor 25 26from lib.gibindings import Gtk 27from lib.gibindings import Gdk 28from lib.gibindings import GLib 29 30import abc 31import logging 32logger = logging.getLogger(__name__) 33 34 35## Class definitions 36 37class PickingGrabPresenter (object): 38 """Picking something via a grab (abstract base, MVP presenter) 39 40 This presenter mediates between passive GTK view widgets 41 accessed via the central app, 42 and a model consisting of some drawing state within the application. 43 When activated, it establishes a pointer grab and a keyboard grab, 44 updates the thing being grabbed zero or more times, 45 then exits making sure that the grab is cleaned up correctly. 46 47 """ 48 49 ## Class configuration 50 51 __metaclass__ = abc.ABCMeta 52 53 _GRAB_MASK = (Gdk.EventMask.BUTTON_RELEASE_MASK 54 | Gdk.EventMask.BUTTON_PRESS_MASK 55 | Gdk.EventMask.BUTTON_MOTION_MASK) 56 57 ## Initialization 58 59 def __init__(self): 60 """Basic initialization.""" 61 super(PickingGrabPresenter, self).__init__() 62 self._app = None 63 self._statusbar_info_cache = None 64 self._grab_button_num = None 65 self._grabbed_pointer_dev = None 66 self._grabbed_keyboard_dev = None 67 self._grab_event_handler_ids = None 68 self._delayed_picking_update_id = None 69 70 @property 71 def app(self): 72 """The coordinating app object.""" 73 # FIXME: The view (statusbar, grab owner widget) is accessed 74 # FIXME: through this, which may be a problem in the long term. 75 # FIXME: There's a need to set up event masks before starting 76 # FIXME: the grab, and this may make _start_grab() more fragile. 77 # Ref: https://github.com/mypaint/mypaint/issues/324 78 return self._app 79 80 @app.setter 81 def app(self, app): 82 self._app = app 83 self._statusbar_info_cache = None 84 85 ## Internals 86 87 @property 88 def _grab_owner(self): 89 """The view widget owning the grab.""" 90 return self.app.drawWindow 91 92 @property 93 def _statusbar_info(self): 94 """The view widget and context for displaying status msgs.""" 95 if not self._statusbar_info_cache: 96 statusbar = self.app.statusbar 97 cid = statusbar.get_context_id("picker-button") 98 self._statusbar_info_cache = (statusbar, cid) 99 return self._statusbar_info_cache 100 101 def _hide_status_message(self): 102 """Remove all statusbar messages coming from this class""" 103 statusbar, cid = self._statusbar_info 104 statusbar.remove_all(cid) 105 106 def _show_status_message(self): 107 """Display a status message via the view.""" 108 statusbar, cid = self._statusbar_info 109 statusbar.push(cid, self.picking_status_text) 110 111 ## Activation 112 113 def activate_from_button_event(self, event): 114 """Activate during handling of a GdkEventButton (press/release) 115 116 If the event is a button press, then the grab will start 117 immediately, begin updating immediately, and will terminate by 118 the release of the initiating button. 119 120 If the event is a button release, then the grab start will be 121 deferred to start in an idle handler. When the grab starts, it 122 won't begin updating until the user clicks button 1 (and only 123 button 1), and it will only be terminated with a button1 124 release. This covers the case of events delivered to "clicked" 125 signal handlers 126 127 """ 128 if event.type == Gdk.EventType.BUTTON_PRESS: 129 logger.debug("Starting picking grab") 130 has_button_info, button_num = event.get_button() 131 if not has_button_info: 132 return 133 self._start_grab(event.device, event.time, button_num) 134 elif event.type == Gdk.EventType.BUTTON_RELEASE: 135 logger.debug("Queueing picking grab") 136 GLib.idle_add( 137 self._start_grab, 138 event.device, 139 event.time, 140 None, 141 ) 142 143 ## Required interface for subclasses 144 145 @abc.abstractproperty 146 def picking_cursor(self): 147 """The cursor to use while picking. 148 149 :returns: The cursor to use during the picking grab. 150 :rtype: Gdk.Cursor 151 152 This abstract property must be overridden with an implementation 153 giving an appropriate cursor to display during the picking grab. 154 155 """ 156 157 @abc.abstractproperty 158 def picking_status_text(self): 159 """The statusbar text to use during the grab.""" 160 161 @abc.abstractmethod 162 def picking_update(self, device, x_root, y_root): 163 """Update whatever's being picked during & after picking. 164 165 :param Gdk.Device device: Pointer device currently grabbed 166 :param int x_root: Absolute screen X coordinate 167 :param int y_root: Absolute screen Y coordinate 168 169 This abstract method must be overridden with an implementation 170 which updates the model object being picked. 171 It is always called at the end of the picking grab 172 when button1 is released, 173 and may be called several times during the grab 174 while button1 is held. 175 176 See gui.tileddrawwidget.TiledDrawWidget.get_tdw_under_device() 177 for details of how to get canvas widgets 178 and their related document models and controllers. 179 180 """ 181 182 ## Internals 183 184 def _start_grab(self, device, time, inibutton): 185 """Start the pointer grab, and enter the picking state. 186 187 :param Gdk.Device device: Initiating pointer device. 188 :param int time: The grab start timestamp. 189 :param int inibutton: Initiating pointer button. 190 191 The associated keyboard device is grabbed too. 192 This method assumes that inibutton is currently held. The grab 193 terminates when inibutton is released. 194 195 """ 196 logger.debug("Starting picking grab...") 197 198 # The device to grab must be a virtual device, 199 # because we need to grab its associated virtual keyboard too. 200 # We don't grab physical devices directly. 201 if device.get_device_type() == Gdk.DeviceType.SLAVE: 202 device = device.get_associated_device() 203 elif device.get_device_type() == Gdk.DeviceType.FLOATING: 204 logger.warning( 205 "Cannot start grab on floating device %r", 206 device.get_name(), 207 ) 208 return 209 assert device.get_device_type() == Gdk.DeviceType.MASTER 210 211 # Find the keyboard paired to this pointer. 212 assert device.get_source() != Gdk.InputSource.KEYBOARD 213 keyboard_device = device.get_associated_device() # again! top API! 214 assert keyboard_device.get_device_type() == Gdk.DeviceType.MASTER 215 assert keyboard_device.get_source() == Gdk.InputSource.KEYBOARD 216 217 # Internal state checks 218 assert not self._grabbed_pointer_dev 219 assert not self._grab_button_num 220 assert self._grab_event_handler_ids is None 221 222 # Validate the widget we're expected to grab. 223 owner = self._grab_owner 224 assert owner.get_has_window() 225 window = owner.get_window() 226 assert window is not None 227 228 # Ensure that it'll receive termination events. 229 owner.add_events(self._GRAB_MASK) 230 assert (int(owner.get_events() & self._GRAB_MASK) == int(self._GRAB_MASK)), \ 231 "Grab owner's events must match %r" % (self._GRAB_MASK,) 232 233 # There should be no message in the statusbar from this Grab, 234 # but clear it out anyway. 235 self._hide_status_message() 236 237 # Grab item, pointer first 238 result = device.grab( 239 window = window, 240 grab_ownership = Gdk.GrabOwnership.APPLICATION, 241 owner_events = False, 242 event_mask = self._GRAB_MASK, 243 cursor = self.picking_cursor, 244 time_ = time, 245 ) 246 if result != Gdk.GrabStatus.SUCCESS: 247 logger.error( 248 "Failed to create pointer grab on %r. " 249 "Result: %r.", 250 device.get_name(), 251 result, 252 ) 253 device.ungrab(time) 254 return False # don't requeue 255 256 # Need to grab the keyboard too, since Mypaint uses hotkeys. 257 keyboard_mask = Gdk.EventMask.KEY_PRESS_MASK \ 258 | Gdk.EventMask.KEY_RELEASE_MASK 259 result = keyboard_device.grab( 260 window = window, 261 grab_ownership = Gdk.GrabOwnership.APPLICATION, 262 owner_events = False, 263 event_mask = keyboard_mask, 264 cursor = self.picking_cursor, 265 time_ = time, 266 ) 267 if result != Gdk.GrabStatus.SUCCESS: 268 logger.error( 269 "Failed to create grab on keyboard associated with %r. " 270 "Result: %r", 271 device.get_name(), 272 result, 273 ) 274 device.ungrab(time) 275 keyboard_device.ungrab(time) 276 return False # don't requeue 277 278 # Grab is established 279 self._grabbed_pointer_dev = device 280 self._grabbed_keyboard_dev = keyboard_device 281 logger.debug( 282 "Grabs established on pointer %r and keyboard %r", 283 device.get_name(), 284 keyboard_device.get_name(), 285 ) 286 287 # Tell the user how to work the thing. 288 self._show_status_message() 289 290 # Establish temporary event handlers during the grab. 291 # These are responsible for ending the grab state. 292 handlers = { 293 "button-release-event": self._in_grab_button_release_cb, 294 "motion-notify-event": self._in_grab_motion_cb, 295 "grab-broken-event": self._in_grab_grab_broken_cb, 296 } 297 if not inibutton: 298 handlers["button-press-event"] = self._in_grab_button_press_cb 299 else: 300 self._grab_button_num = inibutton 301 handler_ids = [] 302 for signame, handler_cb in handlers.items(): 303 hid = owner.connect(signame, handler_cb) 304 handler_ids.append(hid) 305 logger.debug("Added handler for %r: hid=%d", signame, hid) 306 self._grab_event_handler_ids = handler_ids 307 308 return False # don't requeue 309 310 def _in_grab_button_press_cb(self, widget, event): 311 assert self._grab_button_num is None 312 if event.type != Gdk.EventType.BUTTON_PRESS: 313 return False 314 if not self._check_event_devices_still_grabbed(event): 315 return 316 has_button_info, button_num = event.get_button() 317 if not has_button_info: 318 return False 319 if event.device is not self._grabbed_pointer_dev: 320 return False 321 self._grab_button_num = button_num 322 return True 323 324 def _in_grab_button_release_cb(self, widget, event): 325 assert self._grab_button_num is not None 326 if event.type != Gdk.EventType.BUTTON_RELEASE: 327 return False 328 if not self._check_event_devices_still_grabbed(event): 329 return 330 has_button_info, button_num = event.get_button() 331 if not has_button_info: 332 return False 333 if button_num != self._grab_button_num: 334 return False 335 if event.device is not self._grabbed_pointer_dev: 336 return False 337 self._end_grab(event) 338 assert self._grab_button_num is None 339 return True 340 341 def _in_grab_motion_cb(self, widget, event): 342 assert self._grabbed_pointer_dev is not None 343 if not self._check_event_devices_still_grabbed(event): 344 return True 345 if event.device is not self._grabbed_pointer_dev: 346 return False 347 if not self._grab_button_num: 348 return False 349 # Due to a performance issue, picking can take more time 350 # than we have between two motion events (about 8ms). 351 if self._delayed_picking_update_id: 352 GLib.source_remove(self._delayed_picking_update_id) 353 self._delayed_picking_update_id = GLib.idle_add( 354 self._delayed_picking_update_cb, 355 event.device, 356 event.x_root, 357 event.y_root, 358 ) 359 return True 360 361 def _in_grab_grab_broken_cb(self, widget, event): 362 logger.debug("Grab broken, cleaning up.") 363 self._ungrab_grabbed_devices(time=event.time) 364 return False 365 366 def _end_grab(self, event): 367 """Finishes the picking grab normally.""" 368 if not self._check_event_devices_still_grabbed(event): 369 return 370 device = event.device 371 try: 372 self.picking_update(device, event.x_root, event.y_root) 373 finally: 374 self._ungrab_grabbed_devices(time=event.time) 375 376 def _check_event_devices_still_grabbed(self, event): 377 """Abandon picking if devices aren't still grabbed. 378 379 This can happen if the escape key is pressed during the grab - 380 the gui.keyboard handler is still invoked in the normal way, 381 and Escape just does an ungrab. 382 383 """ 384 cleanup_needed = False 385 for dev in (self._grabbed_pointer_dev, self._grabbed_keyboard_dev): 386 if not dev: 387 cleanup_needed = True 388 continue 389 display = dev.get_display() 390 if not display.device_is_grabbed(dev): 391 logger.debug( 392 "Device %r is no longer grabbed: will clean up", 393 dev.get_name(), 394 ) 395 cleanup_needed = True 396 if cleanup_needed: 397 self._ungrab_grabbed_devices(time=event.time) 398 return not cleanup_needed 399 400 def _ungrab_grabbed_devices(self, time=Gdk.CURRENT_TIME): 401 """Ungrabs devices thought to be grabbed, and cleans up.""" 402 for dev in (self._grabbed_pointer_dev, self._grabbed_keyboard_dev): 403 if not dev: 404 continue 405 logger.debug("Ungrabbing device %r", dev.get_name()) 406 dev.ungrab(time) 407 # Unhook potential grab leave handlers 408 # but only if the pick succeeded. 409 if self._grab_event_handler_ids: 410 for hid in self._grab_event_handler_ids: 411 owner = self._grab_owner 412 owner.disconnect(hid) 413 self._grab_event_handler_ids = None 414 # Update state (prevents the idler updating a 2nd time) 415 self._grabbed_pointer_dev = None 416 self._grabbed_keyboard_dev = None 417 self._grab_button_num = None 418 self._hide_status_message() 419 420 def _delayed_picking_update_cb(self, ptrdev, x_root, y_root): 421 """Delayed picking updates during grab. 422 423 Some picking operations can be CPU-intensive, so this is called 424 by an idle handler. If the user clicks and releases immediately, 425 this never gets called, so a final call to picking_update() is 426 made separately after the grab finishes. 427 428 See: picking_update(). 429 430 """ 431 try: 432 if ptrdev is self._grabbed_pointer_dev: 433 self.picking_update(ptrdev, x_root, y_root) 434 except: 435 logger.exception("Exception in picking idler") 436 # HMM: if it's not logged here, it won't be recorded... 437 finally: 438 self._delayed_picking_update_id = None 439 return False 440 441class ContextPickingGrabPresenter (PickingGrabPresenter): 442 """Context picking behaviour (concrete MVP presenter)""" 443 444 @property 445 def picking_cursor(self): 446 """The cursor to use while picking""" 447 return self.app.cursors.get_icon_cursor( 448 icon_name = "mypaint-brush-tip-symbolic", 449 cursor_name = gui.cursor.Name.CROSSHAIR_OPEN_PRECISE, 450 ) 451 452 @property 453 def picking_status_text(self): 454 """The statusbar text to use during the grab.""" 455 return C_( 456 "context picker: statusbar text during grab", 457 u"Pick brushstroke settings, stroke color, and layer…", 458 ) 459 460 def picking_update(self, device, x_root, y_root): 461 """Update brush and layer during & after picking.""" 462 # Can only pick from TDWs 463 tdw, x, y = TiledDrawWidget.get_tdw_under_device(device) 464 if tdw is None: 465 return 466 # Determine which document controller owns that tdw 467 doc = None 468 for d in Document.get_instances(): 469 if tdw is d.tdw: 470 doc = d 471 break 472 if doc is None: 473 return 474 # Get that controller to do the pick. 475 # Arguably this should be direct to the model. 476 x, y = tdw.display_to_model(x, y) 477 doc.pick_context(x, y) 478 479class ColorPickingGrabPresenter (PickingGrabPresenter): 480 """Color picking behaviour (concrete MVP presenter)""" 481 482 @property 483 def picking_cursor(self): 484 """The cursor to use while picking""" 485 return self.app.cursors.get_icon_cursor( 486 icon_name = "mypaint-colors-symbolic", 487 cursor_name = gui.cursor.Name.PICKER, 488 ) 489 490 @property 491 def picking_status_text(self): 492 """The statusbar text to use during the grab.""" 493 return C_( 494 "color picker: statusbar text during grab", 495 u"Pick color…", 496 ) 497 498 def picking_update(self, device, x_root, y_root): 499 """Update brush and layer during & after picking.""" 500 tdw, x, y = TiledDrawWidget.get_tdw_under_device(device) 501 if tdw is None: 502 return 503 color = tdw.pick_color(x, y) 504 cm = self.app.brush_color_manager 505 cm.set_color(color) 506 507 508class ButtonPresenter (object): 509 """Picking behaviour for a button (MVP presenter) 510 511 This presenter mediates between a passive view consisting of a 512 button, and a peer PickingGrabPresenter instance which does the 513 actual work after the button is clicked. 514 515 """ 516 517 ## Initialization 518 519 def __init__(self): 520 """Initialize.""" 521 super(ButtonPresenter, self).__init__() 522 self._evbox = None 523 self._button = None 524 self._grab = None 525 526 def set_picking_grab(self, grab): 527 self._grab = grab 528 529 def set_button(self, button): 530 """Connect view button. 531 532 :param Gtk.Button button: the initiator button 533 534 """ 535 button.connect("clicked", self._clicked_cb) 536 self._button = button 537 538 ## Event handling 539 540 def _clicked_cb(self, button): 541 """Handle click events on the initiator button.""" 542 event = Gtk.get_current_event() 543 assert event is not None 544 assert event.type == Gdk.EventType.BUTTON_RELEASE, ( 545 "The docs lie! Current event's type is %r." % (event.type,), 546 ) 547 self._grab.activate_from_button_event(event) 548