]> git.neil.brown.name Git - plato.git/commitdiff
Add listselect.py
authorNeilBrown <neilb@suse.de>
Sat, 21 Apr 2012 10:29:50 +0000 (20:29 +1000)
committerNeilBrown <neilb@suse.de>
Sat, 21 Apr 2012 10:29:50 +0000 (20:29 +1000)
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 <neilb@suse.de>
lib/listselect.py [new file with mode: 0644]

diff --git a/lib/listselect.py b/lib/listselect.py
new file mode 100644 (file)
index 0000000..b90178e
--- /dev/null
@@ -0,0 +1,472 @@
+#!/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()