1# Based on iwidgets2.2.0/scrolledlistbox.itk code.
2
3import types
4import tkinter
5import Pmw
6import collections
7
8class ScrolledListBox(Pmw.MegaWidget):
9    _classBindingsDefinedFor = 0
10
11    def __init__(self, parent = None, **kw):
12
13        # Define the megawidget options.
14        INITOPT = Pmw.INITOPT
15        optiondefs = (
16            ('dblclickcommand',    None,            None),
17            ('hscrollmode',        'dynamic',       self._hscrollMode),
18            ('items',              (),              INITOPT),
19            ('labelmargin',        0,               INITOPT),
20            ('labelpos',           None,            INITOPT),
21            ('scrollmargin',       2,               INITOPT),
22            ('selectioncommand',   None,            None),
23            ('usehullsize',        0,               INITOPT),
24            ('vscrollmode',        'dynamic',       self._vscrollMode),
25        )
26        self.defineoptions(kw, optiondefs)
27
28        # Initialise the base class (after defining the options).
29        Pmw.MegaWidget.__init__(self, parent)
30
31        # Create the components.
32        interior = self.interior()
33
34        if self['usehullsize']:
35            interior.grid_propagate(0)
36
37        # Create the listbox widget.
38        self._listbox = self.createcomponent('listbox',
39                (), None,
40                tkinter.Listbox, (interior,))
41        self._listbox.grid(row = 2, column = 2, sticky = 'news')
42        interior.grid_rowconfigure(2, weight = 1, minsize = 0)
43        interior.grid_columnconfigure(2, weight = 1, minsize = 0)
44
45        # Create the horizontal scrollbar
46        self._horizScrollbar = self.createcomponent('horizscrollbar',
47                (), 'Scrollbar',
48                tkinter.Scrollbar, (interior,),
49                orient='horizontal',
50                command=self._listbox.xview
51        )
52
53        # Create the vertical scrollbar
54        self._vertScrollbar = self.createcomponent('vertscrollbar',
55                (), 'Scrollbar',
56                tkinter.Scrollbar, (interior,),
57                orient='vertical',
58                command=self._listbox.yview
59        )
60
61        self.createlabel(interior, childCols = 3, childRows = 3)
62
63        # Add the items specified by the initialisation option.
64        items = self['items']
65        if type(items) != tuple:
66            items = tuple(items)
67        if len(items) > 0:
68            self._listbox.insert(*('end',) + items)
69
70        _registerScrolledList(self._listbox, self)
71
72        # Establish the special class bindings if not already done.
73        # Also create bindings if the Tkinter default interpreter has
74        # changed.  Use Tkinter._default_root to create class
75        # bindings, so that a reference to root is created by
76        # bind_class rather than a reference to self, which would
77        # prevent object cleanup.
78        theTag = 'ScrolledListBoxTag'
79        if ScrolledListBox._classBindingsDefinedFor != tkinter._default_root:
80            root  = tkinter._default_root
81
82            def doubleEvent(event):
83                _handleEvent(event, 'double')
84            def keyEvent(event):
85                _handleEvent(event, 'key')
86            def releaseEvent(event):
87                _handleEvent(event, 'release')
88
89            # Bind space and return keys and button 1 to the selectioncommand.
90            root.bind_class(theTag, '<Key-space>', keyEvent)
91            root.bind_class(theTag, '<Key-Return>', keyEvent)
92            root.bind_class(theTag, '<ButtonRelease-1>', releaseEvent)
93
94            # Bind double button 1 click to the dblclickcommand.
95            root.bind_class(theTag, '<Double-ButtonRelease-1>', doubleEvent)
96
97            ScrolledListBox._classBindingsDefinedFor = root
98
99        bindtags = self._listbox.bindtags()
100        self._listbox.bindtags(bindtags + (theTag,))
101
102        # Initialise instance variables.
103        self._horizScrollbarOn = 0
104        self._vertScrollbarOn = 0
105        self.scrollTimer = None
106        self._scrollRecurse = 0
107        self._horizScrollbarNeeded = 0
108        self._vertScrollbarNeeded = 0
109
110        # Check keywords and initialise options.
111        self.initialiseoptions()
112
113    def destroy(self):
114        if self.scrollTimer is not None:
115            self.after_cancel(self.scrollTimer)
116            self.scrollTimer = None
117        _deregisterScrolledList(self._listbox)
118        Pmw.MegaWidget.destroy(self)
119
120    # ======================================================================
121
122    # Public methods.
123
124    def clear(self):
125        self.setlist(())
126
127    def getcurselection(self):
128        rtn = []
129        for sel in self.curselection():
130            rtn.append(self._listbox.get(sel))
131        return tuple(rtn)
132
133    def getvalue(self):
134        return self.getcurselection()
135
136    def setvalue(self, textOrList):
137        self._listbox.selection_clear(0, 'end')
138        listitems = list(self._listbox.get(0, 'end'))
139        if type(textOrList) is str:
140            if textOrList in listitems:
141                self._listbox.selection_set(listitems.index(textOrList))
142            else:
143                raise ValueError('no such item "%s"' % textOrList)
144        else:
145            for item in textOrList:
146                if item in listitems:
147                    self._listbox.selection_set(listitems.index(item))
148                else:
149                    raise ValueError('no such item "%s"' % item)
150
151    def setlist(self, items):
152        self._listbox.delete(0, 'end')
153        if len(items) > 0:
154            if type(items) != tuple:
155                items = tuple(items)
156            self._listbox.insert(*(0,) + items)
157
158    # Override Tkinter.Listbox get method, so that if it is called with
159    # no arguments, return all list elements (consistent with other widgets).
160    def get(self, first=None, last=None):
161        if first is None:
162            return self._listbox.get(0, 'end')
163        else:
164            return self._listbox.get(first, last)
165
166    # ======================================================================
167
168    # Configuration methods.
169
170    def _hscrollMode(self):
171        # The horizontal scroll mode has been configured.
172
173        mode = self['hscrollmode']
174
175        if mode == 'static':
176            if not self._horizScrollbarOn:
177                self._toggleHorizScrollbar()
178        elif mode == 'dynamic':
179            if self._horizScrollbarNeeded != self._horizScrollbarOn:
180                self._toggleHorizScrollbar()
181        elif mode == 'none':
182            if self._horizScrollbarOn:
183                self._toggleHorizScrollbar()
184        else:
185            message = 'bad hscrollmode option "%s": should be static, dynamic, or none' % mode
186            raise ValueError(message)
187
188        self._configureScrollCommands()
189
190    def _vscrollMode(self):
191        # The vertical scroll mode has been configured.
192
193        mode = self['vscrollmode']
194
195        if mode == 'static':
196            if not self._vertScrollbarOn:
197                self._toggleVertScrollbar()
198        elif mode == 'dynamic':
199            if self._vertScrollbarNeeded != self._vertScrollbarOn:
200                self._toggleVertScrollbar()
201        elif mode == 'none':
202            if self._vertScrollbarOn:
203                self._toggleVertScrollbar()
204        else:
205            message = 'bad vscrollmode option "%s": should be static, dynamic, or none' % mode
206            raise ValueError(message)
207
208        self._configureScrollCommands()
209
210    # ======================================================================
211
212    # Private methods.
213
214    def _configureScrollCommands(self):
215        # If both scrollmodes are not dynamic we can save a lot of
216        # time by not having to create an idle job to handle the
217        # scroll commands.
218
219        # Clean up previous scroll commands to prevent memory leak.
220        tclCommandName = str(self._listbox.cget('xscrollcommand'))
221        if tclCommandName != '':
222            self._listbox.deletecommand(tclCommandName)
223        tclCommandName = str(self._listbox.cget('yscrollcommand'))
224        if tclCommandName != '':
225            self._listbox.deletecommand(tclCommandName)
226
227        if self['hscrollmode'] == self['vscrollmode'] == 'dynamic':
228            self._listbox.configure(
229                    xscrollcommand=self._scrollBothLater,
230                    yscrollcommand=self._scrollBothLater
231            )
232        else:
233            self._listbox.configure(
234                    xscrollcommand=self._scrollXNow,
235                    yscrollcommand=self._scrollYNow
236            )
237
238    def _scrollXNow(self, first, last):
239        self._horizScrollbar.set(first, last)
240        self._horizScrollbarNeeded = ((first, last) != ('0', '1'))
241
242        if self['hscrollmode'] == 'dynamic':
243            if self._horizScrollbarNeeded != self._horizScrollbarOn:
244                self._toggleHorizScrollbar()
245
246    def _scrollYNow(self, first, last):
247        self._vertScrollbar.set(first, last)
248        self._vertScrollbarNeeded = ((first, last) != ('0', '1'))
249
250        if self['vscrollmode'] == 'dynamic':
251            if self._vertScrollbarNeeded != self._vertScrollbarOn:
252                self._toggleVertScrollbar()
253
254    def _scrollBothLater(self, first, last):
255        # Called by the listbox to set the horizontal or vertical
256        # scrollbar when it has scrolled or changed size or contents.
257
258        if self.scrollTimer is None:
259            self.scrollTimer = self.after_idle(self._scrollBothNow)
260
261    def _scrollBothNow(self):
262        # This performs the function of _scrollXNow and _scrollYNow.
263        # If one is changed, the other should be updated to match.
264        self.scrollTimer = None
265
266        # Call update_idletasks to make sure that the containing frame
267        # has been resized before we attempt to set the scrollbars.
268        # Otherwise the scrollbars may be mapped/unmapped continuously.
269        self._scrollRecurse = self._scrollRecurse + 1
270        self.update_idletasks()
271        self._scrollRecurse = self._scrollRecurse - 1
272        if self._scrollRecurse != 0:
273            return
274
275        xview = self._listbox.xview()
276        yview = self._listbox.yview()
277        self._horizScrollbar.set(xview[0], xview[1])
278        self._vertScrollbar.set(yview[0], yview[1])
279
280        self._horizScrollbarNeeded = (xview != (0.0, 1.0))
281        self._vertScrollbarNeeded = (yview != (0.0, 1.0))
282
283        # If both horizontal and vertical scrollmodes are dynamic and
284        # currently only one scrollbar is mapped and both should be
285        # toggled, then unmap the mapped scrollbar.  This prevents a
286        # continuous mapping and unmapping of the scrollbars.
287        if (self['hscrollmode'] == self['vscrollmode'] == 'dynamic' and
288                self._horizScrollbarNeeded != self._horizScrollbarOn and
289                self._vertScrollbarNeeded != self._vertScrollbarOn and
290                self._vertScrollbarOn != self._horizScrollbarOn):
291            if self._horizScrollbarOn:
292                self._toggleHorizScrollbar()
293            else:
294                self._toggleVertScrollbar()
295            return
296
297        if self['hscrollmode'] == 'dynamic':
298            if self._horizScrollbarNeeded != self._horizScrollbarOn:
299                self._toggleHorizScrollbar()
300
301        if self['vscrollmode'] == 'dynamic':
302            if self._vertScrollbarNeeded != self._vertScrollbarOn:
303                self._toggleVertScrollbar()
304
305    def _toggleHorizScrollbar(self):
306
307        self._horizScrollbarOn = not self._horizScrollbarOn
308
309        interior = self.interior()
310        if self._horizScrollbarOn:
311            self._horizScrollbar.grid(row = 4, column = 2, sticky = 'news')
312            interior.grid_rowconfigure(3, minsize = self['scrollmargin'])
313        else:
314            self._horizScrollbar.grid_forget()
315            interior.grid_rowconfigure(3, minsize = 0)
316
317    def _toggleVertScrollbar(self):
318
319        self._vertScrollbarOn = not self._vertScrollbarOn
320
321        interior = self.interior()
322        if self._vertScrollbarOn:
323            self._vertScrollbar.grid(row = 2, column = 4, sticky = 'news')
324            interior.grid_columnconfigure(3, minsize = self['scrollmargin'])
325        else:
326            self._vertScrollbar.grid_forget()
327            interior.grid_columnconfigure(3, minsize = 0)
328
329    def _handleEvent(self, event, eventType):
330        if eventType == 'double':
331            command = self['dblclickcommand']
332        elif eventType == 'key':
333            command = self['selectioncommand']
334        else: #eventType == 'release'
335            # Do not execute the command if the mouse was released
336            # outside the listbox.
337            if (event.x < 0 or self._listbox.winfo_width() <= event.x or
338                    event.y < 0 or self._listbox.winfo_height() <= event.y):
339                return
340
341            command = self['selectioncommand']
342
343        if isinstance(command, collections.Callable):
344            command()
345
346    # Need to explicitly forward this to override the stupid
347    # (grid_)size method inherited from Tkinter.Frame.Grid.
348    def size(self):
349        return self._listbox.size()
350
351    # Need to explicitly forward this to override the stupid
352    # (grid_)bbox method inherited from Tkinter.Frame.Grid.
353    def bbox(self, index):
354        return self._listbox.bbox(index)
355
356Pmw.forwardmethods(ScrolledListBox, tkinter.Listbox, '_listbox')
357
358# ======================================================================
359
360_listboxCache = {}
361
362def _registerScrolledList(listbox, scrolledList):
363    # Register an ScrolledList widget for a Listbox widget
364
365    _listboxCache[listbox] = scrolledList
366
367def _deregisterScrolledList(listbox):
368    # Deregister a Listbox widget
369    del _listboxCache[listbox]
370
371def _handleEvent(event, eventType):
372    # Forward events for a Listbox to it's ScrolledListBox
373
374    # A binding earlier in the bindtags list may have destroyed the
375    # megawidget, so need to check.
376    if event.widget in _listboxCache:
377        _listboxCache[event.widget]._handleEvent(event, eventType)
378