maemo, SoftwareEngineering

Maemo : Custom cell renderer for gtk treeview (python)

I wrote a couple of weeks ago a little teaser about writing a custom cell renderer. When I first started witter, I found that a treeview was the obvious way to display tweets. And the available cell renderer was CellRendererText. which took care of the basics. However it didn't look terribly 'pretty'

This post shows the code I wrote, and how it evolved as I figured out more things. Unfortunately when putting code into wordpress I can either have it render as normal text and thus unreadable as structured code, or have it rendered like code but victim to cropping off the ends of long lines due to my fixed with blog theme. For full code listing go look at:Version history for witter_cell_renderer

When I started out, I soon discovered that if I wanted anything nicer I would have to write my own cell renderer, taking responsibility for drawing rectangles behind the text, and rendering the text.

I quickly figured out how to draw the cells, with nice rounded corners:

def render_rect(self, cr, x, y, w, h): ''' create a rectangle with rounded corners ''' x0 = x y0 = y rect_width = w rect_height = h radius = 10 #draw the first half with rounded corners x1 = x0 + rect_width y1 = y0 + rect_height cr.move_to(x0, y0 + radius) cr.curve_to(x0, y0+radius, x0, y0, x0 + radius, y0) cr.line_to(x1 -radius, y0) cr.curve_to(x1-radius, y0, x1, y0, x1, y0 + radius) cr.line_to(x1, y1-radius) cr.curve_to(x1, y1-radius, x1, y1, x1 -radius, y1) cr.line_to(x0 +radius, y1) cr.curve_to(x0+radius, y1, x0, y1, x0, y1-radius -1) cr.close_path()

This works based on being given the size of the 'cell' and drawing straight lines to point just in from the corners, then using curve_to to draw curves in the corner spaces.

then I found some examples that showed how you create a 'patern' by setting colour stops, then calling cairo_context.fill to colour in the rectangle. By doing this you can get colour gradients which make the cell look a little nicer than just a flat background colour. My first draft looked like this:

pat = cairo.LinearGradient(x, y, x, y + h) color = gtk.gdk.color_parse("#6495ED") pat.add_color_stop_rgba( 0.0, self.get_cairo_color(color.red), self.get_cairo_color(color.green), self.get_cairo_color(color.blue), 1 ) color = gtk.gdk.color_parse("#5F9EA0") pat.add_color_stop_rgba( 0.5, self.get_cairo_color(color.red), self.get_cairo_color(color.green), self.get_cairo_color(color.blue), 1 ) color = gtk.gdk.color_parse("#6495ED") pat.add_color_stop_rgb( 1.0, self.get_cairo_color(color.red), self.get_cairo_color(color.green), self.get_cairo_color(color.blue) ) cairo_context.set_source(pat) cairo_context.fill()

Note that the colour values I just picked from thin air, and they turned out around red. Later I actually took a screen shot of an application window on my N900, and used gimp to grab the colour gradient used in the app switcher button. By re-using those colours for my gradient it made the app instantly feel more at home on the n900.

All was going well enough, but then I made a mistake, I hit upon cairo_context.show_text(str) and started to build code around using it. I wound up with something fairly complicated. You see I wanted to check words for some key things, and set different colours for them. I also wanted to wrap text on word boundaries. And obviously not overflow the screen width with text. Using cairo and it's text handling methods you can give it a string and ask it, given the current font settings, what will be the extents of the rendering for that text. From this I build a very compliated routine which figured out how many lines I would need to split into, in order to keep it all in the right width. And based on this determine the height of the cell required. I used this in my on_get_size method to return the cell size, which I would later be passed to render the rectangle and text in. This way of doing things left me with this complicated mess:

#we want to calculate the actual height to render the backing so we need the space required #to render the string using the specified font and font size cairo_context.set_source_rgba(1, 1, 1, 1) cairo_context.select_font_face("Georgia", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) cairo_context.set_font_size(self.get_property('font_size')) x_bearing, y_bearing, width, height = cairo_context.text_extents(self.get_property('text'))[:4] #get the text tweet = self.get_property('text') seg_len = self.get_seg_len_for_font_size(cairo_context,self.get_property('font_size'),(w-20)) words = tweet.split(" ") line = "" linecount = 0 #set the starting position for text display cairo_context.move_to(x+10, ((y+height))) for word in words: if ((len(line) + len(word) + 1) > seg_len): #set the position for the line of text, we start in the top quarter then drop in text heigh increments cairo_context.move_to(x+10, ((y+height) + ((linecount+1)*height) +2)) #set the line string to the word we didn't add line = word linecount = linecount +1 else: line = line + " "+word if ( word.startswith("@")): cairo_context.set_source_rgba(0, 0, 0, 1) elif ( word.startswith("http:")): cairo_context.set_source_rgba(0, 1, 1, 1) else: cairo_context.set_source_rgba(1, 1, 1, 1) word = word.replace("&","&") word = word.replace("<","") cairo_context.show_text(word + " ") if (self.get_property('replyto') != ""): #process any retweet text cairo_context.set_source_rgba(1, 1, 1, 1) cairo_context.set_font_size(self.get_property('font_size') -5) x_bearing, y_bearing, width, height = cairo_context.text_extents(self.get_property('replyto'))[:4] cairo_context.move_to(x+10, ((y+height) + ((linecount+1)*height) +self.get_property('font_size') -5)) seg_len = self.get_seg_len_for_font_size(cairo_context,self.get_property('font_size')-5,(w-20)) retweet = self.get_property('replyto') retweetwords = retweet.split(" ") for word in retweetwords: if ((len(line) + len(word) + 1) > seg_len): #set the position for the line of text, we start in the top quarter then drop in text heigh increments cairo_context.move_to(x+10, ((y+height) + ((linecount+2)*height) +self.get_property('font_size') -5)) #set the line string to the word we didn't add line = word linecount = linecount +1 else: line = line + " "+word if ( word.startswith("@")): cairo_context.set_source_rgba(0, 0, 0, 1) elif ( word.startswith("http:")): cairo_context.set_source_rgba(0, 1, 1, 1) else: cairo_context.set_source_rgba(1, 1, 1, 1) word = word.replace("&","&") word = word.replace("<","") cairo_context.show_text(word + " ") cairo_context.set_source_rgba(1, 1, 1, 1) cairo_context.select_font_face("Georgia", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) cairo_context.set_font_size(self.get_property('font_size') -5) ts_x_bearing, ts_y_bearing, ts_width, ts_height = cairo_context.text_extents(self.get_property('timestamp'))[:4] #position in the bottom right, in set by the width of the timestamp, and a little padding cairo_context.move_to(x+(w-(ts_width+10)), y+(h-5)) cairo_context.show_text(self.get_property('timestamp'))

Yikes! basically I figured out how long a text 'segment' would be to fit on a line. Then split the whole tweet into words, and for each word figured out if adding it to the current line would take it over the segment length or not. If it would it became the first word on the new line. And I also had to handle setting the start of line to the next starting position. I then did the same again for reply text if there was any. And finally set the timestamp in the bottom right corner of the cell.

For all this effort it looked ok:

I had by this point set the colours right, and the tweets where wrapping properly, rather than splitting lines wherever (the default cellRendererText was just spliting anywhere by default)

However... The lines were getting split in slightly weird ways, sometimes, as in the picture, I got two lines that were quite short, instead of one full line and just whatever needed to be on the second.

The other major problem was that this no longer rendered any non romanised languages. Eg Chinese, Japanese, arabic etc etc. These rendered just fine in the normal text cell renderer. But not now, this was most annoying.

So I did some digging and found that I should not have been using the cairo text rendering methods at all. That there is something called 'pango' which provides a much better interface for rendering text. And importantly, it supports non-romanised fonts properly. The problem is something about most fonts don't have glyphs for all languages, and so cairo renders only in the font you select, but pango will go find a font that does have the right glyphs if they aren't present in your selected font.

to use pango you create a pangocairo context, which can be used just like a cairo context:

cairo_context = pangocairo.CairoContext(window.cairo_create())

Suddenly all that text rendering logic gets MUCH easier, because pango has the ability for you to tell it the wrap width you'd like it to use, and whether it should wrap on word boundaries. It can then be called to get the extents of rendering that text, so my method to calculate the cell size required got much simpler and more accurate.

additionally pango supports a markup language much like HTML, so instead of rendering one word at a time and changing colours, I could simply tag up the text with some markup around they keywords, and let pang render the colours.

the resulting text rendering logic looks like this:

layout = cairo_context.create_layout() font = pango.FontDescription("Sans") font.set_size(pango.SCALE*(self.get_property('font_size'))) font.set_style(pango.STYLE_NORMAL) font.set_weight(pango.WEIGHT_BOLD) layout.set_font_description(font) layout.set_width(pango.SCALE *w) layout.set_wrap(pango.WRAP_WORD) #get the text as a unicode string tweet = unicode(self.get_property('text')) line = "" words = tweet.split(" ") for word in words: if (word.startswith("@")): word = "

" + word +"

" if (word.startswith("http:")): word = "

" + word +"

" line = line + " " + word #set the starting position for text display cairo_context.move_to(x+10, ((y+10))) layout.set_markup(line) #layouts start again from begining not where you left off with last word inkRect, logicalRect = layout.get_pixel_extents() tweet_x, tweet_y, tweet_w, tweet_h = logicalRect cairo_context.show_layout(layout) if (self.get_property('replyto') != ""): #process any retweet text layout = cairo_context.create_layout() cairo_context.set_source_rgba(1, 1, 1, 1) font = pango.FontDescription("Sans") font.set_size(pango.SCALE*(self.get_property('font_size')-6)) font.set_style(pango.STYLE_NORMAL) font.set_weight(pango.WEIGHT_BOLD) layout.set_font_description(font) layout.set_wrap(pango.WRAP_WORD) layout.set_width(pango.SCALE *w) #set position under the main tweet text cairo_context.move_to(x+10, ((y+tweet_h+10))) layout.set_text(self.get_property('replyto') ) #layouts start again from begining not where you left off with last word cairo_context.show_layout(layout) cairo_context.set_source_rgba(1, 1, 1, 1) cairo_context.select_font_face("Georgia", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) cairo_context.set_font_size(self.get_property('font_size') -5) ts_x_bearing, ts_y_bearing, ts_width, ts_height = cairo_context.text_extents(self.get_property('timestamp'))[:4] #position in the bottom right, in set by the width of the timestamp, and a little padding cairo_context.move_to(x+(w-(ts_width+10)), y+(h-5)) cairo_context.show_text(self.get_property('timestamp'))

At this point I'm still using regular cairo to render the timestamp, mostly because I've not got around to adjusting it. But the main work is now much easier, I still split the line into words to be able to tag appropriately around urls and twitter usernames. but there is just one position set and render per tweet, then another if there is a reply. I'm not having to maintain positional state to render each line myself.

One thing that caught me out briefly is that pango operated in 'pango scale' So if you want to give it, for instance the width to wrap at, you have to multiply your width number by pango.SCALE to get what you expect.

For a while I thought it still wasn't working with non romanised fonts, because when I ran it in my scratchbox development environment, it just rendered boxes with the hex numbers in, rather than the real glyphs. This turned out to be just because the development environment isn't set up (quite right/the same). When run on a real N900 it renders just fine: http://farm3.static.flickr.com/2770/4227987217_201ee955be_o.png

So there you have it, if you want to write your own cell renderer for a treeview, use pango to render your text.