]> git.neil.brown.name Git - freerunner.git/blob - lib/listselect.py
contacts app
[freerunner.git] / lib / listselect.py
1 #!/usr/bin/env python
2
3 #TODO
4 # - centering
5 # - test variable-length list
6
7 # This module provides the "Select" widget which can be used to
8 # selected one item from a list, such as a command, and file, or
9 # anything else.  Selecting an object should not have any significant
10 # effect (though the control of that is outside this module).  That is
11 # because this widget is intended for a finger-touch display and
12 # precision might not be very good - a wrong selection should be
13 # easily changed.
14 #
15 # A scale factor is available (though external controls must be used
16 # to change it). With small scale factors, the display might use
17 # multiple columns to get more entries on the display.
18 #
19 # There is no direct control of scolling.  Rather the list is
20 # automatically scrolled to ensure that the selected item is displayed
21 # and is not to close to either end.  If the selected item would be
22 # near the end, it is scrolled to be near the beginning, and similarly
23 # if it is near the beginning, the list is scrolled so that the
24 # selected item is near the end of the display
25 #
26 # However we never display blank space before the list and try to
27 # avoid displaying more than one blank space after the list.
28 #
29 # Each entry is a short text.  It can have a number of highlights
30 # including:
31 #  - foreground colour
32 #  - background colour
33 #  - underline
34 #  - leading bullet
35 #
36 # The text is either centered in the column or left justified
37 # (possibly leaving space for a bullet).
38 #
39 # This widget only provides display of the list and selection.
40 # It does not process input events directly.  Rather some other
41 # module must take events from this window (or elsewhere) and send
42 # 'tap' events to this widget as appropriate.  They are converted
43 # to selections.  This allows e.g. a writing-recognition widget
44 # to process all input and keep strokes to itself, only sending
45 # taps to us.
46 #
47 # The list of elements is passed as an array.  However it could be
48 # changed at any time.  This widget assumes that it will be told
49 # whenever the list changes so it doesn't have to poll the list
50 # at all.
51 #
52 # When the list does change, we try to preserve the currently selected
53 # position based on the text of the entry.
54 #
55 # We support arrays with an apparent size of 0.  In this case we
56 # don't try to preserve location on a change, and might display
57 # more white space at the end of the array (which should appear
58 # as containing None).
59 #
60 # It is possible to ask the "Select" to move the the "next" or
61 # "previous" item.  This can have a function which tests candidates
62 # for suitability.
63 #
64 # An entry in the list has two parts: the string and the highlight
65 # It must look like a tuple:  e[0] is the string. e[1] is the highlight
66 # The highlight can be just a string, in which case it is a colour name,
67 # or a tuple of (colour underline bullet background selected-background)
68 # missing fields default to (black False False grey white)
69 # Also a mapping from string to type can be created.
70
71 import gtk, pango, gobject
72
73
74 class ListSelect(gtk.DrawingArea):
75     __gsignals__ = {
76         'selected' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
77                       (gobject.TYPE_INT,))
78         }
79     def __init__(self, center = False):
80         gtk.DrawingArea.__init__(self)
81
82         # Index of first entry displayed
83         self.top = 0
84         # Index of currently selected entry 
85         self.selected = None
86         # string value of current selection
87         self.selected_str = None
88         self.list = []
89         self.center = center
90
91         self.fd = self.get_pango_context().get_font_description()
92         # zoom level: 20..50
93         self.zoom = 0
94         self.width = 1
95         self.height = 1
96         self.rows = 1
97         self.cols = 1
98         self.bullet_space = True
99
100         self.format_list = {}
101         self.colours = {}
102         self.to_draw = []
103         self.set_zoom(30)
104         self.connect("expose-event", self.draw)
105         self.connect("configure-event", self.reconfig)
106
107         self.connect_after("button_press_event", self.press)
108         self.add_events(gtk.gdk.BUTTON_PRESS_MASK)
109
110
111     def press(self, c, ev):
112         #print "press"
113         self.tap(ev.x, ev.y)
114         
115     def draw(self, w, ev):
116         # draw any field that is in the area
117         (x,y,w,h) = ev.area
118         for c in range(self.cols):
119             if (c+1) * self.colwidth < x:
120                 continue
121             if x + w < c * self.colwidth:
122                 break
123             for r in range(self.rows):
124                 if (r+1) * self.lineheight < y:
125                     continue
126                 if y + h < r * self.lineheight:
127                     break
128                 if (r,c) not in self.to_draw:
129                     self.to_draw.append((r,c))
130         if ev.count == 0:
131             for r,c in self.to_draw:
132                 self.draw_one(r,c)
133         self.to_draw = []
134
135     def draw_one(self, r, c, task = None):
136         ind = r + c * self.rows + self.top
137         if len(self.list) >= 0 and ind >= len(self.list):
138             val = None
139         else:
140             val = self.list[ind]
141         if task != None and task != val:
142             return
143         if val == None:
144             strng,fmt = "", "blank"
145         else:
146             strng,fmt = val
147             try:
148                 val.on_change(self, ind)
149             except:
150                 pass
151
152         if type(fmt) == str:
153             fmt = self.get_format(fmt)
154
155         if len(fmt) == 5:
156             (col, under, bullet, back, sel) = fmt
157             bold=(0,0)
158         if len(fmt) == 6:
159             (col, under, bullet, back, sel, bold) = fmt
160         # draw background rectangle
161         if ind == self.selected:
162             self.window.draw_rectangle(self.get_colour(sel), True,
163                                 c*self.colwidth, r*self.lineheight,
164                                 self.colwidth, self.lineheight)
165         else:
166             self.window.draw_rectangle(self.get_colour(back), True,
167                                        c*self.colwidth, r*self.lineheight,
168                                        self.colwidth, self.lineheight)
169         if bullet:
170             w = int(self.lineheight * 0.4)
171             vo = (self.lineheight - w)/2
172             ho = 0
173             self.window.draw_rectangle(self.get_colour(col), True,
174                                        c*self.colwidth+ho, r*self.lineheight + vo,
175                                        w, w)
176
177         # draw text
178         layout = self.create_pango_layout(strng)
179         a = pango.AttrList()
180         if under:
181             a.insert(pango.AttrUnderline(pango.UNDERLINE_SINGLE, 0, len(strng)))
182         if bold[0] < bold[1]:
183             a.insert(pango.AttrWeight(pango.WEIGHT_BOLD,bold[0], bold[1]))
184         layout.set_attributes(a)
185
186         offset = self.offset
187         if self.center:
188             ink, (ex,ey,ew,eh) = layout.get_pixel_extents()
189             offset = int((self.colwidth - ew) / 2)
190         if offset < 0:
191             offset = 0
192
193         # FIXME
194         self.window.draw_layout(self.get_colour(col),
195                                 c*self.colwidth + offset,
196                                 r*self.lineheight,
197                                 layout)
198         
199
200     def set_colour(self, name, col):
201         self.colours[name] = col
202     def get_colour(self, col):
203         # col is either a colour name, or a pre-set colour.
204         # so if it isn't in the list, add it
205         if col == None:
206             return self.get_style().bg_gc[gtk.STATE_NORMAL]
207         if col not in self.colours:
208             self.set_colour(col, col)
209         if type(self.colours[col]) == str:
210             gc = self.window.new_gc()
211             gc.set_foreground(self.get_colormap().
212                               alloc_color(gtk.gdk.color_parse(self.colours[col])))
213             self.colours[col] = gc;
214         return self.colours[col]
215
216     
217     def set_format(self, name, colour, underline=False, bullet=False,
218                 background=None, selected="white"):
219         self.format_list[name] = (colour, underline, bullet, background, selected)
220
221     def get_format(self, name):
222         if name in self.format_list:
223             return self.format_list[name]
224         if name == "blank":
225             return (None, False, False, None, None)
226         return (name, False, False, None, "white")
227
228     def calc_layout(self):
229         # The zoom or size or list has changed.
230         # We need to calculate lineheight and colwidth
231         # and from those, rows and cols.
232         # If the list is of indefinite length we cannot check the
233         # width of every entry so we just check until we have enough
234         # to fill the page
235
236         i = 0
237         n = len(self.list)
238         indefinite = (n < 0)
239         maxw = 1; maxh = 1;
240         while n < 0 or i < n:
241             e = self.list[i]
242             if e == None:
243                 break
244             strng, fmt = e
245             layout = self.create_pango_layout(strng)
246             ink, (ex,ey,ew,eh) = layout.get_pixel_extents()
247             if ew > maxw: maxw = ew
248             if eh > maxh: maxh = eh
249
250             if indefinite:
251                 rs = int(self.height / maxh)
252                 cs = int(self.width / maxw)
253                 if rs < 1: rs = 1
254                 if cs < 1: cs = 1
255                 n = self.top + rs * cs
256             i += 1
257
258         real_maxw = maxw
259         if self.bullet_space:
260             maxw = maxw + maxh
261         self.rows = int(self.height / maxh)
262         self.cols = int(self.width / maxw)
263         if self.rows == 0:
264             self.rows = 1
265         if self.cols > int((i + self.rows-1) / self.rows):
266             self.cols = int((i + self.rows-1) / self.rows)
267         if self.cols == 0:
268             self.cols = 1
269         self.lineheight = maxh
270         self.colwidth = int(self.width / self.cols)
271         self.offset = (self.colwidth - real_maxw) / 2
272
273     def check_scroll(self):
274         # the top and/or selected have changed, or maybe the layout has
275         # changed.
276         # We need to make sure 'top' is still appropriate.
277         oldtop = self.top
278         if self.selected == None:
279             self.top = 0
280         else:
281             margin = self.rows / 3
282             if margin < 1:
283                 margin = 1
284             remainder = self.rows * self.cols - margin
285             if self.selected < self.top + margin:
286                 self.top = self.selected - (remainder - 1)
287                 if self.top < 0:
288                     self.top = 0
289             if self.selected >= self.top + remainder:
290                 self.top = self.selected - margin
291                 l = len(self.list)
292                 if l >= 0 and self.top + self.rows * self.cols > l:
293                     self.top = l - self.rows * self.cols + 1
294
295         return self.top != oldtop
296
297     def reconfig(self, w, ev):
298         alloc = w.get_allocation()
299         if alloc.width != self.width or alloc.height != self.height:
300             self.width, self.height = alloc.width, alloc.height
301             self.calc_layout()
302             self.check_scroll()
303             self.queue_draw()
304
305     def set_zoom(self, zoom):
306         if zoom > 50:
307             zoom = 50
308         if zoom < 20:
309             zoom = 20
310         if zoom == self.zoom:
311             return
312         self.zoom = zoom
313         s = pango.SCALE
314         for i in range(zoom):
315             s = s * 11 / 10
316         self.fd.set_absolute_size(s)
317         self.modify_font(self.fd)
318
319         self.calc_layout()
320         self.check_scroll()
321         self.queue_draw()
322
323     def list_changed(self):
324         l = len(self.list)
325         if l >= 0:
326             for i in range(l):
327                 if self.list[i][0] == self.selected_str:
328                     self.selected = i
329                     break
330             if self.selected >= l:
331                 self.selected = None
332             elif self.selected != None:
333                 self.selected_str = self.list[self.selected][0]
334         self.calc_layout()
335         self.check_scroll()
336         self.queue_draw()
337
338     def item_changed(self, ind, task = None):
339         # only changed if it is still 'task'
340         col = (ind - self.top) / self.rows
341         row = (ind - self.top) - (col * self.rows)
342         self.draw_one(row, col, task)
343
344
345     def map_pos(self, x, y):
346         row = int(y / self.lineheight)
347         col = int(x / self.colwidth)
348         ind = row + col * self.rows + self.top
349         l = len(self.list)
350         if l >= 0 and ind >= l:
351             return None
352         if l < 0 and self.list[ind] == None:
353             return None
354         return ind
355
356     def tap(self, x, y):
357         ind = self.map_pos(x,y)
358         if ind != None:
359             self.select(ind)
360
361     def select(self, ind):
362         if self.selected == ind:
363             self.emit('selected', -1 if ind == None else ind)
364             return
365         if ind == None:
366             self.selected = None
367             self.selected_str = None
368             self.list_changed()
369             self.emit('selected', -1)
370             return
371         old = self.selected
372         self.selected = ind
373         self.selected_str = self.list[ind][0]
374         if self.window == None or self.check_scroll():
375             self.queue_draw()
376         else:
377             col = (ind - self.top) / self.rows
378             row = (ind - self.top) - (col * self.rows)
379             self.draw_one(row, col)
380             if old != None:
381                 col = (old - self.top) / self.rows
382                 row = (old - self.top) - (col * self.rows)
383                 self.draw_one(row, col)
384         self.emit('selected', ind)
385
386 if __name__ == "__main__":
387
388     # demo app using this widget
389     w = gtk.Window(gtk.WINDOW_TOPLEVEL)
390     w.connect("destroy", lambda w: gtk.main_quit())
391     w.set_title("ListSelect Test")
392
393     s = ListSelect()
394     list = [ "zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
395                "nine", "ten", "eleven", "twelve", "thirteen", "forteen"]
396     el = []
397     for a in list:
398         el.append((a, "blue"))
399     el[9] = (el[9][0], ("red",True,True,"black","white"))
400     el[13] = (el[13][0], ("black",False,False,"yellow","white",(4,8)))
401     def sel(s, n):
402         print n, s.list[n], "selected"
403     s.connect('selected', sel)
404
405     s.list = el
406     s.select(12)
407     w.add(s)
408     s.show()
409     w.show()
410
411     def key(c, ev):
412         print "key"
413         if ev.string == '+':
414             s.set_zoom(c.zoom+1)
415         if ev.string == '-':
416             s.set_zoom(c.zoom-1)
417     w.connect("key_press_event", key)
418     w.add_events(gtk.gdk.KEY_PRESS_MASK)
419     #w.set_property('can-focus', True)
420     #s.grab_focus()
421
422     han = 0
423     def tap(c, ev):
424         print "tap", ev.send_event
425         #s.tap(ev.x, ev.y)
426         c.handler_block(han)
427         c.event(ev)
428         c.handler_unblock(han)
429         c.stop_emission("button_press_event")
430
431     han = s.connect("button_press_event", tap)
432     gtk.main()