Wednesday, December 9, 2009

More Quidgets ... No More TreeViews



TreeViews (Not Easy and Fun)
If you have data that you want to display in a grid format in PyGtk is code intensive and inflexible. In Cognitive Dimensions speak we would say that the Work Step Units are too small, and the resulting code has high viscosity. It's also quite hard to learn.

In order to take a dictionary of data and display it to a user in a grid in PyGtk, you use a combination of three object:
  • TreeView
  • ListModel
  • TreeViewColumn

Here's some real code that I wrote while back for using the FogBugz XML API:
def __init__(self,session,data):
gtk.VBox.__init__( self, False, 3 )
child = gtk.ScrolledWindow()
#start time, end time, duration, case, title, category
store = gtk.ListStore(gobject.TYPE_STRING,gobject.TYPE_STRING,gobject.TYPE_STRING, gobject.TYPE_INT,gobject.TYPE_STRING,gobject.TYPE_STRING)

total_time = None
for interval in data:
b = pyFogbugz.getbugs(session, [interval["case"]])[0]
store.append([interval["start"],interval["end"],interval["duration"],interval["case"],b["title"],b["category"]])
#see if this is the earliest date

#accumulate total time
if total_time == None:
total_time = interval["duration"]
else:
total_time += interval["duration"]

view = gtk.TreeView(store)

stcol = gtk.TreeViewColumn("Start", gtk.CellRendererText(), text=0)
stcol.set_sort_column_id(0)
view.append_column(stcol)

endcol = gtk.TreeViewColumn("End", gtk.CellRendererText(), text=1)
endcol.set_sort_column_id(1)
view.append_column(endcol)

durcol = gtk.TreeViewColumn("Duration", gtk.CellRendererText(), text=2)
durcol.set_sort_column_id(2)
view.append_column(durcol)

casecol = gtk.TreeViewColumn("Case", gtk.CellRendererText(), text=3)
casecol.set_sort_column_id(3)
view.append_column(casecol)

titlecol = gtk.TreeViewColumn("Case Title", gtk.CellRendererText(), text=4)
titlecol.set_sort_column_id(4)
titlecol.set_resizable(True)
view.append_column(titlecol)

catcol = gtk.TreeViewColumn("Case Category", gtk.CellRendererText(), text=5)
catcol.set_sort_column_id(5)
catcol.set_resizable(True)
view.append_column(catcol)

view.show()


Then in order to do anything with the TreeView, you need to manipulate:
  • The ListModel
  • Iters
  • Paths

So to remove all the selected rows, you do something like this:
#get the selected rows, and return if nothing is selected
model, rows = self.get_selection().get_selected_rows()
if len(rows) == 0:
return

#store the last selected row to reselect after removal
next_to_select = rows[-1][0] + 1 - len(rows)

#loop through and remove
iters = [model.get_iter(path) for path in rows]
for i in iters:
self.get_model().remove(i)

#select a row for the user, nicer that way
rows_remaining = len(self.get_model())

#don't try to select anything if there are no rows left
if rows_remaining < 1:
return

#select the next row down, unless it's out of range
#in which case just select the last row
if next_to_select < rows_remaining:
self.get_selection().select_path(next_to_select)
else:
self.get_selection().select_path(rows_remaining - 1)


Enter DictionaryGrid (Easy and Fun)
Last week I found that the TreeView code that I wrote for bughugger was not flexible enough to incorporate some new features. In Karmic I wrote a TreeView wrapper for Desktop Couch called CouchGrid, so I had recent experience in how I could do this better, and I determined that this would be the last time that I write this code. Thus, the quidget DictionaryGrid was born. To use it, simply hand the DictionaryGrid a list of dictionaries to display and it will do the work for you.

Here is what the test code for DictionaryGrid looks like:
    dicts = [{"ID": 0, "key2": 5, "tags": "aaa bbb ccc","_foo":"bar"},
{"ID": 1, "key2": 6, "tags": "bbb ccc ddd","_foo":"bar"},
{"ID": 2, "key2": 7, "tags": "ccc ddd eee","_foo":"bar"},
{"ID": 3, "key2": 8, "tags": "ddd eee fff","_foo":"bar"},
{"ID": 4, "key2": 9, "tags": "eee fff ggg","_foo":"bar"}]

grid = DictionaryGrid(dicts)
grid.show()


This produces this:


Seems to make more sense right? That is Easy and Fun! You just bind the TreeView to the dictionary. There are a couple of things to make DictionaryGrid a bit more flexible:

  1. If you use keys that start with underscores, they are hidden by default, thus the key "_foo" is not displayed
  2. You can determine which keys to display and in what order by passing in a list of keys, so this:
    dicts = [{"ID": 0, "key2": 5, "tags": "aaa bbb ccc","_foo":"bar"},
{"ID": 1, "key2": 6, "tags": "bbb ccc ddd","_foo":"bar"},
{"ID": 2, "key2": 7, "tags": "ccc ddd eee","_foo":"bar"},
{"ID": 3, "key2": 8, "tags": "ddd eee fff","_foo":"bar"},
{"ID": 4, "key2": 9, "tags": "eee fff ggg","_foo":"bar"}]

keys = ["ID","tags"]
grid = DictionaryGrid(dicts, keys)

grid.show()


produces this:

You can use underscores and or the keys property to store data along with the dictionary that you don't want to display, and then retrieve the complete dictionary for the selected rows. If you've set the DictionaryGrid to be editable, the user can only edit the columns that are displayed, but all of the data that you passed along in the dictionaries is persisted. This is useful if you are persisting the data in a database or similar, and need to track something like the row id but don't want to expose it to the user. The code simply looks like this:
for row in self.grid.selected_rows:
url = "http://bugs.launchpad.net/bugs/" + row["_id"]
webbrowser.open(url)

Finally, DictionaryGrid is just a subclass of gtk.TreeView, so you don't have to sacrifice any of the power and flexibility of TreeView.

GridFilter
One of the coolest things about TreeViews is that they support lightening fast filtering. But building filters for them is onerous. In fact, so onerous, I don't even want to discuss it in this posting, if you are interested, you can peruse the documentation.

However, I have created a Quidget that makes it very easy to create a filter. To cut to the chase, this code:
    dicts = [{"ID": 0, "key2": 5, "tags": "aaa bbb ccc"},
{"ID": 1, "key2": 6, "tags": "bbb ccc ddd"},
{"ID": 2, "key2": 7, "tags": "ccc ddd eee"},
{"ID": 3, "key2": 8, "tags": "ddd eee fff"},
{"ID": 4, "key2": 9, "tags": "eee fff ggg"}]
grid = DictionaryGrid(dicts)
grid.show()

filt = GridFilter(grid)
filt.show()


produces something the user can use the filters like this:

Note that:
1. There are built in filters for strings, numbers, and tags
2. a column with a key of "Id" (case insensitive) will default to using a number filter, a column with a key of "tags" (case insentitive) will default to using a tag filter, other columns will default to a string filter
3. You can create custom filters, but I don't have a base class yet to make this easy, but the code is simple if you look at GridFilter.py. In fact, the tags filter was originally written by Loic Miner.

You can override the default filter by passing in a filter hint, like this:
    dicts = [{"ID": 0, "key2": 5, "tags": "aaa bbb ccc"},
{"ID": 1, "key2": 6, "tags": "bbb ccc ddd"},
{"ID": 2, "key2": 7, "tags": "ccc ddd eee"},
{"ID": 3, "key2": 8, "tags": "ddd eee fff"},
{"ID": 4, "key2": 9, "tags": "eee fff ggg"}]
hints = {"key2":NumericFilterCombo()}
grid = DictionaryGrid(dicts)
grid.show()

filt = GridFilter(grid,hints)
filt.show()

Which makes the key2 column use a number filter:


I've already started to pull these Quidgets into a branch of bughugger. I hope that other developers find them useful, and easy and fun.

2 comments: