source: sasview/src/sas/sasgui/guiframe/local_perspectives/plotting/binder.py @ 2aba0a4

Last change on this file since 2aba0a4 was 20fa5fe, checked in by Stuart Prescott <stuart@…>, 7 years ago

Fix lots more typos in comments and docs

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