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

Last change on this file since 6138f73 was cee5c78, checked in by Piotr Rozyczko <rozyczko@…>, 7 years ago

Converted more syntax not covered by 2to3

  • 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
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        #self._artists.sort(cmp=lambda x, y: cmp(y.zorder, x.zorder))
236        #self._artists.sort(cmp=lambda x, y: y.zorder.__gt__(x.zorder))
237        found = Selection()
238        for artist in self._artists:
239            # TODO: should contains() return false if invisible?
240            if not artist.get_visible():
241                continue
242            # TODO: optimization - exclude artists not inaxes
243            try:
244                inside, prop = artist.contains(event)
245            except:
246                # Probably an old version of matplotlib
247                inside = False
248            if inside:
249                found.artist, found.prop = artist, prop
250                break
251
252        # TODO: how to check if prop is equal?
253        if found != self._current:
254            self.trigger(self._current, 'leave', event)
255            self.trigger(found, 'enter', event)
256        self._current = found
257
258        return found
259
260    def _onMotion(self, event):
261        """
262        Track enter/leave/motion through registered artists; all
263        other artists are invisible.
264        """
265        # # Can't kill double-click on motion since Windows produces
266        # # spurious motion events.
267        # self._last_button = None
268
269        # Dibs on the motion event for the clicked artist
270        if self._hasclick:
271            # Make sure the x,y data use the coordinate system of the
272            # artist rather than the default axes coordinates.
273
274            transform = self._hasclick.artist.get_transform()
275            # x,y = event.xdata,event.ydata
276            x, y = event.x, event.y
277            try:
278                if transform.__class__.__name__ == "IdentityTransform":
279                    x, y = transform.inverted().transform((x, y))
280                else:
281                    # # For interactive plottable apply transform is not working
282                    # # don't know why maybe marker definition
283                    # #transform ="CompositeGenericTransform" crash
284                    pass
285            except:
286                # # CRUFT matplotlib-0.91 support
287                # # exception for transform ="CompositeGenericTransform"
288                # # crashes also here
289                x, y = transform.inverse_xy_tup((x, y))
290
291            # event.xdata, event.ydata = x, y
292            self.trigger(self._hasclick, 'drag', event)
293        else:
294            found = self._find_current(event)
295            self.trigger(found, 'motion', event)
296
297    def _onClick(self, event):
298        """
299        Process button click
300        """
301        import time
302
303        # Check for double-click
304        event_time = time.time()
305        if (event.button != self._last_button) or \
306                (event_time > self._last_time + self.dclick_threshhold):
307            action = 'click'
308        else:
309            action = 'dclick'
310        self._last_button = event.button
311        self._last_time = event_time
312
313        # If an artist is already dragging, feed any additional button
314        # presses to that artist.
315        # TODO: do we want to force a single button model on the user?
316        # TODO: that is, once a button is pressed, no other buttons
317        # TODO: can come through?  I think this belongs in canvas, not here.
318        if self._hasclick:
319            found = self._hasclick
320        else:
321            found = self._find_current(event)
322        # print "button %d pressed"%event.button
323        # Note: it seems like if "click" returns False then hasclick should
324        # not be set.  The problem is that there are two reasons it can
325        # return false: because there is no click action for this artist
326        # or because the click action returned false.  A related problem
327        # is that click actions will go to the canvas if there is no click
328        # action for the artist, even if the artist has a drag. I'll leave
329        # it to future maintainers to sort out this problem.  For now the
330        # recommendation is that users should define click if they have
331        # drag or release on the artist.
332        self.trigger(found, action, event)
333        self._hasclick = found
334
335    def _onDClick(self, event):
336        """
337        Process button double click
338        """
339        # If an artist is already dragging, feed any additional button
340        # presses to that artist.
341        # TODO: do we want to force a single button model on the user?
342        # TODO: that is, once a button is pressed, no other buttons
343        # TODO: can come through?  I think this belongs in canvas, not here.
344        if self._hasclick:
345            found = self._hasclick
346        else:
347            found = self._find_current(event)
348        self.trigger(found, 'dclick', event)
349        self._hasclick = found
350
351    def _onRelease(self, event):
352        """
353        Process release release
354        """
355        self.trigger(self._hasclick, 'release', event)
356        self._hasclick = Selection()
357
358    def _onKey(self, event):
359        """
360        Process key click
361        """
362        # TODO: Do we really want keyboard focus separate from mouse focus?
363        # TODO: Do we need an explicit focus command for keyboard?
364        # TODO: Can we tab between items?
365        # TODO: How do unhandled events get propogated to axes, figure and
366        # TODO: finally to application?  Do we need to implement a full tags
367        # TODO: architecture a la Tk?
368        # TODO: Do modifiers cause a grab?  Does the artist see the modifiers?
369        if event.key in ('alt', 'meta', 'control', 'shift'):
370            setattr(self, event.key, True)
371            return
372
373        if self._haskey:
374            found = self._haskey
375        else:
376            found = self._find_current(event)
377        self.trigger(found, 'key', event)
378        self._haskey = found
379
380    def _onKeyRelease(self, event):
381        """
382        Process key release
383        """
384        if event.key in ('alt', 'meta', 'control', 'shift'):
385            setattr(self, event.key, False)
386            return
387
388        if self._haskey:
389            self.trigger(self._haskey, 'keyup', event)
390        self._haskey = Selection()
391
392    def _onScroll(self, event):
393        """
394        Process scroll event
395        """
396        found = self._find_current(event)
397        self.trigger(found, 'scroll', event)
398
Note: See TracBrowser for help on using the repository browser.