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