source: sasview/src/sas/qtgui/Plotting/Binder.py @ d32a594

Last change on this file since d32a594 was 75906a1, checked in by Piotr Rozyczko <piotr.rozyczko@…>, 6 years ago

More fixes from PK's CR

  • Property mode set to 100644
File size: 14.8 KB
Line 
1"""
2Extension to MPL to support the binding of artists to key/mouse events.
3"""
4import sys
5import logging
6
7class Selection(object):
8    """
9    Store and compare selections.
10    """
11    # TODO: We need some way to check in prop matches, preferably
12    # TODO: without imposing structure on prop.
13
14    artist = None
15    prop = {}
16
17    def __init__(self, artist=None, prop={}):
18        self.artist, self.prop = artist, self.prop
19
20    def __eq__(self, other):
21        return self.artist is other.artist
22
23    def __ne__(self, other):
24        return self.artist is not other.artist
25
26    def __bool__(self):
27        return self.artist is not None
28
29
30class BindArtist(object):
31    """
32    """
33    # Track keyboard modifiers for events.
34    # TODO: Move keyboard modifier support into the backend.  We cannot
35    # TODO: properly support it from outside the windowing system since there
36    # TODO: is no way to recognized whether shift is held down when the mouse
37    # TODO: first clicks on the the application window.
38    control, shift, alt, meta = False, False, False, False
39
40    # Track doubleclick
41    dclick_threshhold = 0.25
42    _last_button, _last_time = None, 0
43
44    # Mouse/keyboard events we can bind to
45    events = ['enter', 'leave', 'motion', 'click', 'dclick', 'drag', 'release',
46              'scroll', 'key', 'keyup']
47    # TODO: Need our own event structure
48
49    def __init__(self, figure):
50        canvas = figure.canvas
51
52        # Link to keyboard/mouse
53        try:
54            self._connections = [
55                canvas.mpl_connect('motion_notify_event', self._onMotion),
56                canvas.mpl_connect('button_press_event', self._onClick),
57                canvas.mpl_connect('button_release_event', self._onRelease),
58                canvas.mpl_connect('key_press_event', self._onKey),
59                canvas.mpl_connect('key_release_event', self._onKeyRelease),
60                canvas.mpl_connect('scroll_event', self._onScroll)
61            ]
62        except:
63            print("bypassing scroll_event: wrong matplotlib version")
64            self._connections = [
65                canvas.mpl_connect('motion_notify_event', self._onMotion),
66                canvas.mpl_connect('button_press_event', self._onClick),
67                canvas.mpl_connect('button_release_event', self._onRelease),
68                canvas.mpl_connect('key_press_event', self._onKey),
69                canvas.mpl_connect('key_release_event', self._onKeyRelease),
70            ]
71
72        self._current = None
73        self._actions = {}
74        self.canvas = canvas
75        self.figure = figure
76        self.clearall()
77
78    def clear(self, *artists):
79        """
80        self.clear(h1,h2,...)
81            Remove connections for artists h1, h2, ...
82
83        Use clearall() to reset all connections.
84        """
85
86        for h in artists:
87            for a in self.events:
88                if h in self._actions[a]:
89                    del self._actions[a][h]
90            if h in self._artists:
91                self._artists.remove(h)
92        if self._current.artist in artists:
93            self._current = Selection()
94        if self._hasclick.artist in artists:
95            self._hasclick = Selection()
96        if self._haskey.artist in artists:
97            self._haskey = Selection()
98
99    def clearall(self):
100        """
101        Clear connections to all artists.
102
103        Use clear(h1,h2,...) to reset specific artists.
104        """
105        # Don't monitor any actions
106        self._actions = {}
107        for action in self.events:
108            self._actions[action] = {}
109
110        # Need activity state
111        self._artists = []
112        self._current = Selection()
113        self._hasclick = Selection()
114        self._haskey = Selection()
115
116    def disconnect(self):
117        """
118        In case we need to disconnect from the canvas...
119        """
120        try:
121            for cid in self._connections: self.canvas.mpl_disconnect(cid)
122        except:
123            logging.error("Error disconnection canvas: %s" % sys.exc_info()[1])
124        self._connections = []
125
126    def __del__(self):
127        self.disconnect()
128
129    def __call__(self, trigger, artist, action):
130        """Register a callback for an artist to a particular trigger event.
131
132        usage:
133            self.connect(eventname,artist,action)
134
135        where:
136            eventname is a string
137            artist is the particular graph object to respond to the event
138            action(event,**kw) is called when the event is triggered
139
140        The action callback is associated with particular artists.
141        Different artists will have different kwargs.  See documentation
142        on the contains() method for each artist.  One common properties
143        are ind for the index of the item under the cursor, which is
144        returned by Line2D and by collections.
145
146        The following events are supported:
147            enter: mouse cursor moves into the artist or to a new index
148            leave: mouse cursor leaves the artist
149            click: mouse button pressed on the artist
150            drag: mouse button pressed on the artist and cursor moves
151            release: mouse button released for the artist
152            key: key pressed when mouse is on the artist
153            keyrelease: key released for the artist
154
155        The event received by action has a number of attributes:
156            name is the event name which was triggered
157            artist is the object which triggered the event
158            x,y are the screen coordinates of the mouse
159            xdata,ydata are the graph coordinates of the mouse
160            button is the mouse button being pressed/released
161            key is the key being pressed/released
162            shift,control,alt,meta are flags which are true if the
163                corresponding key is pressed at the time of the event.
164            details is a dictionary of artist specific details, such as the
165                id(s) of the point that were clicked.
166
167        When receiving an event, first check the modifier state to be
168        sure it applies.  E.g., the callback for 'press' might be:
169            if event.button == 1 and event.shift: process Shift-click
170
171        TODO: Only receive events with the correct modifiers (e.g., S-click,
172        TODO:   or *-click for any modifiers).
173        TODO: Only receive button events for the correct button (e.g., click1
174        TODO:   release3, or dclick* for any button)
175        TODO: Support virtual artist, so that and artist can be flagged as
176        TODO:   having a tag list and receive the correct events
177        TODO: Support virtual events for binding to button-3 vs shift button-1
178        TODO:   without changing callback code
179        TODO: Attach multiple callbacks to the same event?
180        TODO: Clean up interaction with toolbar modes
181        TODO: push/pushclear/pop context so that binding changes for
182             the duration
183        TODO:   e.g., to support ? context sensitive help
184        """
185        # Check that the trigger is valid
186        if trigger not in self._actions:
187            raise ValueError("%s invalid --- valid triggers are %s"\
188                 % (trigger, ", ".join(self.events)))
189
190        # Register the trigger callback
191        self._actions[trigger][artist] = action
192
193        # Maintain a list of all artists
194        if artist not in self._artists:
195            self._artists.append(artist)
196
197    def trigger(self, actor, action, ev):
198        """
199        Trigger a particular event for the artist.  Fallback to axes,
200        to figure, and to 'all' if the event is not processed.
201        """
202        if action not in self.events:
203            raise ValueError("Trigger expects " + ", ".join(self.events))
204
205        # Tag the event with modifiers
206        for mod in ('alt', 'control', 'shift', 'meta'):
207            setattr(ev, mod, getattr(self, mod))
208        setattr(ev, 'artist', None)
209        setattr(ev, 'action', action)
210        setattr(ev, 'prop', {})
211
212        # Fallback scheme.  If the event does not return false, pass to parent.
213        processed = False
214        artist, prop = actor.artist, actor.prop
215        if artist in self._actions[action]:
216            ev.artist, ev.prop = artist, prop
217            processed = self._actions[action][artist](ev)
218        if not processed and ev.inaxes in self._actions[action]:
219            ev.artist, ev.prop = ev.inaxes, {}
220            processed = self._actions[action][ev.inaxes](ev)
221        if not processed and self.figure in self._actions[action]:
222            ev.artist, ev.prop = self.figure, {}
223            processed = self._actions[action][self.figure](ev)
224        if not processed and 'all' in self._actions[action]:
225            ev.artist, ev.prop = None, {}
226            processed = self._actions[action]['all'](ev)
227        return processed
228
229    def _find_current(self, event):
230        """
231        Find the artist who will receive the event.  Only search
232        registered artists.  All others are invisible to the mouse.
233        """
234        # TODO: sort by zorder of axes then by zorder within axes
235        found = Selection()
236        self._artists.sort(key=lambda x: x.zorder, reverse=True)
237        for artist in self._artists:
238            # TODO: should contains() return false if invisible?
239            if not artist.get_visible():
240                continue
241            # TODO: optimization - exclude artists not inaxes
242            try:
243                inside, prop = artist.contains(event)
244            except:
245                # Probably an old version of matplotlib
246                inside = False
247            if inside:
248                found.artist, found.prop = artist, prop
249                break
250
251        # TODO: how to check if prop is equal?
252        if found != self._current:
253            self.trigger(self._current, 'leave', event)
254            self.trigger(found, 'enter', event)
255        self._current = found
256
257        return found
258
259    def _onMotion(self, event):
260        """
261        Track enter/leave/motion through registered artists; all
262        other artists are invisible.
263        """
264        # # Can't kill double-click on motion since Windows produces
265        # # spurious motion events.
266        # self._last_button = None
267
268        # Dibs on the motion event for the clicked artist
269        if self._hasclick:
270            # Make sure the x,y data use the coordinate system of the
271            # artist rather than the default axes coordinates.
272
273            transform = self._hasclick.artist.get_transform()
274            # x,y = event.xdata,event.ydata
275            x, y = event.x, event.y
276            try:
277                if transform.__class__.__name__ == "IdentityTransform":
278                    x, y = transform.inverted().transform((x, y))
279                else:
280                    # # For interactive plottable apply transform is not working
281                    # # don't know why maybe marker definition
282                    # #transform ="CompositeGenericTransform" crash
283                    pass
284            except:
285                # # CRUFT matplotlib-0.91 support
286                # # exception for transform ="CompositeGenericTransform"
287                # # crashes also here
288                x, y = transform.inverse_xy_tup((x, y))
289
290            # event.xdata, event.ydata = x, y
291            self.trigger(self._hasclick, 'drag', event)
292        else:
293            found = self._find_current(event)
294            self.trigger(found, 'motion', event)
295
296    def _onClick(self, event):
297        """
298        Process button click
299        """
300        import time
301
302        # Check for double-click
303        event_time = time.time()
304        if (event.button != self._last_button) or \
305                (event_time > self._last_time + self.dclick_threshhold):
306            action = 'click'
307        else:
308            action = 'dclick'
309        self._last_button = event.button
310        self._last_time = event_time
311
312        # If an artist is already dragging, feed any additional button
313        # presses to that artist.
314        # TODO: do we want to force a single button model on the user?
315        # TODO: that is, once a button is pressed, no other buttons
316        # TODO: can come through?  I think this belongs in canvas, not here.
317        if self._hasclick:
318            found = self._hasclick
319        else:
320            found = self._find_current(event)
321        # print "button %d pressed"%event.button
322        # Note: it seems like if "click" returns False then hasclick should
323        # not be set.  The problem is that there are two reasons it can
324        # return false: because there is no click action for this artist
325        # or because the click action returned false.  A related problem
326        # is that click actions will go to the canvas if there is no click
327        # action for the artist, even if the artist has a drag. I'll leave
328        # it to future maintainers to sort out this problem.  For now the
329        # recommendation is that users should define click if they have
330        # drag or release on the artist.
331        self.trigger(found, action, event)
332        self._hasclick = found
333
334    def _onDClick(self, event):
335        """
336        Process button double click
337        """
338        # If an artist is already dragging, feed any additional button
339        # presses to that artist.
340        # TODO: do we want to force a single button model on the user?
341        # TODO: that is, once a button is pressed, no other buttons
342        # TODO: can come through?  I think this belongs in canvas, not here.
343        if self._hasclick:
344            found = self._hasclick
345        else:
346            found = self._find_current(event)
347        self.trigger(found, 'dclick', event)
348        self._hasclick = found
349
350    def _onRelease(self, event):
351        """
352        Process release release
353        """
354        self.trigger(self._hasclick, 'release', event)
355        self._hasclick = Selection()
356
357    def _onKey(self, event):
358        """
359        Process key click
360        """
361        # TODO: Do we really want keyboard focus separate from mouse focus?
362        # TODO: Do we need an explicit focus command for keyboard?
363        # TODO: Can we tab between items?
364        # TODO: How do unhandled events get propogated to axes, figure and
365        # TODO: finally to application?  Do we need to implement a full tags
366        # TODO: architecture a la Tk?
367        # TODO: Do modifiers cause a grab?  Does the artist see the modifiers?
368        if event.key in ('alt', 'meta', 'control', 'shift'):
369            setattr(self, event.key, True)
370            return
371
372        if self._haskey:
373            found = self._haskey
374        else:
375            found = self._find_current(event)
376        self.trigger(found, 'key', event)
377        self._haskey = found
378
379    def _onKeyRelease(self, event):
380        """
381        Process key release
382        """
383        if event.key in ('alt', 'meta', 'control', 'shift'):
384            setattr(self, event.key, False)
385            return
386
387        if self._haskey:
388            self.trigger(self._haskey, 'keyup', event)
389        self._haskey = Selection()
390
391    def _onScroll(self, event):
392        """
393        Process scroll event
394        """
395        found = self._find_current(event)
396        self.trigger(found, 'scroll', event)
397
Note: See TracBrowser for help on using the repository browser.