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