
"""
Pythonic containers

By implementing __getitem__, etc for containers, we make addition and
removal of child widgets from different types of containers more uniform and
a bit more Pythonic.

All containers are treated as one- or two-dimensional arrays.  Addressing is
thus more uniform.  The origin is in the upper left-hand corner.  When
addressing two-dimensional widgets such as Tables, you can use indexes like:

    table[0,2] = child

This says to attach the child to column offset 0 and row offset 2.  If you
want the child widget to span more than one column or one row, use the four
element indexing form:

    table[0,2,3,5] = child

This says to attach the child to column offset 0 on the left, column offset
2 on the right, row offset 3 on the top and row offset 5 on the bottom.

"""                                     # '

# This doesn't do everything but does do most of what you would commonly
# want.

import types
import gtk
import sys

class TableError(Exception):
    pass

class PyTable(gtk.GtkTable):
    """
    Base Class for Pythonic widget containers.

    The PyTable class serves as a base class for other containers that want
    to implement Gtk-based containers that are more Pythonic in their
    interface.  Instead of adding child widgets with calls like

        table.attach_defaults(child, left, left+1, top, top+1)

    you can use

        table[left, top] = child

    For child widgets that span more than one row or column you can use

        table[left, left+colspan, top, top+rowspan] = child

    """

    # self.attachments is a dict mapping index to child widgets.  It's not
    # clear to me that this is the only way to do this, but even if there is
    # some way to query the table for the attachments and extents of its
    # child widgets, it seems easier to just cache that info.

    def __init__(self, rows, columns, homogeneous):
        gtk.GtkTable.__init__(self, rows, columns, homogeneous)
        self.attachments = {}
        self.allow_overlap = 0
        self.rows = rows
        self.columns = columns
        self.row_attachment_policy = gtk.EXPAND|gtk.SHRINK|gtk.FILL
        self.column_attachment_policy = gtk.EXPAND|gtk.SHRINK|gtk.FILL
        self.row_padding = 0
        self.column_padding = 0

    def __getitem__(self, (col, row)):
        if row < 0:
            row += self.get_property("n-rows")
        if col < 0:
            col += self.get_property("n-columns")
        try:
            return self.attachments[(col, row)]
        except KeyError:
            raise IndexError, `(col, row)`, sys.exc_info()[2]

    def __delitem__(self, (col, row)):
        try:
            w = self.attachments[(col, row)]
        except KeyError:
            raise IndexError, `(col, row)`, sys.exc_info()[2]
        else:
            self.remove(w)

    def get(self, (col, row), default=None):
        """Return the child attached at (col, row) or the default value."""
        try:
            return self[(col, row)]
        except IndexError:
            return default

    def __setitem__(self, index, w):
        """Attach w to this table at the given index.

        If index is a two-element tuple, it represents the column and row
        offsets from the upper lefthand corner of the table.  If index is a
        four-element tuple, it represents the left attachment column, right
        attachment column (column extent), top attachment row, and bottom
        attachment row (row extent).  An IndexError is raised for any other
        size tuples.

        """
        if len(index) == 2:
            col, row = index
            colextent = col + 1
            rowextent = row + 1
        elif len(index) == 4:
            col, colextent, row, rowextent = index
            if col >= colextent:
                raise IndexError, ("column extent (%d) must be greater"
                                   " than column (%d)"%(col, colextent))
            if row >= rowextent:
                raise IndexError, ("row extent (%d) must be greater"
                                   " than row (%d)"%(row, rowextent))
        else:
            raise IndexError, ("index must be two-element sequence: %s"%
                               `index`)
        if rowextent > self.rows or colextent > self.columns:
            self.resize(max(self.rows, rowextent),
                        max(self.columns, colextent))
        #print "adding to", (col, colextent, row, rowextent)
        self._attach(w, col, colextent, row, rowextent)

    def extent(self, w):
        """Return extent of w in table.

        Return value is a tuple representing the left, right, top and bottom
        attachments of w.
        """
        left = top = max(self.rows, self.columns)+1
        right = bottom = -1
        for key in self.attachments.keys():
            if self.attachments[key] == w:
                left = min(left, key[0])
                right = max(right, key[0]+1)
                top = min(top, key[1])
                bottom = max(bottom, key[1]+1)
        return (left, right, top, bottom)

    def remove(self, w):
        """Remove w from this container."""
        if w not in self.attachments.values():
            raise ValueError, "not a child of this container"
        keys = [k for k in self.attachments.keys() if self.attachments[k]==w]
        for key in keys:
            del self.attachments[key]
        gtk.GtkTable.remove(self, w)
        
    def location(self, w):
        """Return the attachment point of w in this container."""
        for key in self.attachments.keys():
            if self.attachments[key] == w:
                return key
        raise ValueError, "not a child of this container widget"

    def children(self):
        """Return the child widgets of this container."""
        kids = self.attachments.items()
        kids.sort
        return [v for (k,v) in kids]

    def shrinkwrap(self):
        """Resize this table to it just encloses all its child widgets."""
        ### FIXME - should shift widgets to fill empty space along top and
        ### left sides
        #print "original size:", (self.columns, self.rows)
        widgets = self.attachments.values()
        if widgets:
            w,widgets = widgets[0],widgets[1:]
            left, right, top, bottom = self.extent(w)
            #print (right, bottom)
            seenwidgets = {}
            for w in widgets:
                if seenwidgets.has_key(w):
                    continue
                seenwidgets[w] = 1
                l, r, t, b = self.extent(w)
                #print (w, r, b), "->",
                right = max(right, r)
                bottom = max(bottom, b)
                #print (right, bottom)
        else:
            right = bottom = 1
        if self.columns != right or self.rows != bottom:
            #print "resizing", self, "from", (self.columns, self.rows),
            #print "to", (right, bottom)
            self.resize(bottom, right)
            self.queue_resize()
        #print "final size:", (self.columns, self.rows)
        
    def resize(self, rows, columns):
        """Resize this table to have at least the requested rows and columns.

        GtkTables always maintain their size to enclose all their children,
        so it may have more rows and columns than requested.
        """
        gtk.GtkTable.resize(self, rows, columns)
        # it is not an error in Gtk to resize a table to a size smaller
        # than can accommodate its current children - the GtkTable
        # implementation just adjusts rows and colums to be big enough
        # to hold all the current children - it is simpler to just
        # ask the underlying GtkTable for its current number of rows
        # and columns after the resize
        self.rows = self.get_property("n-rows")
        self.columns = self.get_property("n-columns")

    def _attach(self, w, left_attach, right_attach, top_attach, bottom_attach):
        """Attach w to this table using the specified attachments.

        This is a holdover from a more Gtk-like view of GtkTable.
        Do not call this method from application code.
        """
        for row in range(top_attach, bottom_attach):
            for col in range(left_attach, right_attach):
                if not self.allow_overlap and self.attachments.get((col, row)):
                    raise TableError, "child already at %s" % `(col, row)`
                self.attachments[(col, row)] = w
        gtk.GtkTable.attach(self, w,
                            left_attach, right_attach,
                            top_attach, bottom_attach,
                            self.column_attachment_policy,
                            self.row_attachment_policy,
                            self.column_padding,
                            self.row_padding)
        w.show()

    def attach(self, *args):
        raise NotImplementedError, "Can't call attach directly"

    def attach_defaults(self, *args):
        raise NotImplementedError, "Can't call attach_defaults directly"

    def add(self, w, colspan=1, rowspan=1):
        """Attach w to the first empty slot in this table.

        Empty slots are searched for by columns, then rows.  The child will
        span the specified number of columns and rows.

        """

        ## this method doesn't work quite right when the row/column spans
        ## are > 1. corrections cheerfully accepted.

        if colspan < 1:
            raise ValueError, "column span must be >= 1"
        if rowspan < 1:
            raise ValueError, "row span must be >= 1"

        # make sure the new widget will fit somewhere
        if rowspan > self.rows:
            self.resize(rowspan, self.columns)
        if colspan > self.columns:
            self.resize(self.rows, colspan)

        #print "shape (cols,rows):", (self.columns, self.rows)
        #print "span (cols,rows):", (colspan, rowspan)
        class Extent(Exception): pass
        for row in range(self.rows):
            for col in range(self.columns):
                try:
                    for r in range(row,row+rowspan,1):
                        for c in range(col,col+colspan,1):
                            #print (c,r),
                            if self.get((c,r)):
                                #print "slot filled"
                                raise Extent
                            if r+rowspan>self.rows:
                                #print "too tall"
                                raise Extent
                            if c+colspan>self.columns:
                                #print "too wide"
                                raise Extent
                    #print "adding to", (col, col+colspan, row, row+rowspan)
                    return self._attach(w, col, col+colspan,
                                        row, row+rowspan)
                except Extent:
                    continue
        # no free space - enlarge table
        #print "resizing to", self.rows+rowextent, "rows and",
        #print self.columns, "columns"
        self.resize(self.rows+rowspan, self.columns)
        #print "adding to", (0, colspan, self.rows-1, self.rows-1+rowspan)
        self[0, colspan, self.rows-1, self.rows-1+rowspan] = w
    
    def set_attach_policy(self, col, row):
        """Set attachment properties of children attached to table.

        The col and row values are saved and used for future attachments.
        """
        self.column_attachment_policy = col
        self.row_attachment_policy = row

    def set_padding(self, colpad, rowpad):
        """Set padding values of children attached to table.

        The colpad and rowpad values are saved and used for future attachments.
        """
        self.column_padding = colpad
        self.row_padding = rowpad

class BoxError(TableError):
    pass

class PyBox(PyTable):
    """One-dimension specialization of the PyTable class.

    The programmer can specify an orientation ('horizontal' or 'vertical')
    when the class is instantiated or call the set_orientation method
    to change the orientation of an already created instance.

    In addition to the list-like indexing provided by the PyTable class,
    PyBox instances have an append method that works like the append method
    of Python lists.
    """

    def __init__(self, cells=1, orientation="horizontal",
                 homogeneous=gtk.FALSE):
        if cells <= 0:
            raise BoxError, "Must specify a number of cells > 0"
        if orientation == "horizontal":
            rows = 1
            columns = cells
        elif orientation == "vertical":
            rows = cells
            columns = 1
        else:
            raise BoxError, "Unrecognized orientation: %s" % orientation
        self.orientation = orientation
        PyTable.__init__(self, rows=rows, columns=columns,
                         homogeneous=homogeneous)

    def __setitem__(self, index, val):
        if type(index) == types.TupleType:
            if len(index) == 2:
                cell, extent = index
            else:
                raise IndexError, "invalid subscript: %s"%`index`
        elif type(index) == types.IntType:
            cell = index
            extent = cell+1
        else:
            try:
                index = int(index)
            except ValueError:
                raise IndexError, "invalid subscript: %s"%`index`
                
        if self.orientation == "vertical":
            row = cell
            col = 0
            rowextent = extent
            colextent = 1
        else:
            row = 0
            col = cell
            rowextent = 1
            colextent = extent
        return PyTable.__setitem__(self, (col, colextent, row, rowextent), val)

    def __getitem__(self, cell):
        if self.orientation == "vertical":
            row = cell
            column = 0
        else:
            row = 0
            column = cell
        return PyTable.__getitem__(self, (column, row))

    def __delitem__(self, cell):
        if self.orientation == "vertical":
            row = cell
            column = 0
        else:
            row = 0
            column = cell
        return PyTable.__delitem__(self, (column, row))

    def set_orientation(self, orientation):
        """Set the orientation of the Box instance.

        The orientation must be either 'horizontal' or 'vertical'.  If the
        new orientation is different than the current orientation, the child
        widgets are transposed.
        """

        if self.orientation == orientation:
            return
        if orientation != "horizontal" and orientation != "vertical":
            raise BoxError, "Unrecognized orientation: %s" % orientation
        self.orientation == orientation
        for col, row in self.attachments.keys():
            w = PyTable.__getitem__(self, (col, row))
            PyTable.__delitem__(self, (col, row))
            PyTable.__setitem__(self, (row, col), w)
        self.shrinkwrap()

    def get_orientation(self):
        return self.orientation

    def append(self, w):
        keys = self.attachments.keys()
        if self.orientation == "horizontal":
            if keys:
                cell = max([k[0] for k in keys])+1
            else:
                cell = 0
        else:
            if keys:
                cell = max([k[1] for k in keys])+1
            else:
                cell = 0
        self[cell] = w

class _Test_App(gtk.GtkWindow):
    def __init__(self):
        gtk.GtkWindow.__init__(self)
        self.set_title("table example")
        self.connect("destroy", gtk.mainquit)

        vbox = PyBox(orientation="vertical", homogeneous=gtk.FALSE)
        self.add(vbox)

        self.extbox = PyBox(orientation="horizontal", homogeneous=gtk.TRUE)
        vbox.append(self.extbox)

        ## these two scales don't work the way I think they should
        ## can't they be constrained to integer values?
        f = gtk.GtkFrame(label="column extent")
        self.extbox.append(f)
        adj = gtk.GtkAdjustment(1,1,6,1,1,1)
        self.ce = gtk.GtkHScale(adj)
        self.ce.set_digits(0)
        f.add(self.ce)

        f = gtk.GtkFrame(label="row extent")
        self.extbox.append(f)
        adj = gtk.GtkAdjustment(1,1,6,1,1,1)
        self.re = gtk.GtkHScale(adj)
        self.re.set_digits(0)
        f.add(self.re)

        hbox = PyBox(orientation="horizontal", homogeneous=gtk.TRUE)
        vbox.append(hbox)

        self.table = PyTable(4, 4, gtk.FALSE)

        b = gtk.GtkButton(label="add widget")
        b.connect("clicked", self.add_widget)
        hbox.append(b)

        b = gtk.GtkButton(label="add column")
        b.connect("clicked", self.add_column)
        hbox.append(b)

        b = gtk.GtkButton(label="add row")
        b.connect("clicked", self.add_row)
        hbox.append(b)

        b = gtk.GtkButton(label="set homogeneous")
        b.connect("clicked", self.toggle)
        hbox.append(b)

        b = gtk.GtkButton(label="shrink wrap")
        b.connect("clicked", self.shrink)
        hbox.append(b)

        b = gtk.GtkButton(label="flip extent display")
        b.connect("clicked", self.orient_cb)
        hbox.append(b)

        vbox.append(self.table)

        for r in range(4):
            for c in range(0,4,2):
                b = gtk.GtkButton(label="delete %s" % `(c,r)`)
                self.table[c,c+2,r,r+1] = b
                b.connect("clicked", self.delete, (c, r))

        b = gtk.GtkButton(label="quit")
        b.connect("clicked", gtk.mainquit)
        vbox.append(b)

    def orient_cb(self, b):
        o = self.extbox.get_orientation()
        self.extbox.set_orientation(o == "horizontal" and "vertical" or
                                    "horizontal")

    def delete(self, b, (col, row)):
        del self.table[(col, row)]

    def shrink(self, b):
        """compress out empty rows and columns from the bottom/right edges"""
        self.table.shrinkwrap()

    def add_widget(self, b):
        """locate an empty spot in the table and add a new widget there"""
        b = gtk.GtkButton(label="")
        self.table.add(b, int(self.ce.get_value()),
                       int(self.re.get_value()))
        (left, right, top, bottom) = self.table.extent(b)
        b.connect("clicked", self.delete, (left, top))
        b.set_property("label", "delete %s" % `(left, top)`)

    def add_row(self, b):
        """add a new empty row to the bottom edge of the table"""
        self.table.resize(self.table.rows+1,
                          self.table.columns)
        self.table.queue_resize()

    def add_column(self, b):
        """add a new empty column to the right edge of the table"""
        self.table.resize(self.table.rows,
                          self.table.columns+1)
        self.table.queue_resize()

    ## when moving from homogeneous to non-homogeneneous table display
    ## the button's label doesn't redraw properly until something else
    ## happens to the user interface - I suppose there's some way to
    ## force updates

    def toggle(self, b):
        h = self.table.get_property("homogeneous")
        h = not h
        self.table.set_homogeneous(h)
        if h:
            b.set_property("label", "set non-homogeneous")
        else:
            b.set_property("label", "set homogeneous")
        self.table.queue_resize()

if __name__ == "__main__":
    app = _Test_App()
    app.show_all()
    gtk.mainloop()

