source: sasview/guitools/binder.py @ 04edd0d

ESS_GUIESS_GUI_DocsESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_iss959ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalccostrafo411magnetic_scattrelease-4.1.1release-4.1.2release-4.2.2release_4.0.1ticket-1009ticket-1094-headlessticket-1242-2d-resolutionticket-1243ticket-1249ticket885unittest-saveload
Last change on this file since 04edd0d was 4972de2, checked in by Mathieu Doucet <doucetm@…>, 16 years ago

Updated for interactive graphs.

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