]> git.neil.brown.name Git - freerunner.git/blob - lib/listselect.py
More random stuff
[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.callout = None
92         
93         self.fd = self.get_pango_context().get_font_description()
94         # zoom level: 20..50
95         self.zoom = 0
96         self.width = 1
97         self.height = 1
98         self.rows = 1
99         self.cols = 1
100         self.bullet_space = True
101
102         self.format_list = {}
103         self.colours = {}
104         self.to_draw = []
105         self.set_zoom(30)
106         self.connect("expose-event", self.draw)
107         self.connect("configure-event", self.reconfig)
108
109         self.connect_after("button_press_event", self.press)
110         self.add_events(gtk.gdk.BUTTON_PRESS_MASK)
111
112
113     def press(self, c, ev):
114         #print "press"
115         self.tap(ev.x, ev.y)
116         
117     def draw(self, w, ev):
118         # draw any field that is in the area
119         (x,y,w,h) = ev.area
120         for c in range(self.cols):
121             if (c+1) * self.colwidth < x:
122                 continue
123             if x + w < c * self.colwidth:
124                 break
125             for r in range(self.rows):
126                 if (r+1) * self.lineheight < y:
127                     continue
128                 if y + h < r * self.lineheight:
129                     break
130                 if (r,c) not in self.to_draw:
131                     self.to_draw.append((r,c))
132         if ev.count == 0:
133             for r,c in self.to_draw:
134                 self.draw_one(r,c)
135         self.to_draw = []
136
137     def draw_one(self, r, c, task = None):
138         ind = r + c * self.rows + self.top
139         if len(self.list) >= 0 and ind >= len(self.list):
140             val = None
141         else:
142             val = self.list[ind]
143         if task != None and task != val:
144             return
145         if val == None:
146             strng,fmt = "", "blank"
147         else:
148             strng,fmt = val
149             try:
150                 val.on_change(self, ind)
151             except:
152                 pass
153
154         if type(fmt) == str:
155             fmt = self.get_format(fmt)
156
157         if len(fmt) == 5:
158             (col, under, bullet, back, sel) = fmt
159             bold=(0,0)
160         if len(fmt) == 6:
161             (col, under, bullet, back, sel, bold) = fmt
162         # draw background rectangle
163         if ind == self.selected:
164             self.window.draw_rectangle(self.get_colour(sel), True,
165                                 c*self.colwidth, r*self.lineheight,
166                                 self.colwidth, self.lineheight)
167         else:
168             self.window.draw_rectangle(self.get_colour(back), True,
169                                        c*self.colwidth, r*self.lineheight,
170                                        self.colwidth, self.lineheight)
171         if bullet:
172             w = int(self.lineheight * 0.4)
173             vo = (self.lineheight - w)/2
174             ho = 0
175             self.window.draw_rectangle(self.get_colour(col), True,
176                                        c*self.colwidth+ho, r*self.lineheight + vo,
177                                        w, w)
178
179         # draw text
180         layout = self.create_pango_layout(strng)
181         a = pango.AttrList()
182         if under:
183             a.insert(pango.AttrUnderline(pango.UNDERLINE_SINGLE, 0, len(strng)))
184         if bold[0] < bold[1]:
185             a.insert(pango.AttrWeight(pango.WEIGHT_BOLD,bold[0], bold[1]))
186         layout.set_attributes(a)
187
188         offset = self.offset
189         if self.center:
190             ink, (ex,ey,ew,eh) = layout.get_pixel_extents()
191             offset = int((self.colwidth - ew) / 2)
192         if offset < 0:
193             offset = 0
194
195         # FIXME
196         self.window.draw_layout(self.get_colour(col),
197                                 c*self.colwidth + offset,
198                                 r*self.lineheight,
199                                 layout)
200         
201
202     def set_colour(self, name, col):
203         self.colours[name] = col
204     def get_colour(self, col):
205         # col is either a colour name, or a pre-set colour.
206         # so if it isn't in the list, add it
207         if col == None:
208             return self.get_style().bg_gc[gtk.STATE_NORMAL]
209         if col not in self.colours:
210             self.set_colour(col, col)
211         if type(self.colours[col]) == str:
212             gc = self.window.new_gc()
213             gc.set_foreground(self.get_colormap().
214                               alloc_color(gtk.gdk.color_parse(self.colours[col])))
215             self.colours[col] = gc;
216         return self.colours[col]
217
218     
219     def set_format(self, name, colour, underline=False, bullet=False,
220                 background=None, selected="white"):
221         self.format_list[name] = (colour, underline, bullet, background, selected)
222
223     def get_format(self, name):
224         if name in self.format_list:
225             return self.format_list[name]
226         if name == "blank":
227             return (None, False, False, None, None)
228         return (name, False, False, None, "white")
229
230     def calc_layout(self):
231         # The zoom or size or list has changed.
232         # We need to calculate lineheight and colwidth
233         # and from those, rows and cols.
234         # If the list is of indefinite length we cannot check the
235         # width of every entry so we just check until we have enough
236         # to fill the page
237
238         i = 0
239         n = len(self.list)
240         indefinite = (n < 0)
241         maxw = 1; maxh = 1;
242         while n < 0 or i < n:
243             e = self.list[i]
244             if e == None:
245                 break
246             strng, fmt = e
247             layout = self.create_pango_layout(strng)
248             ink, (ex,ey,ew,eh) = layout.get_pixel_extents()
249             if ew > maxw: maxw = ew
250             if eh > maxh: maxh = eh
251
252             if indefinite:
253                 rs = int(self.height / maxh)
254                 cs = int(self.width / maxw)
255                 if rs < 1: rs = 1
256                 if cs < 1: cs = 1
257                 n = self.top + rs * cs
258             i += 1
259
260         real_maxw = maxw
261         if self.bullet_space:
262             maxw = maxw + maxh
263         self.rows = int(self.height / maxh)
264         self.cols = int(self.width / maxw)
265         if i == 0 or self.rows == 0:
266             self.rows = 1
267         if self.cols > int((i + self.rows-1) / self.rows):
268             self.cols = int((i + self.rows-1) / self.rows)
269         if self.cols == 0:
270             self.cols = 1
271         self.lineheight = maxh
272         self.colwidth = int(self.width / self.cols)
273         self.offset = (self.colwidth - real_maxw) / 2
274
275     def check_scroll(self):
276         # the top and/or selected have changed, or maybe the layout has
277         # changed.
278         # We need to make sure 'top' is still appropriate.
279         oldtop = self.top
280         if self.selected == None:
281             self.top = 0
282         else:
283             margin = self.rows / 3
284             if margin < 1:
285                 margin = 1
286             remainder = self.rows * self.cols - margin
287             if self.selected < self.top + margin:
288                 self.top = self.selected - (remainder - 1)
289                 if self.top < 0:
290                     self.top = 0
291             if self.selected >= self.top + remainder:
292                 self.top = self.selected - margin
293                 l = len(self.list)
294                 if l >= 0 and self.top + self.rows * self.cols > l:
295                     self.top = l - self.rows * self.cols + 1
296
297         return self.top != oldtop
298
299     def reconfig(self, w, ev):
300         alloc = w.get_allocation()
301         if alloc.width != self.width or alloc.height != self.height:
302             self.width, self.height = alloc.width, alloc.height
303             self.calc_layout()
304             self.check_scroll()
305             self.queue_draw()
306
307     def set_zoom(self, zoom):
308         if zoom > 50:
309             zoom = 50
310         if zoom < 20:
311             zoom = 20
312         if zoom == self.zoom:
313             return
314         self.zoom = zoom
315         s = pango.SCALE
316         for i in range(zoom):
317             s = s * 11 / 10
318         self.fd.set_absolute_size(s)
319         self.modify_font(self.fd)
320
321         self.calc_layout()
322         self.check_scroll()
323         self.queue_draw()
324
325     def list_changed(self):
326         l = len(self.list)
327         if l >= 0:
328             for i in range(l):
329                 if self.list[i][0] == self.selected_str:
330                     self.selected = i
331                     break
332             if self.selected >= l:
333                 self.selected = None
334             elif self.selected != None:
335                 self.selected_str = self.list[self.selected][0]
336         self.calc_layout()
337         self.check_scroll()
338         self.queue_draw()
339
340     def item_changed(self, ind, task = None):
341         # only changed if it is still 'task'
342         col = (ind - self.top) / self.rows
343         row = (ind - self.top) - (col * self.rows)
344         self.draw_one(row, col, task)
345
346
347     def map_pos(self, x, y):
348         row = int(y / self.lineheight)
349         col = int(x / self.colwidth)
350         ind = row + col * self.rows + self.top
351         l = len(self.list)
352         if l >= 0 and ind >= l:
353             return None
354         if l < 0 and self.list[ind] == None:
355             return None
356         return ind
357
358     def tap(self, x, y):
359         ind = self.map_pos(x,y)
360         if ind != None:
361             self.select(ind)
362
363     def select(self, ind):
364         if self.selected == ind:
365             return
366         old = self.selected
367         self.selected = ind
368         self.selected_str = self.list[ind][0]
369         if self.window == None or self.check_scroll():
370             self.queue_draw()
371         else:
372             col = (ind - self.top) / self.rows
373             row = (ind - self.top) - (col * self.rows)
374             self.draw_one(row, col)
375             if old != None:
376                 col = (old - self.top) / self.rows
377                 row = (old - self.top) - (col * self.rows)
378                 self.draw_one(row, col)
379         if self.callout:
380             self.callout(ind, self.list[ind])
381         self.emit('selected', ind)
382
383 if __name__ == "__main__":
384
385     # demo app using this widget
386     w = gtk.Window(gtk.WINDOW_TOPLEVEL)
387     w.connect("destroy", lambda w: gtk.main_quit())
388     w.set_title("ListSelect Test")
389
390     s = ListSelect()
391     list = [ "zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
392                "nine", "ten", "eleven", "twelve", "thirteen", "forteen"]
393     el = []
394     for a in list:
395         el.append((a, "blue"))
396     el[9] = (el[9][0], ("red",True,True,"black","white"))
397     el[13] = (el[13][0], ("black",False,False,"yellow","white",(4,8)))
398     def sel(n, i):
399         s,f = i
400         print n, s, "selected"
401     s.callout = sel
402
403     s.list = el
404     s.select(12)
405     w.add(s)
406     s.show()
407     w.show()
408
409     def key(c, ev):
410         print "key"
411         if ev.string == '+':
412             s.set_zoom(c.zoom+1)
413         if ev.string == '-':
414             s.set_zoom(c.zoom-1)
415     w.connect("key_press_event", key)
416     w.add_events(gtk.gdk.KEY_PRESS_MASK)
417     #w.set_property('can-focus', True)
418     #s.grab_focus()
419
420     han = 0
421     def tap(c, ev):
422         print "tap", ev.send_event
423         #s.tap(ev.x, ev.y)
424         c.handler_block(han)
425         c.event(ev)
426         c.handler_unblock(han)
427         c.stop_emission("button_press_event")
428
429     han = s.connect("button_press_event", tap)
430     gtk.main()