"""
ScrolledTable - display scrollable table with rows sortable by column.
Rows are searchable by both anchored match and general regular expression.
"""
__version__ = "1.15"
__author__ = "Piers Lauder <piers@cs.su.oz.au>"
import re, string, Tkinter, tkMessageBox
try:
string.ascii_letters
except AttributeError:
string.ascii_letters = string.letters
try:
import globals
except ImportError:
class _X1: pass
globals = _X1()
globals.NameFont = '*-helvetica-medium-r-normal--*-100-*'
globals.BoldNameFont = '*-helvetica-bold-r-normal--*-100-*'
globals.ActiveListBGColour = 'lightyellow'
try:
import ctype
except ImportError:
class _X2:
printable = string.ascii_letters+string.digits+string.whitespace+\
'''!"#$%&'()*+,-./:;<=>?[\]^_`{|}~'''
def isprint(self, c):
return c in self.printable
ctype = _X2()
try:
from logger import Debug
except ImportError:
def Debug(l,s): pass
KILL = chr(21)
BUTTON = 'button'
DBLBUTTON = 'doublebutton'
FIND = 'find'
REGEX = 'regex'
RETURN = 'return'
class ScrolledTable:
""" Instantiate as: ScrolledTable(master, coldescs, font=None)
`coldescs' is a list of column descriptions defined by dictionaries
with the following elements:
'title' column title
'datacol' optional: column of input data rows [list position]
'position' optional: position of column [list position]
'weight' optional: value for grid cell growth rate [1]
'sort' optional: sort function: sort(rows, column) [None]
'rjust' optional: boolean meaning right justify row [None]
'width' optional: initial width for column [0]
Note: the default sort is a case-insensitive sort of `column' from `rows'
returning a new list with the sorted column prepended to each row,
as in: [(row[col], row), ...].
Any provided sort must behave similarly.
`font' is for the columns. NB all the columns must have the same font
so that the rows align. [Default: globals.NameFont.]
Public methods:
build Build widgets (call once)
configure Configure widgets (call many times)
fill Fill table (call many times)
select_row Select row matching given column data
select_rows Select rows given by indices
selected_rows Return indices of selected rows
selected_values Return dictionary representing selected row
Public variables:
lbframe Frame containing listboxes
columns Dictionary of listbox widgets indexed by 'title'
selected index in columns of selected line
"""
def __init__(self, master, coldescs, font=None):
self.master = master
self.coldescs = coldescs
self.font = font or globals.NameFont
self.listboxes = []
self.columns = {}
self.sorted_lb = None
self.rows = []
self.sorted_rows = []
self.sorted_column = 0
self.sorted_column_reversed = None
self.selected = None
self.upper, self.lower = None, None
self.last_pat = (None,)*3
self.sel_func = None
self.allow_mult_sel = None
self.mult_sel = None
self.mesg_func = None
def build(self):
""" build()
Build widgets.
Call just once per instance.
"""
frame = Tkinter.Frame(self.master)
frame.pack(side='top', expand=1, fill='both')
frame.rowconfigure(1, weight=1)
self.lbframe = frame
font = self.font
self.listboxes = [None]*len(self.coldescs)
self.buttons = [None]*len(self.coldescs)
sort = self.sort
yscroll = self.yscroll
datacols = [None]*len(self.coldescs)
col = 0
for coldesc in self.coldescs:
pos = coldesc.get('position', col)
datacol = coldesc.get('datacol', col)
col = col + 1
datacols[pos] = 'row[%s]' % datacol
coldesc['datacol'] = datacol
frame.columnconfigure(pos, weight=coldesc.get('weight', 1))
title = coldesc['title']
bt = Tkinter.Button(frame, text=title,
font=globals.BoldNameFont, padx=0, pady=0)
bt.grid(row=0, column=pos, sticky='we')
width = coldesc.get('width', 0)
lb = Tkinter.Listbox(frame, width=width, selectmode='single',
exportselection=0, font=font)
lb.grid(row=1, column=pos, sticky='wens')
bb = Tkinter.Button(frame, text=title,
font=globals.BoldNameFont, padx=0, pady=0)
bb.grid(row=2, column=pos, sticky='we')
self.listboxes[pos] = (lb, coldesc, pos)
self.buttons[pos] = (bb, bt)
self.columns[title] = lb
lb['yscroll'] = yscroll
lb.bind('<Page_Up>', self.lb_scrollpage)
lb.bind('<Page_Down>', self.lb_scrollpage)
lb.bind('<Button-1>', self.selectlineb)
lb.bind('<Shift-Button-1>', self.selectlinesb)
lb.bind('<Double-Button-1>', self.selectlinebb)
lb.bind('<Return>', self.publish_selected)
lb.bind('<Enter>', self.lb_enter)
lb.bind('<Any-Key>', self.lb_key)
csort = coldesc.get('sort')
bt['command'] = lambda c=csort,d=0,n=pos,s=sort: s(n,d,c)
bb['command'] = lambda c=csort,d=1,n=pos,s=sort: s(n,d,c)
if not self.listboxes:
raise ValueError('no columns specified')
self.sorted_lb = self.listboxes[0][0]
self.listbox_default_background = self.sorted_lb['background']
self.table_sb = Tkinter.Scrollbar(frame)
self.table_sb_col = col
self.table_sb['command'] = self.yview
exec "def displayed_order(row): return [%s]" % string.join(datacols, ', ')
self.displayed_order = displayed_order
searchframe = Tkinter.Frame(self.master)
self.searchframe = searchframe
for name,column in (('regex', 0), ('find', 2)):
label = string.capitalize(name)
l = Tkinter.Label(searchframe, text=label)
l.grid(row=0, column=column)
setattr(self, '%s_l' % name, l)
e = Tkinter.Entry(searchframe)
e.grid(row=0, column=column+1, sticky='we')
setattr(self, '%s_e' % name, e)
searchframe.columnconfigure(column+1, weight=1)
self.regex_e.bind('<Return>', self.regex)
self.searchval = Tkinter.StringVar()
self.find_e.configure(textvariable=self.searchval)
self.find_e.bind('<Return>', self.find_enter)
self.find_e.bind('<Any-Key>', self.find_key)
def configure(self, sel_func=None, allow_mult_sel=None, mesg_func=None,
scroll=1, search=1, **lb_args):
""" configure(sel_func=None, mesg_func=None, **lb_args)
Configure widget parameters.
Call more than once if necessary.
`sel_func' is called with two arguments:
the first is the type of the selection, one of
BUTTON, DBLBUTTON, FIND, REGEX, RETURN
the second is the contents of a selected row.
The contents are contained in a dictionary
whose keys are the column titles (or list of
dictionaries if multiple rows selected).
NB: contents are a *list* of dictionaries
if multiple rows were selected.
`allow_mult_sel' if true allows multi-line selects
(using Shift-Button-1 to extend selection).
`mesg_func' is called to show warning messages. Otherwise
a `tkMessageBox' warning is displayed.
`scroll' specifies if the scrollbar is displayed.
`search' specifies if regex and find boxes are displayed.
`lb_args' are optional named arguments that will be passed
to the `configure' method for each listbox.
"""
if sel_func is not None:
self.sel_func = sel_func
if allow_mult_sel is not None:
self.allow_mult_sel = 1
if mesg_func is not None:
self.mesg_func = mesg_func
if lb_args:
for lb,coldesc,pos in self.listboxes:
apply(lb.configure, (), lb_args)
if scroll:
self.table_sb.grid(row=1, column=self.table_sb_col, sticky='wns')
else:
self.table_sb.grid_forget()
if search:
self.searchframe.pack(side='top', fill='x')
self.regex_e.delete('0', 'end')
self.searchval.set('')
else:
self.searchframe.pack_forget()
def fill(self, rows, col=None, rev=None):
""" fill(rows, col=None, rev=None)
(Re)fill table with rows.
`rows' must be a list of lists containing one element per column.
`col' selects the initial sort column from original rows
(default is previously selected column).
`rev' reverses the initial sort if true
(default is None unless col is None, in which case it is
the previous direction.)
"""
Debug(3, '''"(rows, %s, %s) <= %s..." % (col, rev, `rows[0:1]`)''')
self.selected = None
l = len(rows)
listrows = [None]*l
for i in range(l):
listrows[i] = self.displayed_order(rows[i])
Debug(3, '''"listrows[0] = %s..." % `listrows[0:1]`''')
if col is None:
col = self.listboxes[self.sorted_column][1]['datacol']
if rev is None:
rev = self.sorted_column_reversed
findcol = 1
for lb,coldesc,pos in self.listboxes:
if findcol is not None and col == coldesc['datacol']:
col = pos
findcol = None
csort = coldesc.get('sort')
if coldesc.get('rjust'):
width = 0
for row in listrows:
width = max(len(str(row[pos])), width)
else:
width = 0
for row in listrows:
row[pos] = '%*s' % (width, str(row[pos]))
if findcol:
raise ValueError('"col" must be in range 0<=col<%s'
% len(self.coldescs))
self.rows = listrows
list2orig = {}; orig2list = {}
for l,o in map(None, listrows, rows):
list2orig[tuple(l)] = o
orig2list[tuple(o)] = l
self.list2orig = list2orig
self.orig2list = orig2list
self.sort(col, rev, csort)
def select_row(self, row):
""" Select displayed row containing `row' (a list of column values). """
Debug(2, '''"select_row(%s)" % `row`''')
self.select_find_row(self.orig2list[tuple(row)])
def select_rows(self, indices):
""" Select rows given by `indices' (a range of two row indices). """
self.mult_sel, self.selected = indices
if self.selected is None:
return
if self.mult_sel is None:
first = self.selected
else:
first = self.mult_sel
for lb,col,pos in self.listboxes:
lb.selection_clear(0, 'end')
lb.selection_set(first, self.selected)
lb.see(self.selected)
def selected_rows(self):
""" Return indices of selected rows (a range of two row indices). """
return self.mult_sel, self.selected
def selected_values(self):
""" Return dictionary of selected row
*using original row values*.
"""
row = self.selected_row()
if row is None:
return None
try:
row = self.list2orig[tuple(row)]
except KeyError, val:
if self.rows:
self.message("Can't find original row!")
return None
values = {}
for lb,coldesc,pos in self.listboxes:
values[coldesc['title']] = row[coldesc['datacol']]
return values
def find_enter(self, event):
self.searchval.set('')
return 'break'
def find_key(self, event):
self.lb_key(event)
self.find_e.icursor('end')
return 'break'
def lb_enter(self, event):
self.sorted_lb.focus_set()
self.searchval.set('')
def lb_key(self, event, lower=string.lower):
oc = event.char
if oc == KILL:
self.searchval.set('')
return 'break'
if oc == '\b':
s = self.searchval.get()[:-1]
self.searchval.set(s)
return 'break'
if not oc or not ctype.isprint(oc):
return 'break'
s = self.searchval.get() + lower(oc)
self.searchval.set(s)
lb = self.sorted_lb
v = lambda i,get=lb.get,low=lower: low(get(i))
z = lb.size() - 1
l = 0
u = z
if self.sorted_column_reversed:
while u >= l:
i = (l + u)/2
r = cmp(s, v(i))
if r == 0: break
if r > 0: u = i - 1
else: l = i + 1
else:
while u >= l:
i = (l + u)/2
r = cmp(s, v(i))
if r == 0: break
if r < 0: u = i - 1
else: l = i + 1
if self.sorted_column_reversed:
if r and u >= 0: i = u
else:
if r and l <= z: i = l
lb.see(i)
self.selectrow(i)
self.publish(FIND)
return 'break'
def lb_scrollpage(self, event):
Debug(3, '''"lb_scrollpage(%s)" % event.keysym''')
if event.keysym == 'Next':
dir = 1
else:
dir = -1
for lb,col,pos in self.listboxes: lb.yview('scroll', dir, 'pages')
apply(self.table_sb.set, lb.yview())
return 'break'
def message(self, msg=''):
if self.mesg_func is not None:
self.mesg_func(msg, 'warn')
return
tkMessageBox.showwarning('Warning!', msg, parent=self.master)
def publish(self, typ):
if self.sel_func is None:
return
if self.mult_sel is not None:
values = []
for index in range(self.mult_sel, self.selected+1):
self.selected = index
vals = self.selected_values()
if vals is not None:
values.append(vals)
if not values:
return
else:
values = self.selected_values()
if values is None:
return
Debug(2, '''"publish(%s, %s)" % (typ, `values`)''')
self.sel_func(typ, values)
def publish_selected(self, event):
self.publish(RETURN)
def regex(self, event):
rows = self.sorted_rows
end = len(rows)
pat = self.regex_e.get()
last_pat, last_cpat, last_pat_pos = self.last_pat
if pat == last_pat:
cpat = last_cpat
pos = last_pat_pos+1
if pos >= end: pos = 0
else:
try:
cpat = re.compile(pat, re.IGNORECASE)
except re.error, val:
self.message(
"Pattern compilation error near character %s: %s." % (val[1], val[0]))
return 'break'
pos = 0
Debug(2, '''"regex: lastpat = %s, pos = %s" % (last_pat, pos)''')
while 1:
index = pos-1
for x,row in rows[pos:end]:
index = index + 1
if cpat.search(string.join(row)) is None:
continue
self.selectrow(index)
self.publish(REGEX)
self.last_pat = (pat, cpat, index)
return 'break'
if pos == 0: break
end = pos
pos = 0
self.last_pat = (pat, cpat, 0)
self.message('No match.')
return 'break'
def select_find_row(self, row):
Debug(3, '''"select_find_row(%s)" % `row`''')
row = (string.lower(row[self.sorted_column]), row)
try:
index = self.sorted_rows.index(row)
except ValueError:
Debug(1, '''"CAN'T FIND %s in %s" % (`row`, `ordrows`)''')
return
self.selected = None
self.selectrow(index)
def selected_row(self):
index = self.selected
if index is None:
return None
listboxes = self.listboxes
row = [None]*len(listboxes)
for lb,coldesc,pos in listboxes:
row[pos] = lb.get(index)
return row
def selectline(self, typ, event):
self.mult_sel = None
self.selectrow(self.listboxes[0][0].nearest(event.y))
self.publish(typ)
def selectlineb(self, event):
self.selectline(BUTTON, event)
def selectlinebb(self, event):
self.selectline(DBLBUTTON, event)
def selectlinesb(self, event):
index = self.listboxes[0][0].nearest(event.y)
Debug(3, '''"selectlinesb %d (selected=%d)" % (index, self.selected)''')
if not self.allow_mult_sel or self.selected is None or index == self.selected:
self.selectline(BUTTON, event)
return
if index < self.selected:
self.mult_sel = index
else:
self.mult_sel = self.selected
self.selected = index
for lb,col,pos in self.listboxes:
lb.selection_clear(0, 'end')
lb.selection_set(self.mult_sel, self.selected)
lb.see(index)
self.publish(BUTTON)
def selectrow(self, index):
if self.selected == index: return
self.selected = index
for lb,col,pos in self.listboxes:
lb.selection_clear(0, 'end')
lb.selection_set(index)
lb.see(index)
def sort(self, col=0, rev=None, colsort=None):
Debug(3, '''"(col=%s, rev=%s, colsort=%s)" % (col, rev, colsort)''')
listboxes = self.listboxes
defbkgrnd = self.listbox_default_background
sel_row = self.selected_row()
if colsort is not None:
ordrows = colsort(self.rows, col)
else:
rows = self.rows
l = len(rows)
ordrows = [None]*l
lower = string.lower
for r in range(l):
row = rows[r]
ordrows[r] = (lower(row[col]), row)
ordrows.sort()
if rev: ordrows.reverse()
Debug(3, '''"sort fill rows %s..." % `ordrows[0:1]`''')
self.sorted_rows = ordrows
self.sorted_column = col
self.sorted_column_reversed = rev
for lb,coldesc,column in listboxes:
lb['background'] = defbkgrnd
lb.delete(0, 'end')
insert = lb.insert
for x,row in ordrows:
insert('end', row[column])
Debug(3, '''"listboxes filled"''')
self.sorted_lb = listboxes[col][0]
self.sorted_lb['background'] = globals.ActiveListBGColour
if sel_row is None:
return
self.select_find_row(sel_row)
def yscroll(self, upper, lower):
if self.upper == upper and self.lower == lower:
Debug(5, '''"yscroll(%s,%s) ignored" % (upper, lower)''')
return
Debug(3, '''"yscroll(%s,%s)" % (upper, lower)''')
self.upper, self.lower = upper, lower
self.table_sb.set(upper, lower)
for lb,col,pos in self.listboxes: lb.yview('moveto', upper)
def yview(self, *args):
Debug(3, '''"yview(%s)" % `args`''')
for lb,col,pos in self.listboxes: apply(lb.yview, args)
if __debug__:
if __name__ == '__main__':
try:
import logger; logger.DebugLevel(3)
except ImportError:
pass
colours = ('/usr/lib/X11/rgb.txt', '/usr/openwin/lib/X11/rgb.txt')
colmap = {'red': (0,1,0), 'green': (1,2,0), 'blue': (2,3,0), 'name': (3,0,1)}
coldescs = []
for k,(col,pos,wet) in colmap.items():
d = {'datacol':col, 'position':pos, 'title':k, 'weight':wet}
if k != 'name': d['rjust'] = 1
coldescs.append(d)
for path in colours:
try:
fd = open(path)
except:
pass
rows = []
for line in map(string.strip, fd.readlines()):
rows.append(string.split(line, None, 3))
fd.close()
top = Tkinter.Tk()
top.iconname('ScrolledTable')
def cb(t,d):
print t,`d`
if type(d) is type([]):
col=d[-1]['name']
else:
col=d['name']
top.configure(bg=col)
st = ScrolledTable(top, coldescs)
st.build()
st.configure(cb, height=10, allow_mult_sel=1)
st.fill(rows, col=3)
x = Tkinter.Button(top, text='Quit', command=top.quit)
x.pack(side='bottom', anchor='e')
try: top.mainloop()
except KeyboardInterrupt: print
# code highlighted using py2html.py version 0.8