source: sasview/src/sas/sasgui/plottools/binder.py @ b9d74f3

Last change on this file since b9d74f3 was b9d74f3, checked in by andyfaff, 8 years ago

MAINT: use raise Exception() not raise Exception

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