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

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

MAINT: from future import print_function

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