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