Welcome, guest | Sign In | My Account | Store | Cart

This is a compound widget that gangs multiple Tk Listboxes to a single scrollbar to achieve a simple multi-column scrolled listbox. Most of the Listbox API is mirrored to make it act like the normal Listbox but with multiple values per row.

Python, 96 lines
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
from Tkinter import *

class MultiListbox(Frame):
    def __init__(self, master, lists):
	Frame.__init__(self, master)
	self.lists = []
	for l,w in lists:
	    frame = Frame(self); frame.pack(side=LEFT, expand=YES, fill=BOTH)
	    Label(frame, text=l, borderwidth=1, relief=RAISED).pack(fill=X)
	    lb = Listbox(frame, width=w, borderwidth=0, selectborderwidth=0,
			 relief=FLAT, exportselection=FALSE)
	    lb.pack(expand=YES, fill=BOTH)
	    self.lists.append(lb)
	    lb.bind('<B1-Motion>', lambda e, s=self: s._select(e.y))
	    lb.bind('<Button-1>', lambda e, s=self: s._select(e.y))
	    lb.bind('<Leave>', lambda e: 'break')
	    lb.bind('<B2-Motion>', lambda e, s=self: s._b2motion(e.x, e.y))
	    lb.bind('<Button-2>', lambda e, s=self: s._button2(e.x, e.y))
	frame = Frame(self); frame.pack(side=LEFT, fill=Y)
	Label(frame, borderwidth=1, relief=RAISED).pack(fill=X)
	sb = Scrollbar(frame, orient=VERTICAL, command=self._scroll)
	sb.pack(expand=YES, fill=Y)
	self.lists[0]['yscrollcommand']=sb.set

    def _select(self, y):
	row = self.lists[0].nearest(y)
	self.selection_clear(0, END)
	self.selection_set(row)
	return 'break'

    def _button2(self, x, y):
	for l in self.lists: l.scan_mark(x, y)
	return 'break'

    def _b2motion(self, x, y):
	for l in self.lists: l.scan_dragto(x, y)
	return 'break'

    def _scroll(self, *args):
	for l in self.lists:
	    apply(l.yview, args)

    def curselection(self):
	return self.lists[0].curselection()

    def delete(self, first, last=None):
	for l in self.lists:
	    l.delete(first, last)

    def get(self, first, last=None):
	result = []
	for l in self.lists:
	    result.append(l.get(first,last))
	if last: return apply(map, [None] + result)
	return result
	    
    def index(self, index):
	self.lists[0].index(index)

    def insert(self, index, *elements):
	for e in elements:
	    i = 0
	    for l in self.lists:
		l.insert(index, e[i])
		i = i + 1

    def size(self):
	return self.lists[0].size()

    def see(self, index):
	for l in self.lists:
	    l.see(index)

    def selection_anchor(self, index):
	for l in self.lists:
	    l.selection_anchor(index)

    def selection_clear(self, first, last=None):
	for l in self.lists:
	    l.selection_clear(first, last)

    def selection_includes(self, index):
	return self.lists[0].selection_includes(index)

    def selection_set(self, first, last=None):
	for l in self.lists:
	    l.selection_set(first, last)

if __name__ == '__main__':
    tk = Tk()
    Label(tk, text='MultiListbox').pack()
    mlb = MultiListbox(tk, (('Subject', 40), ('Sender', 20), ('Date', 10)))
    for i in range(1000):
	mlb.insert(END, ('Important Message: %d' % i, 'John Doe', '10/10/%04d' % (1900+i)))
    mlb.pack(expand=YES,fill=BOTH)
    tk.mainloop()

The resulting widget is lightweight, fast, and very easy to use. The main limitation is that only text is supported which is a fundamental limitation of the Listbox.

In this implementation, only single-selection is allowed but it could be extended to multiple selection. User-resizeable columns and auto-sorting by clicking on the column label should also be possible.

Auto-scrolling while dragging Button-1 was disabled because this was breaks the synchronization between the lists. However, scrolling with Button-2 works fine.

15 comments

Dave Richards 19 years, 3 months ago  # | flag

Nice work, but there is a problem with mouse wheels. The scrollbar works as promised, scrolling all listboxes at once, but scrolling with the mouse wheel only scrolls the listbox it is over. Is there a way to use scroll events to link the scrolls or another way to trap mouse wheels?

Martin Miller 18 years, 11 months ago  # | flag

Mouse Wheel works for me. Seems fine (using Win XP w/SP1).

Pedro Werneck 18 years, 8 months ago  # | flag

Sugested solution for mouse wheel problem. On X, the wheel corresponds to the events and (at least on my box), so we need to bind these events to yview. I just added the following lines to the end of the "for l,w in lists" loop:

lb.bind('', lambda e, s=self: s._scroll(SCROLL, -1, UNITS))

lb.bind('', lambda e, s=self: s._scroll(SCROLL, 1, UNITS))

And the following line to the function _scroll:

return 'break',

otherwise, the listbox under the mouse arrow will be scrolled more than the others...

Pedro Werneck 18 years, 8 months ago  # | flag

Ops... Ops... the braces of the event are being stripped as html tags and the pre tag is crazy... the events are 'Button-4' and 'Button-5'...

Eric Rose 17 years, 9 months ago  # | flag

Sort Button. I really like your implementation. I made a few changes to the constructor, let me know what you think.

[changed]

for l,w in lists:

    frame = Frame(self); frame.pack(side=LEFT, expand=YES, fill=BOTH)

    Label(frame, text=l, borderwidth=1, relief=RAISED).pack(fill=X)

[to]

for l,w,a in lists:

    frame = Frame(self); frame.pack(side=LEFT, expand=YES, fill=BOTH)

    Button(frame, text=l, borderwidth=1, relief=RAISED, command=a).pack(fill=X)

So that I could pass a sort command to the multilistbox and call it when I pressed the button.

--Eric Rose

Pedro Alves 15 years, 5 months ago  # | flag

Columns Resize, Independent scroll. Hi. I'm using this widget on my application but since I'm just starting with Python I'm having some difficulties into trying to adapt it in order to respect two constraints I have. a) Columns resize Is it possible to allow the user to resize with the mouse the width of the columns on the multilistbox? My problem is that by setting 2 columns, independent of the value that I set them with, they will have the same size on the GUI and I have very little information on one of the columns and more information on the other. b) Independent scroll I wanted to turn off the possibility to perform scroll on each list independently. Is this possible?

Thank you very much for your time, Pedro Alves

Eric Bogaerts 14 years, 8 months ago  # | flag

this with some improved. Hi,

I add lines from this site to modify the initial script and instead of :

  • working with frame, I worked with PanedWindow
  • using single select i used 'MULTIPLE'

Feel free to use this for your own.

Regards,

Eric Bogaerts 14 years, 8 months ago  # | flag

this with some improved. Hi,

I add lines from this site to modify the initial script and instead of :

  • working with frame, I worked with PanedWindow

  • using single select i used 'MULTIPLE'

Feel free to use this for your own.

This is working, at least, under Linux

Regards,

from Tkinter import *

class MultiListbox(PanedWindow):
    def __init__(self,master,lists):
        PanedWindow.__init__(self,master,borderwidth=1,showhandle=False,sashwidth=2,sashpad=0,relief=SUNKEN)
        self.lists = []
        self.columns=[]
        for l,w in lists:
            self.columns.append(l)
            frame = Frame(self); frame.pack(side=LEFT, expand=YES, fill=BOTH)
            tl=Label(frame, text=l, borderwidth=2, relief=GROOVE)
            tl.pack(fill=X)
            tl.bind('',self.clickon)
            lb = Listbox(frame, width=w, borderwidth=0, selectborderwidth=0,relief=FLAT, exportselection=FALSE, selectmode=MULTIPLE ,bg='white')
            lb.pack(expand=YES, fill=BOTH)
            self.lists.append(lb)
            lb.bind('', lambda e, s=self: s._select(e.y,e.state))
            lb.bind('', lambda e, s=self: s._select(e.y,e.state))
            lb.bind('', lambda e: 'break')
            lb.bind('', lambda e, s=self: s._b2motion(e.x, e.y))
            lb.bind('', lambda e, s=self: s._button2(e.x, e.y))
            lb.bind('', lambda e, s=self: s._scroll(SCROLL, 1, PAGES))
            lb.bind('', lambda e, s=self: s._scroll(SCROLL, -1, PAGES))
            self.add(frame)
        Label(master, borderwidth=1, relief=FLAT).pack(fill=X)
        sb = Scrollbar(master, orient=VERTICAL, command=self._scroll,borderwidth=1)
        sb.pack(fill=Y,side=RIGHT,expand=NO)
        for l in self.lists:
            l['yscrollcommand']=sb.set
        self.add(frame)
        self.pack(expand=YES,fill=BOTH)
        self.sortedBy=-1
        self.previousWheel=0


    def _select(self, y,state=16):
        row = self.lists[0].nearest(y)
        if state==16:self.selection_clear(0, END)
        self.selection_set(row)
##        print self.curselection()
        return 'break'


    def _button2(self, x, y):
        for l in self.lists: l.scan_mark(x, y)
        return 'break'


    def _b2motion(self, x, y):
        for l in self.lists: l.scan_dragto(x, y)
        return 'break'


    def _scroll(self, *args):
        for l in self.lists:
            apply(l.yview, args)
        return 'break'


    def clickon(self,e):
        self._sortBy(self.columns.index(e.widget['text']))


    def _sortBy(self, column):
        """ Sort by a given column. """

(comment continued...)

Eric Bogaerts 14 years, 8 months ago  # | flag

(...continued from previous comment)

        if column == self.sortedBy:
            direction = -1 * self.direction
        else:
            direction = 1

        elements = self.get(0, END)
        self.delete(0, END)
        elements.sort(lambda x, y: self._sortAssist(column, direction, x, y))
        self.insert(END, *elements)

        self.sortedBy = column
        self.direction = direction


    def _sortAssist(self, column, direction, x, y):
        c = cmp(x[column], y[column])
        if c:
            return direction * c
        else:
            return direction * cmp(x, y)

    def curselection(self):
        return self.lists[0].curselection()


    def delete(self, first, last=None):
        for l in self.lists:
            l.delete(first, last)


    def get(self, first, last=None):
        result = []
        for l in self.lists:
            result.append(l.get(first,last))
        if last: return apply(map, [None] + result)
        return result


    def index(self, index):
        self.lists[0].index(index)


    def insert(self, index, *elements):
        for e in elements:
            i = 0
            for l in self.lists:
                l.insert(index, e[i])
                i = i + 1


    def size(self):
        return self.lists[0].size()


    def see(self, index):
        for l in self.lists:
            l.see(index)

    def selection_anchor(self, index):
        for l in self.lists:
            l.selection_anchor(index)


    def selection_clear(self, first, last=None):
        for l in self.lists:
            l.selection_clear(first, last)


    def selection_includes(self, index):
        return self.lists[0].selection_includes(index)


    def selection_set(self, first, last=None):
        for l in self.lists:
            l.selection_set(first, last)
        print self.curselection()


if __name__ == '__main__':
    tk = Tk()
    Label(tk, text='MultiListbox').pack(side=TOP)
    mlb = MultiListbox(tk, (('Subject', 40), ('Sender', 20), ('Date', 10)))
    for i in range(5000):
        mlb.insert(END, ('Important Message: %d' % i, 'John Doe %d' % i, '10/10/%04d' % (1900+i)))
        mlb.pack(expand=YES,fill=BOTH,side=TOP)
    tk.mainloop()
Boris Sitsker 13 years, 9 months ago  # | flag

fix for above code. the modified code is very useful, but the command events were missing (mangled because the &lt; wasnt used) ... tl.bind('<Button-1>',self.clickon)

        lb = Listbox(frame, width=w, borderwidth=0, selectborderwidth=0,relief=FLAT, exportselection=FALSE, selectmode=MULTIPLE ,bg='white')

        lb.pack(expand=YES, fill=BOTH)

        self.lists.append(lb)

        lb.bind('&lt;B1-Motion>', lambda e, s=self: s._select(e.y))

        lb.bind('&lt;Button-1>', lambda e, s=self: s._select(e.y))

        lb.bind('&lt;Leave>', lambda e: 'break')

        lb.bind('&lt;B2-Motion>', lambda e, s=self: s._b2motion(e.x, e.y))

        lb.bind('&lt;Button-2>', lambda e, s=self: s._button2(e.x, e.y))

        lb.bind('&lt;Button-4>', lambda e, s=self: s._scroll(SCROLL, 1, PAGES))

        lb.bind('&lt;Button-5>', lambda e, s=self: s._scroll(SCROLL, -1, PAGES))

...

David O'Gwynn 13 years, 9 months ago  # | flag

Headaches with Windows XP and 'MouseWheel' events. First of, many thanks to Brent, Eric et al. for producing this fine solution to a GUI problem!

So, after fiddling and fiddling, I've finally fixed my problem trying to use this widget in Windows XP.

My problem: My mouse wheel wasn't working at all on XP, even though it worked just fine under MacOSX.

The problem: For whatever reason, XP (at least my XP -- Prof. x64 2003 SP2) does not transfer focus to sub-windows, even if they've been clicked, so mouse wheel events end up getting trapped by the root Tk window and never get transferred to my Listboxes to be processed. Or perhaps Tk on XP requires whatever callback is bound to 'Button 1' to explicitly call focus_force(). I don't know the exact reason, but I do know that it's not a problem on OSX; clicking on a subwindow in OSX implicitly transfers mouse-event-related focus to it.

My solution: 3 statements. [[Caveat: this code is specific to XP/OSX regarding mouse wheel events. XP and OSX use a single '<MouseWheel>' event (which has a delta attribute that specifies the direction of the scroll event) as opposed to *nix/XWindows's separate 'Button-4' and 'Button-5' events. It's also specific to Python2.5 ]]

The first line goes with the individual bindings for the separate Listboxes:

...
lb.bind('&lt;B2-Motion&gt;', lambda e, s=self: s._b2motion(e.x, e.y))
lb.bind('&lt;Button-2&gt;', lambda e, s=self: s._button2(e.x, e.y))
lb.bind(
  '&lt;MouseWheel&gt;',
  lambda e,s=self:s._scroll(SCROLL,
                            -1*(1 if e.delta>0 else -1),
                            UNITS))
...

On XP, this added line is not necessary, as the Listboxes never receive focus. I suppose you could add an 'Enter' callback like the one below for each, but I don't think it's really necessary. I include this line so the code will work on both OSX and XP, since in OSX, clicking the Listbox transfers mouse-event-related focus to it and thus requires that we capture 'MouseWheel' events correctly.

The other two lines go at the end of the __init__ method:

...
self.pack(expand=YES,fill=BOTH)
self.sortedBy=-1
self.previousWheel=0
self.bind(
  '&lt;MouseWheel&gt;',
  lambda e,s=self:s._scroll(SCROLL,
                            -1*(1 if e.delta>0 else -1),
                            UNITS))
self.bind('',lambda e,s=self:s.focus_force())

Those last two statements are what get the job done on XP. The 'Enter' callback forces Tk to give the MultiListbox focus so that it will handle its own mouse wheel events.

David O'Gwynn 13 years, 9 months ago  # | flag

Bah... Blast.. that last line of code should read:

self.bind('&lt;Enter&gt;',lambda e,s=self:s.focus_force())
Chris Wolf 11 years, 10 months ago  # | flag

I totally re-wrote this great idea and built on the work of other persons: DataGrid

Chris Wolf 11 years, 10 months ago  # | flag

Actually, one would be much better off using Michael Lange's implementation, here:

http://klappnase.bubble.org/TkinterTreectrl/index.html

a 11 years, 9 months ago  # | flag

apply() is deprecated. apply(l.yview, args) should be changed to l.yview(*args). apply(map, [None] + result) should be changed to map(None, *result).