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

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

improved support for py37 in sasgui

  • Property mode set to 100644
File size: 15.2 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                if transform.__class__.__name__ == "IdentityTransform":
290                    x, y = transform.inverted().transform((x, y))
291                else:
292                    # # For interactive plottable apply transform is not working
293                    # # don't know why maybe marker definition
294                    # #transform ="CompositeGenericTransform" crash
295                    pass
296            except: # CRUFT: matplotlib-0.91 support
297                # # exception for transform ="CompositeGenericTransform"
298                # # crashes also here
299                x, y = transform.inverse_xy_tup((x, y))
300
301            #TODO: why compute (x,y) if it isn't being used?
302            #event.xdata, event.ydata = x, y
303            self.trigger(self._hasclick, 'drag', event)
304        else:
305            found = self._find_current(event)
306            self.trigger(found, 'motion', event)
307
308    def _onClick(self, event):
309        """
310        Process button click
311        """
312        import time
313
314        # Check for double-click
315        event_time = time.time()
316        if (event.button != self._last_button) or \
317                (event_time > self._last_time + self.dclick_threshhold):
318            action = 'click'
319        else:
320            action = 'dclick'
321        self._last_button = event.button
322        self._last_time = event_time
323
324        # If an artist is already dragging, feed any additional button
325        # presses to that artist.
326        # TODO: do we want to force a single button model on the user?
327        # TODO: that is, once a button is pressed, no other buttons
328        # TODO: can come through?  I think this belongs in canvas, not here.
329        if self._hasclick:
330            found = self._hasclick
331        else:
332            found = self._find_current(event)
333        # print "button %d pressed"%event.button
334        # Note: it seems like if "click" returns False then hasclick should
335        # not be set.  The problem is that there are two reasons it can
336        # return false: because there is no click action for this artist
337        # or because the click action returned false.  A related problem
338        # is that click actions will go to the canvas if there is no click
339        # action for the artist, even if the artist has a drag. I'll leave
340        # it to future maintainers to sort out this problem.  For now the
341        # recommendation is that users should define click if they have
342        # drag or release on the artist.
343        self.trigger(found, action, event)
344        self._hasclick = found
345
346    def _onDClick(self, event):
347        """
348        Process button double click
349        """
350        # If an artist is already dragging, feed any additional button
351        # presses to that artist.
352        # TODO: do we want to force a single button model on the user?
353        # TODO: that is, once a button is pressed, no other buttons
354        # TODO: can come through?  I think this belongs in canvas, not here.
355        if self._hasclick:
356            found = self._hasclick
357        else:
358            found = self._find_current(event)
359        self.trigger(found, 'dclick', event)
360        self._hasclick = found
361
362    def _onRelease(self, event):
363        """
364        Process release release
365        """
366        self.trigger(self._hasclick, 'release', event)
367        self._hasclick = Selection()
368
369    def _onKey(self, event):
370        """
371        Process key click
372        """
373        # TODO: Do we really want keyboard focus separate from mouse focus?
374        # TODO: Do we need an explicit focus command for keyboard?
375        # TODO: Can we tab between items?
376        # TODO: How do unhandled events get propagated to axes, figure and
377        # TODO: finally to application?  Do we need to implement a full tags
378        # TODO: architecture a la Tk?
379        # TODO: Do modifiers cause a grab?  Does the artist see the modifiers?
380        if event.key in ('alt', 'meta', 'control', 'shift'):
381            setattr(self, event.key, True)
382            return
383
384        if self._haskey:
385            found = self._haskey
386        else:
387            found = self._find_current(event)
388        self.trigger(found, 'key', event)
389        self._haskey = found
390
391    def _onKeyRelease(self, event):
392        """
393        Process key release
394        """
395        if event.key in ('alt', 'meta', 'control', 'shift'):
396            setattr(self, event.key, False)
397            return
398
399        if self._haskey:
400            self.trigger(self._haskey, 'keyup', event)
401        self._haskey = Selection()
402
403    def _onScroll(self, event):
404        """
405        Process scroll event
406        """
407        found = self._find_current(event)
408        self.trigger(found, 'scroll', event)
Note: See TracBrowser for help on using the repository browser.