"""
	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)	# CTRL-U

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 = {}	# Public interface to listboxes indexed by title
		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.
		"""

		# Columns composed of listboxes,
		# with a single scrollbar at the side,
		# and sort buttons top and bottom.

		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	# Ensure presence

			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	# Exported interface

			# Widget bindings

			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

		# Make function that maps row columns to displayed column order:
		# def displayed_order(row): return [row[3], row[1], ...]

		exec "def displayed_order(row): return [%s]" % string.join(datacols, ', ')
		self.displayed_order = displayed_order

		# Regular expression search and simple finder entry boxes.

		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
		
		# Canonicalise rows to be strings suitable for listbox insertion,
		# in same order as displayed listboxes.

		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'):
				# Calculate column width.
				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
					
		# Build dictionary mapping canonicalised rows to original.

		list2orig = {}; orig2list = {}
		for l,o in map(None, listrows, rows):
			# NB: list isn't allowed as dict key.
			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
			
		# Set selection across all rows and lines.
		for lb,col,pos in self.listboxes:
			lb.selection_clear(0, 'end')
			lb.selection_set(first, self.selected)
			lb.see(self.selected)	# Maintain view of last selected row


	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


	#
	#	Private methods
	#


	def find_enter(self, event):

		# <Return> typed into `Find' entry - clear entry.

		self.searchval.set('')
		return 'break'


	def find_key(self, event):

		# Character typed into `Find' entry - refine match.

		self.lb_key(event)
		self.find_e.icursor('end')
		return 'break'


	def lb_enter(self, event):

		# Listbox entered - set focus, clear search string.

		self.sorted_lb.focus_set()
		self.searchval.set('')


	def lb_key(self, event, lower=string.lower):

		# Character typed into (case-insensitive sorted) listbox -
		#	add to search string, and select closest match.

		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'	# Shift?

		s = self.searchval.get() + lower(oc)

		# Update `searchval'

		self.searchval.set(s)

		# Function to fetch listbox entry in lowercase

		lb = self.sorted_lb
		v = lambda i,get=lb.get,low=lower: low(get(i))

		# Binary chop

		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

		# Adjust display for (nearest) match.

		# `searchval' is probably short, so adjust up if no match.
		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:		#  'Prior'
			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):

		# Callback to process selected values.

		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):

		# <Return> in column - publish selected row (if any)

		self.publish(RETURN)


	def regex(self, event):

		# <Return> in regexp entry box - run regexp on rows *as displayed*.

		# `self.sorted_rows' contains rows in display order.

		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	# Wrap around

		self.last_pat = (pat, cpat, 0)
		self.message('No match.')
		return 'break'


	def select_find_row(self, row):

		# Find and select `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):

		# Return selected listbox values as row.

		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):

		# Select and publish line

		self.mult_sel = None
		self.selectrow(self.listboxes[0][0].nearest(event.y))
		self.publish(typ)


	def selectlineb(self, event):

		# Button-1 pressed - select line

		self.selectline(BUTTON, event)


	def selectlinebb(self, event):

		# Double-Button-1 pressed - select line emphasised

		self.selectline(DBLBUTTON, event)


	def selectlinesb(self, event):

		# Shift-Button-1 pressed - select all lines between last and this

		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
			
		# Set selection across all rows and lines.
		for lb,col,pos in self.listboxes:
			lb.selection_clear(0, 'end')
			lb.selection_set(self.mult_sel, self.selected)
			lb.see(index)	# Maintain view of last selected row

		self.publish(BUTTON)


	def selectrow(self, index):

		# Select row at `index'.

		if self.selected == index: return
		self.selected = index

		# Set selection across all rows.
		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):

		# Perform a case-insensitive sort on displayed column `col'.

		Debug(3, '''"(col=%s, rev=%s, colsort=%s)" % (col, rev, colsort)''')

		listboxes = self.listboxes
		defbkgrnd = self.listbox_default_background
		sel_row = self.selected_row()

		# Sort rows

		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

		# Clear and fill listboxes

		for lb,coldesc,column in listboxes:
			lb['background'] = defbkgrnd
			lb.delete(0, 'end')
			# Fill listbox
			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