1import os
2import string
3import tkinter
4import Pmw
5import collections
6
7class Balloon(Pmw.MegaToplevel):
8    def __init__(self, parent = None, **kw):
9
10        # Define the megawidget options.
11        optiondefs = (
12            ('initwait',                 500,            None), # milliseconds
13            ('label_background',         'lightyellow',  None),
14            ('label_foreground',         'black',        None),
15            ('label_justify',            'left',         None),
16            ('master',                   'parent',       None),
17            ('relmouse',                 'none',         self._relmouse),
18            ('state',                    'both',         self._state),
19            ('statuscommand',            None,           None),
20            ('xoffset',                  20,             None), # pixels
21            ('yoffset',                  1,              None), # pixels
22            ('hull_highlightthickness',  1,              None),
23            ('hull_highlightbackground', 'black',        None),
24        )
25        self.defineoptions(kw, optiondefs)
26
27        # Initialise the base class (after defining the options).
28        Pmw.MegaToplevel.__init__(self, parent)
29
30        self.withdraw()
31        self.overrideredirect(1)
32
33        # Create the components.
34        interior = self.interior()
35        self._label = self.createcomponent('label',
36                (), None,
37                tkinter.Label, (interior,))
38        self._label.pack()
39
40        # The default hull configuration options give a black border
41        # around the balloon, but avoids a black 'flash' when the
42        # balloon is deiconified, before the text appears.
43        if 'hull_background' not in kw:
44            self.configure(hull_background = \
45                    str(self._label.cget('background')))
46
47        # Initialise instance variables.
48        self._timer = None
49
50        # The widget or item that is currently triggering the balloon.
51        # It is None if the balloon is not being displayed.  It is a
52        # one-tuple if the balloon is being displayed in response to a
53        # widget binding (value is the widget).  It is a two-tuple if
54        # the balloon is being displayed in response to a canvas or
55        # text item binding (value is the widget and the item).
56        self._currentTrigger = None
57
58        # Check keywords and initialise options.
59        self.initialiseoptions()
60
61    def destroy(self):
62        if self._timer is not None:
63            self.after_cancel(self._timer)
64            self._timer = None
65        Pmw.MegaToplevel.destroy(self)
66
67    def bind(self, widget, balloonHelp, statusHelp = None):
68
69        # If a previous bind for this widget exists, remove it.
70        self.unbind(widget)
71
72        if balloonHelp is None and statusHelp is None:
73            return
74
75        if statusHelp is None:
76            statusHelp = balloonHelp
77        enterId = widget.bind('<Enter>',
78                lambda event, self = self, w = widget,
79                        sHelp = statusHelp, bHelp = balloonHelp:
80                                self._enter(event, w, sHelp, bHelp, 0))
81
82        # Set Motion binding so that if the pointer remains at rest
83        # within the widget until the status line removes the help and
84        # then the pointer moves again, then redisplay the help in the
85        # status line.
86        # Note:  The Motion binding only works for basic widgets, and
87        # the hull of megawidgets but not for other megawidget components.
88        motionId = widget.bind('<Motion>',
89                lambda event = None, self = self, statusHelp = statusHelp:
90                        self.showstatus(statusHelp))
91
92        leaveId = widget.bind('<Leave>', self._leave)
93        buttonId = widget.bind('<ButtonPress>', self._buttonpress)
94
95        # Set Destroy binding so that the balloon can be withdrawn and
96        # the timer can be cancelled if the widget is destroyed.
97        destroyId = widget.bind('<Destroy>', self._destroy)
98
99        # Use the None item in the widget's private Pmw dictionary to
100        # store the widget's bind callbacks, for later clean up.
101        if not hasattr(widget, '_Pmw_BalloonBindIds'):
102            widget._Pmw_BalloonBindIds = {}
103        widget._Pmw_BalloonBindIds[None] = \
104                (enterId, motionId, leaveId, buttonId, destroyId)
105
106    def unbind(self, widget):
107        if hasattr(widget, '_Pmw_BalloonBindIds'):
108            if None in widget._Pmw_BalloonBindIds:
109                (enterId, motionId, leaveId, buttonId, destroyId) = \
110                        widget._Pmw_BalloonBindIds[None]
111                # Need to pass in old bindings, so that Tkinter can
112                # delete the commands.  Otherwise, memory is leaked.
113                widget.unbind('<Enter>', enterId)
114                widget.unbind('<Motion>', motionId)
115                widget.unbind('<Leave>', leaveId)
116                widget.unbind('<ButtonPress>', buttonId)
117                widget.unbind('<Destroy>', destroyId)
118                del widget._Pmw_BalloonBindIds[None]
119
120        if self._currentTrigger is not None and len(self._currentTrigger) == 1:
121            # The balloon is currently being displayed and the current
122            # trigger is a widget.
123            triggerWidget = self._currentTrigger[0]
124            if triggerWidget == widget:
125                if self._timer is not None:
126                    self.after_cancel(self._timer)
127                    self._timer = None
128                self.withdraw()
129                self.clearstatus()
130                self._currentTrigger = None
131
132    def tagbind(self, widget, tagOrItem, balloonHelp, statusHelp = None):
133
134        # If a previous bind for this widget's tagOrItem exists, remove it.
135        self.tagunbind(widget, tagOrItem)
136
137        if balloonHelp is None and statusHelp is None:
138            return
139
140        if statusHelp is None:
141            statusHelp = balloonHelp
142        enterId = widget.tag_bind(tagOrItem, '<Enter>',
143                lambda event, self = self, w = widget,
144                        sHelp = statusHelp, bHelp = balloonHelp:
145                                self._enter(event, w, sHelp, bHelp, 1))
146        motionId = widget.tag_bind(tagOrItem, '<Motion>',
147                lambda event = None, self = self, statusHelp = statusHelp:
148                        self.showstatus(statusHelp))
149        leaveId = widget.tag_bind(tagOrItem, '<Leave>', self._leave)
150        buttonId = widget.tag_bind(tagOrItem, '<ButtonPress>', self._buttonpress)
151
152        # Use the tagOrItem item in the widget's private Pmw dictionary to
153        # store the tagOrItem's bind callbacks, for later clean up.
154        if not hasattr(widget, '_Pmw_BalloonBindIds'):
155            widget._Pmw_BalloonBindIds = {}
156        widget._Pmw_BalloonBindIds[tagOrItem] = \
157                (enterId, motionId, leaveId, buttonId)
158
159    def tagunbind(self, widget, tagOrItem):
160        if hasattr(widget, '_Pmw_BalloonBindIds'):
161            if tagOrItem in widget._Pmw_BalloonBindIds:
162                (enterId, motionId, leaveId, buttonId) = \
163                        widget._Pmw_BalloonBindIds[tagOrItem]
164                widget.tag_unbind(tagOrItem, '<Enter>', enterId)
165                widget.tag_unbind(tagOrItem, '<Motion>', motionId)
166                widget.tag_unbind(tagOrItem, '<Leave>', leaveId)
167                widget.tag_unbind(tagOrItem, '<ButtonPress>', buttonId)
168                del widget._Pmw_BalloonBindIds[tagOrItem]
169
170        if self._currentTrigger is None:
171            # The balloon is not currently being displayed.
172            return
173
174        if len(self._currentTrigger) == 1:
175            # The current trigger is a widget.
176            return
177
178        if len(self._currentTrigger) == 2:
179            # The current trigger is a canvas item.
180            (triggerWidget, triggerItem) = self._currentTrigger
181            if triggerWidget == widget and triggerItem == tagOrItem:
182                if self._timer is not None:
183                    self.after_cancel(self._timer)
184                    self._timer = None
185                self.withdraw()
186                self.clearstatus()
187                self._currentTrigger = None
188        else: # The current trigger is a text item.
189            (triggerWidget, x, y) = self._currentTrigger
190            if triggerWidget == widget:
191                currentPos = widget.index('@%d,%d' % (x, y))
192                currentTags = widget.tag_names(currentPos)
193                if tagOrItem in currentTags:
194                    if self._timer is not None:
195                        self.after_cancel(self._timer)
196                        self._timer = None
197                    self.withdraw()
198                    self.clearstatus()
199                    self._currentTrigger = None
200
201    def showstatus(self, statusHelp):
202        if self['state'] in ('status', 'both'):
203            cmd = self['statuscommand']
204            if isinstance(cmd, collections.Callable):
205                cmd(statusHelp)
206
207    def clearstatus(self):
208        self.showstatus(None)
209
210    def _state(self):
211        if self['state'] not in ('both', 'balloon', 'status', 'none'):
212            raise ValueError('bad state option ' + repr(self['state']) + \
213                ': should be one of \'both\', \'balloon\', ' + \
214                '\'status\' or \'none\'')
215
216    def _relmouse(self):
217        if self['relmouse'] not in ('both', 'x', 'y', 'none'):
218            raise ValueError('bad relmouse option ' + repr(self['relmouse'])+ \
219                ': should be one of \'both\', \'x\', ' + '\'y\' or \'none\'')
220
221    def _enter(self, event, widget, statusHelp, balloonHelp, isItem):
222
223        # Do not display balloon if mouse button is pressed.  This
224        # will only occur if the button was pressed inside a widget,
225        # then the mouse moved out of and then back into the widget,
226        # with the button still held down.  The number 0x1f00 is the
227        # button mask for the 5 possible buttons in X.
228        buttonPressed = (event.state & 0x1f00) != 0
229
230        if not buttonPressed and balloonHelp is not None and \
231                self['state'] in ('balloon', 'both'):
232            if self._timer is not None:
233                self.after_cancel(self._timer)
234                self._timer = None
235
236            self._timer = self.after(self['initwait'],
237                    lambda self = self, widget = widget, help = balloonHelp,
238                            isItem = isItem:
239                            self._showBalloon(widget, help, isItem))
240
241        if isItem:
242            if hasattr(widget, 'canvasx'):
243                # The widget is a canvas.
244                item = widget.find_withtag('current')
245                if len(item) > 0:
246                    item = item[0]
247                else:
248                    item = None
249                self._currentTrigger = (widget, item)
250            else:
251                # The widget is a text widget.
252                self._currentTrigger = (widget, event.x, event.y)
253        else:
254            self._currentTrigger = (widget,)
255
256        self.showstatus(statusHelp)
257
258    def _leave(self, event):
259        if self._timer is not None:
260            self.after_cancel(self._timer)
261            self._timer = None
262        self.withdraw()
263        self.clearstatus()
264        self._currentTrigger = None
265
266    def _destroy(self, event):
267
268        # Only withdraw the balloon and cancel the timer if the widget
269        # being destroyed is the widget that triggered the balloon.
270        # Note that in a Tkinter Destroy event, the widget field is a
271        # string and not a widget as usual.
272
273        if self._currentTrigger is None:
274            # The balloon is not currently being displayed
275            return
276
277        if len(self._currentTrigger) == 1:
278            # The current trigger is a widget (not an item)
279            triggerWidget = self._currentTrigger[0]
280            if str(triggerWidget) == event.widget:
281                if self._timer is not None:
282                    self.after_cancel(self._timer)
283                    self._timer = None
284                self.withdraw()
285                self.clearstatus()
286                self._currentTrigger = None
287
288    def _buttonpress(self, event):
289        if self._timer is not None:
290            self.after_cancel(self._timer)
291            self._timer = None
292        self.withdraw()
293        self._currentTrigger = None
294
295    def _showBalloon(self, widget, balloonHelp, isItem):
296
297        self._label.configure(text = balloonHelp)
298
299        # First, display the balloon offscreen to get dimensions.
300        screenWidth = self.winfo_screenwidth()
301        screenHeight = self.winfo_screenheight()
302        self.geometry('+%d+0' % (screenWidth + 1))
303        self.update_idletasks()
304
305        if isItem:
306            # Get the bounding box of the current item.
307            bbox = widget.bbox('current')
308            if bbox is None:
309                # The item that triggered the balloon has disappeared,
310                # perhaps by a user's timer event that occured between
311                # the <Enter> event and the 'initwait' timer calling
312                # this method.
313                return
314
315            # The widget is either a text or canvas.  The meaning of
316            # the values returned by the bbox method is different for
317            # each, so use the existence of the 'canvasx' method to
318            # distinguish between them.
319            if hasattr(widget, 'canvasx'):
320                # The widget is a canvas.  Place balloon under canvas
321                # item.  The positions returned by bbox are relative
322                # to the entire canvas, not just the visible part, so
323                # need to convert to window coordinates.
324                leftrel = bbox[0] - widget.canvasx(0)
325                toprel = bbox[1] - widget.canvasy(0)
326                bottomrel = bbox[3] - widget.canvasy(0)
327            else:
328                # The widget is a text widget.  Place balloon under
329                # the character closest to the mouse.  The positions
330                # returned by bbox are relative to the text widget
331                # window (ie the visible part of the text only).
332                leftrel = bbox[0]
333                toprel = bbox[1]
334                bottomrel = bbox[1] + bbox[3]
335        else:
336            leftrel = 0
337            toprel = 0
338            bottomrel = widget.winfo_height()
339
340        xpointer, ypointer = widget.winfo_pointerxy()   # -1 if off screen
341
342        if xpointer >= 0 and self['relmouse'] in ('both', 'x'):
343            x = xpointer
344        else:
345            x = leftrel + widget.winfo_rootx()
346        x = x + self['xoffset']
347
348        if ypointer >= 0 and self['relmouse'] in ('both', 'y'):
349            y = ypointer
350        else:
351            y = bottomrel + widget.winfo_rooty()
352        y = y + self['yoffset']
353        #Python 3 conversion
354        #edges = (string.atoi(str(self.cget('hull_highlightthickness'))) +
355        #    string.atoi(str(self.cget('hull_borderwidth')))) * 2
356        edges = (int(str(self.cget('hull_highlightthickness'))) +
357            int(str(self.cget('hull_borderwidth')))) * 2
358        if x + self._label.winfo_reqwidth() + edges > screenWidth:
359            x = screenWidth - self._label.winfo_reqwidth() - edges
360
361        if y + self._label.winfo_reqheight() + edges > screenHeight:
362            if ypointer >= 0 and self['relmouse'] in ('both', 'y'):
363                y = ypointer
364            else:
365                y = toprel + widget.winfo_rooty()
366            y = y - self._label.winfo_reqheight() - self['yoffset'] - edges
367
368        Pmw.setgeometryanddeiconify(self, '+%d+%d' % (x, y))
369