--- /dev/null
+#!/usr/bin/env python
+
+# Copyright (C) 2011 Neil Brown <neilb@suse.de>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+#TODO
+# - centering
+# - test variable-length list
+
+# This module provides the "Select" widget which can be used to
+# selected one item from a list, such as a command, a file, or
+# anything else. Selecting an object should not have any significant
+# effect (though the control of that is outside this module). That is
+# because this widget is intended for a finger-touch display and
+# precision might not be very good - a wrong selection should be
+# easily changed.
+#
+# A scale factor is available (though external controls must be used
+# to change it). With small scale factors, the display might use
+# multiple columns to get more entries on the display.
+#
+# There is no direct control of scolling. Rather the list is
+# automatically scrolled to ensure that the selected item is displayed
+# and is not too close to either end. If the selected item would be
+# near the end, it is scrolled to be near the beginning, and similarly
+# if it is near the beginning, the list is scrolled so that the
+# selected item is near the end of the display
+#
+# However we never display blank space before the list and try to
+# avoid displaying more than one blank space after the list.
+#
+# Each entry is a short text. It can have a number of highlights
+# including:
+# - foreground colour
+# - background colour
+# - underline
+# - leading bullet
+#
+# The text is either centered in the column or left justified
+# (possibly leaving space for a bullet).
+#
+# This widget only provides display of the list and selection.
+# It does not process input events directly. Rather some other
+# module must take events from this window (or elsewhere) and send
+# 'tap' events to this widget as appropriate. They are converted
+# to selections. This allows e.g. a writing-recognition widget
+# to process all input and keep strokes to itself, only sending
+# taps to us.
+#
+# The list of elements is passed as an array. However it could be
+# changed at any time. This widget assumes that it will be told
+# whenever the list changes so it doesn't have to poll the list
+# at all.
+#
+# When the list does change, we try to preserve the currently selected
+# position based on the text of the entry.
+#
+# We support arrays with an apparent size of 0. In this case we
+# don't try to preserve location on a change, and might display
+# more white space at the end of the array (which should appear
+# as containing None).
+#
+# It is possible to ask the "Select" to move to the "next" or
+# "previous" item. This can have a function which tests candidates
+# for suitability.
+#
+# An entry in the list has two parts: the string and the highlight
+# It must look like a tuple: e[0] is the string. e[1] is the highlight
+# The highlight can be just a string, in which case it is a colour name,
+# or a tuple of
+# (colour underline bullet background selected-background (start,len))
+# missing fields default to (black False False grey white)
+# (start,len) are optional. If present, that range of characters is then
+# emboldened.
+# Also a mapping from string to type can be created.
+
+import gtk, pango, gobject
+
+class ListSelect(gtk.DrawingArea):
+ __gsignals__ = {
+ 'selected' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
+ (gobject.TYPE_INT,))
+ }
+ def __init__(self, center = False, linescale = 1, markup = False):
+ gtk.DrawingArea.__init__(self)
+
+ # Index of first entry displayed
+ self.top = 0
+ # Index of currently selected entry
+ self.selected = None
+ # string value of current selection
+ self.selected_str = None
+ self.list = []
+ self.center = center
+ self.linescale = linescale
+ self.markup = markup
+
+ self.fd = self.get_pango_context().get_font_description()
+ # zoom level: 20..50
+ self.zoom = 0
+ self.width = 1
+ self.height = 1
+ self.rows = 1
+ self.cols = 1
+ self.bullet_space = True
+
+ self.format_list = {}
+ self.colours = {}
+ self.to_draw = []
+ self.set_zoom(30)
+ self.connect("expose-event", self.draw)
+ self.connect("configure-event", self.reconfig)
+
+ self.connect_after("button_press_event", self.press)
+ self.add_events(gtk.gdk.BUTTON_PRESS_MASK)
+
+
+ def press(self, c, ev):
+ self.tap(ev.x, ev.y)
+
+ def draw(self, w, ev):
+ # draw any field that is in the area
+ (x,y,w,h) = ev.area
+ for c in range(self.cols):
+ if (c+1) * self.colwidth < x:
+ continue
+ if x + w < c * self.colwidth:
+ break
+ for r in range(self.rows):
+ if (r+1) * self.lineheight < y:
+ continue
+ if y + h < r * self.lineheight:
+ break
+ if (r,c) not in self.to_draw:
+ self.to_draw.append((r,c))
+ if ev.count == 0:
+ for r,c in self.to_draw:
+ self.draw_one(r,c)
+ self.to_draw = []
+
+ def draw_one(self, r, c, task = None):
+ ind = r + c * self.rows + self.top
+ if len(self.list) >= 0 and ind >= len(self.list):
+ val = None
+ else:
+ val = self.list[ind]
+ if task != None and task != val:
+ return
+ if val == None:
+ strng,fmt = "", "blank"
+ else:
+ strng,fmt = val
+ try:
+ val.on_change(self, ind)
+ except AttributeError:
+ pass
+
+ if type(fmt) == str:
+ fmt = self.get_format(fmt)
+
+ if len(fmt) == 5:
+ (col, under, bullet, back, sel) = fmt
+ bold=(0,0)
+ if len(fmt) == 6:
+ (col, under, bullet, back, sel, bold) = fmt
+ # draw background rectangle
+ if ind == self.selected:
+ self.window.draw_rectangle(self.get_colour(sel), True,
+ c*self.colwidth, r*self.lineheight,
+ self.colwidth, self.lineheight)
+ else:
+ self.window.draw_rectangle(self.get_colour(back), True,
+ c*self.colwidth, r*self.lineheight,
+ self.colwidth, self.lineheight)
+ if bullet:
+ w = int(self.lineheight * 0.4)
+ vo = (self.lineheight - w)/2
+ ho = 0
+ self.window.draw_rectangle(self.get_colour(col), True,
+ c*self.colwidth+ho, r*self.lineheight + vo,
+ w, w)
+
+ # draw text
+ if self.markup:
+ layout = self.create_pango_layout("")
+ else:
+ layout = self.create_pango_layout(strng)
+ a = pango.AttrList()
+ if under:
+ a.insert(pango.AttrUnderline(pango.UNDERLINE_SINGLE, 0, len(strng)))
+ if bold[0] < bold[1]:
+ a.insert(pango.AttrWeight(pango.WEIGHT_BOLD,bold[0], bold[1]))
+ layout.set_attributes(a)
+ if self.markup:
+ layout.set_markup(strng)
+
+ offset = self.offset
+ ink, (ex,ey,ew,eh) = layout.get_pixel_extents()
+ if self.center:
+ offset = int((self.colwidth - ew) / 2)
+ if offset < 0:
+ offset = 0
+ voffset = int((self.lineheight - eh) / 2)
+
+ self.window.draw_layout(self.get_colour(col),
+ c*self.colwidth + offset,
+ r*self.lineheight + voffset,
+ layout)
+
+
+ def set_colour(self, name, col):
+ self.colours[name] = col
+ def get_colour(self, col):
+ # col is either a colour name, or a pre-set colour.
+ # so if it isn't in the list, add it
+ if col == None:
+ return self.get_style().bg_gc[gtk.STATE_NORMAL]
+ if col not in self.colours:
+ self.set_colour(col, col)
+ if type(self.colours[col]) == str:
+ gc = self.window.new_gc()
+ gc.set_foreground(self.get_colormap().
+ alloc_color(gtk.gdk.color_parse(self.colours[col])))
+ self.colours[col] = gc;
+ return self.colours[col]
+
+
+ def set_format(self, name, colour, underline=False, bullet=False,
+ background=None, selected="white", bold=(0,0)):
+ self.format_list[name] = (colour, underline, bullet, background, selected, bold)
+
+ def get_format(self, name):
+ if name in self.format_list:
+ return self.format_list[name]
+ if name == "blank":
+ return (None, False, False, None, None)
+ return (name, False, False, None, "white")
+
+ def calc_layout(self):
+ # The zoom or size or list has changed.
+ # We need to calculate lineheight and colwidth
+ # and from those, rows and cols.
+ # If the list is of indefinite length we cannot check the
+ # width of every entry so we just check until we have enough
+ # to fill the page
+
+ i = 0
+ n = len(self.list)
+ indefinite = (n < 0)
+ maxw = 1; maxh = 1;
+ cnt = 0; sumh = 0
+ while n < 0 or i < n:
+ e = self.list[i]
+ if e == None:
+ break
+ strng, fmt = e
+ if self.markup:
+ layout = self.create_pango_layout('')
+ layout.set_markup(strng)
+ else:
+ layout = self.create_pango_layout(strng)
+
+ ink, (ex,ey,ew,eh) = layout.get_pixel_extents()
+ if ew > maxw: maxw = ew
+ if eh > maxh: maxh = eh
+ cnt += 1; sumh += eh
+
+ if indefinite:
+ rs = int(self.height / maxh)
+ cs = int(self.width / maxw)
+ if rs < 1: rs = 1
+ if cs < 1: cs = 1
+ n = self.top + rs * cs
+ i += 1
+
+ real_maxw = maxw
+ if self.bullet_space:
+ maxw = maxw + maxh
+ self.rows = int(self.height / maxh)
+ self.cols = int(self.width / maxw)
+ if self.rows == 0:
+ self.rows = 1
+ if self.cols > int((i + self.rows-1) / self.rows):
+ self.cols = int((i + self.rows-1) / self.rows)
+ if self.cols == 0:
+ self.cols = 1
+ if cnt:
+ avgh = sumh / cnt
+ else:
+ avgh = maxh
+ if avgh * self.linescale > maxh:
+ maxh = avgh * self.linescale
+ self.lineheight = int(maxh)
+ self.colwidth = int(self.width / self.cols)
+ self.offset = (self.colwidth - real_maxw) / 2
+
+ def check_scroll(self):
+ # the top and/or selected have changed, or maybe the layout has
+ # changed.
+ # We need to make sure 'top' is still appropriate.
+ oldtop = self.top
+ if self.selected == None:
+ self.top = 0
+ else:
+ margin = self.rows / 3
+ if margin < 1:
+ margin = 1
+ remainder = self.rows * self.cols - margin
+ if self.selected < self.top + margin:
+ self.top = self.selected - (remainder - 1)
+ if self.top < 0:
+ self.top = 0
+ if self.selected >= self.top + remainder:
+ self.top = self.selected - margin
+ l = len(self.list)
+ if l >= 0 and self.top + self.rows * self.cols > l:
+ self.top = l - self.rows * self.cols + 1
+ if self.top < 0:
+ self.top = 0
+
+ return self.top != oldtop
+
+ def reconfig(self, w, ev):
+ alloc = w.get_allocation()
+ if alloc.width != self.width or alloc.height != self.height:
+ self.width, self.height = alloc.width, alloc.height
+ self.calc_layout()
+ self.check_scroll()
+ self.queue_draw()
+
+ def set_zoom(self, zoom):
+ if zoom > 50:
+ zoom = 50
+ if zoom < 20:
+ zoom = 20
+ if zoom == self.zoom:
+ return
+ self.zoom = zoom
+ s = pango.SCALE
+ for i in range(zoom):
+ s = s * 11 / 10
+ self.fd.set_absolute_size(s)
+ self.modify_font(self.fd)
+
+ self.calc_layout()
+ self.check_scroll()
+ self.queue_draw()
+
+ def list_changed(self):
+ l = len(self.list)
+ if l >= 0 and l < 50000:
+ for i in range(l):
+ if self.list[i][0] == self.selected_str:
+ self.selected = i
+ break
+ if self.selected >= l:
+ self.selected = None
+ elif self.selected != None:
+ self.selected_str = self.list[self.selected][0]
+ self.calc_layout()
+ self.check_scroll()
+ self.queue_draw()
+
+ def item_changed(self, ind, task = None):
+ # only changed if it is still 'task'
+ col = (ind - self.top) / self.rows
+ row = (ind - self.top) - (col * self.rows)
+ self.draw_one(row, col, task)
+
+
+ def map_pos(self, x, y):
+ row = int(y / self.lineheight)
+ col = int(x / self.colwidth)
+ ind = row + col * self.rows + self.top
+ l = len(self.list)
+ if l >= 0 and ind >= l:
+ return None
+ if l < 0 and self.list[ind] == None:
+ return None
+ return ind
+
+ def tap(self, x, y):
+ ind = self.map_pos(x,y)
+ if ind != None:
+ self.select(ind)
+
+ def select(self, ind):
+ if self.selected == ind:
+ self.emit('selected', -1 if ind == None else ind)
+ return
+ if ind == None:
+ self.selected = None
+ self.selected_str = None
+ self.list_changed()
+ self.emit('selected', -1)
+ return
+ old = self.selected
+ self.selected = ind
+ self.selected_str = self.list[ind][0]
+ if self.window == None or self.check_scroll():
+ self.queue_draw()
+ else:
+ col = (ind - self.top) / self.rows
+ row = (ind - self.top) - (col * self.rows)
+ self.draw_one(row, col)
+ if old != None:
+ col = (old - self.top) / self.rows
+ row = (old - self.top) - (col * self.rows)
+ self.draw_one(row, col)
+ self.emit('selected', ind)
+
+if __name__ == "__main__":
+
+ # demo app using this widget
+ w = gtk.Window(gtk.WINDOW_TOPLEVEL)
+ w.connect("destroy", lambda w: gtk.main_quit())
+ w.set_title("ListSelect Test")
+
+ s = ListSelect()
+ list = [ "zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
+ "nine", "ten", "eleven", "twelve", "thirteen", "forteen"]
+ el = []
+ for a in list:
+ el.append((a, "blue"))
+ el[9] = (el[9][0], ("red",True,True,"black","white"))
+ s.set_format("foo",'black',background='yellow',selected='pink', bold=(4,8))
+ el[13] = (el[13][0], "foo")
+ def sel(s, n):
+ print n, s.list[n], "selected"
+ s.connect('selected', sel)
+
+ s.list = el
+ s.select(12)
+ w.add(s)
+ s.show()
+ w.show()
+
+ def key(c, ev):
+ print "key"
+ if ev.string == '+':
+ s.set_zoom(s.zoom+1)
+ if ev.string == '-':
+ s.set_zoom(s.zoom-1)
+ w.connect("key_press_event", key)
+ w.add_events(gtk.gdk.KEY_PRESS_MASK)
+ #w.set_property('can-focus', True)
+ #s.grab_focus()
+
+ han = 0
+ def tap(c, ev):
+ print "tap", ev.send_event
+ #s.tap(ev.x, ev.y)
+ c.handler_block(han)
+ c.event(ev)
+ c.handler_unblock(han)
+ c.stop_emission("button_press_event")
+
+ han = s.connect("button_press_event", tap)
+ gtk.main()