source: sasview/src/sas/sasgui/guiframe/local_perspectives/plotting/binder.py @ 6d7b252b

Last change on this file since 6d7b252b was 5251ec6, checked in by Paul Kienzle <pkienzle@…>, 6 years ago

improved support for py37 in sasgui

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