From 621c673c68cd6978d94734ff894c2bb17af894b4 Mon Sep 17 00:00:00 2001 From: NeilBrown Date: Sat, 21 Apr 2012 20:29:50 +1000 Subject: [PATCH] Add listselect.py listselect is a python/gtk library module for building lists that can be easily selected from using a finger on the touch screen. Signed-off-by: NeilBrown --- lib/listselect.py | 472 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 lib/listselect.py diff --git a/lib/listselect.py b/lib/listselect.py new file mode 100644 index 0000000..b90178e --- /dev/null +++ b/lib/listselect.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python + +# Copyright (C) 2011 Neil Brown +# +# 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() -- 2.39.5