Manage widget geometry in PyGTK

Explore the considerations in placing and sizing container widgets

Several container widgets exist in GTK+, and with the toolkit's API, you can create user-defined containers. This API is also exposed to PyGTK. In this article, learn how to create a "weighted-table" container in PyGTK. The implementation introduces you to the basic model of GTK+ geometry management and gives you a feel for what to consider and expect when implementing container widgets.

Share:

Yotam Medini (yotam.medini@gmail.com), Software Engineer, Jungo Ltd.

Yotam Medini, authorYotam Medini has worked as software engineer for more than 20 years. He started with Pascal and soon moved to C and C++. For fast solutions and whenever possible, he uses Python. He currently works for Jungo Ltd. in Netanya, Israel. He also does volunteer work teaching young kids Python on Linux (in Hebrew) at Keshet democratic school.



20 May 2008

Also available in Chinese Russian

The GTK+ toolkit and its PyGTK binding package have several convenient containers. For putting a list of widgets together, the horizontal gtk.HBox or the vertical gtk.VBox often suffice. And for aligning in both dimensions, the gtk.Table generally works fine. But in some cases, you might want to achieve nicer-looking results by setting finer requirements.

In general, placing and resizing widgets is not a computation-intensive task, but it may involve various considerations and details. Therefore, creating a manager via interpreted language has the benefit of rapidly trying various methods and settings. The container I describe in this article could encourage PyGTK developers to write or enhance other containers. Alternatively, it could spur the implementation of similar container policies in other GUI toolkits.

Who are the major players?

GTK+ is a highly usable, feature-rich toolkit for creating graphical user interfaces; it boasts cross-platform compatibility and an easy-to-use API. GTK+ it is written in C, but it has bindings to many other popular programming languages such as C++, Python, and C#.

PyGTK is GTK+ for Python—it helps you easily create programs with a graphical user interface using the Python programming language.

Popular alignment widgets include gtk.HBox for the horizontal and gtk.VBox for the vertical.

For layout widgets in both dimensions, consider gtk.Table.

Weighting on a table

Let's look at an implementation of a table-container in which the pixel size of its rows and columns can expand via assigned weights. But before we dive into the details, let's review some examples.

Each of the following three examples has a different set of button children with an attachment policy. Each example is first shown given a small pixel space, followed by a more generous pixel space allowance.

  1. Here we have a single button for which we want to leave exactly 20 pixels on its left and equally share whatever extra horizontal pixel space we get between the button itself and the margin to its right. On the vertical dimension, we distribute extra space in a ratio of (1, 4, 2) corresponding to (top margin, button itself, bottom margin).
    Figure 1. Single button within a small space
    Single button within a small space
    Figure 2. Single button within a bigger space
    Single button within a bigger space
  2. Now we have two buttons that grow in ratios of 2:3 and 1:3 of extra horizontal space.
    Figure 3. Two buttons within a small space
    Two buttons within a small space
    Figure 4. Two buttons within a bigger space
    Two buttons within a bigger space
  3. Next, it's multi-columns requirements where we want the "middle7/8" button to receive 7/8 of possible extra horizontal pixels.
    Figure 5. Multi-columns requirements within a small space
    Multi-columns requirements within a small space
    Figure 6. Multi-columns requirements within a bigger space
    Multi-columns requirements within a bigger space

Interface

The WTable widget is similar to the standard gtk.Table. It derives from gtk.Container. The main differences are:

  • No need to pre-determine the numbers of rows and columns.
  • The attach method of gtk.Table has the optional xoptions=GTK.EXPAND|GTK.FILL, yoptions=GTK.EXPAND|GTK.FILL, xpadding=0, ypadding=0 parameters, while our WTable.attach method has an optional glue parameter that I describe below.
Listing 1. WTable constructor
class WTable(GTK.Container):

    def __init__(self, maxsize=None):
        GTK.Container.__init__(self)
        self.gChildren = []
        self.maxsize = maxsize or (gdk.screen_width(), gdk.screen_height())

By default, self.maxsize in Listing 1 puts virtually no restriction on the width and height of WTable.

Our attach method has the following interface:

Listing 2. WTable.attach interface
def attach(self, widget, leftright=(0,1), topbottom=(0,1), glue=None):

The leftright and topbottom are STL (Standard Template Library)-like half-open ranges for column(s) and row(s) respectably. A range [a,b) or (a,b] is half-open in that it is "open" at one end (doesn't include its limit point) and "closed" at the other (does include its limit point). Describing the glue parameter requires a note about how I'm using the (0,1) indices.

Dimensions and enumerations

The horizontal and vertical dimensions are treated symmetrically. Rather than multiplying code, we use the following dimension enumeration: (X, Y) = (0, 1). We use it in loops, indexed two-sized arrays. We also use similar (0,1) enumeration for left and right, top and bottom margins, and their associated Spring classes (more on that later).

Note that the gtk.Requisition (an object containing information about the desired space requirements of a widget) has the width and height fields, and the allocation is specified by gtk.gdk.Rectangle (an object holding data about a rectangle) that has the x, y, width, and height fields that do not use the convenient indexing.

Attaching with glue

When WTable attaches a widget, a child object is created and added to the WTable's gChildren list. The child has the following class definition:

Listing 3. Child
class Child:
    "Child of WTable. Adoption data"
    def __init__(self, w, leftright=(0,1), topbottom=(0,1), glue=None):
        self.widget = w
        # lrtb: 2x2 tuple:( Cols[begin, end), Rows[Begin, end) )
        self.lrtb = (leftright, topbottom)
        self.glue = glue

It holds the data passed to the WTable attach method.

The Glue class deals with both dimensions. It consists of two one-dimensional Glue1D methods:

Listing 4. Glue
class Glue:
    "2-Dimensional Glue"
    def __init__(self, xglue=None, yglue=None):
        self.xy = (xglue or Glue1D(), yglue or Glue1D())

Each Glue1D consists of a grow weight value and two springs, left and right or top and bottom, depending on the dimension. We refer to these type of values as wGrow.

Listing 5. Glue1D
class Glue1D:
    "1-Dimensional Glue"
    def __init__(self, preSpring=None, postSpring=None, wGrow=1):
        self.springs = (preSpring or Spring(), postSpring or Spring())
        self.wGrow = wGrow # of owning widget

Finally, the Spring class consists of the values shown in the following class constructor.

Listing 6. Spring
class Spring:
    "One side attachment requirement"
    def __init__(self, pad=0, wGrow=0):
        self.pad = pad
        self.wGrow = wGrow

To summarize, a Glue object consists of four Spring objects that affect the margins' sizes. The (2x(1+2)=6) wGrow values play two roles:

  • Globally determining the relative extra pixel space the rows or columns receive
  • For each attached child, locally determining the distribution of extra space between the child and its (2x2=4) margins

For convenience, we have the following "total wGrow" method:

Listing 7. Glue1D.total_grow_weight
def total_grow_weight(self):
    return self.wGrow + self.springs[0].wGrow + self.springs[1].wGrow

Then you return back and present the attach method. (Note that you actually allow the option to conveniently specify a single column and a single row instead of a columns and rows range.

Listing 8. WTable.attach
def attach(self, widget, leftright=(0,1), topbottom=(0,1), glue=None):
    if glue == None:
        glue = Glue()
    # Conveniently change possible single n value to (n,n+1)
    lrtb = map(lambda x: (type(x) == types.IntType) and (x, x+1) or x,
               (leftright, topbottom))
    child = Child(widget, lrtb[0], lrtb[1], glue)
    self.gChildren.append(child)
    if self.flags() & GTK.REALIZED:
        widget.set_parent_window(self.window)
    widget.set_parent(self)
    self.queue_resize()
    return child

Implementation

The model for GTK+ geometry management consists of two phases:

  • Requisition: In this phase, the container traverses its children and queries each for its desired size.
  • Allocation: In this phase, the container is given some sub-rectangle of pixel space and divides it among its children.

Understand that both phases are recursive and that some children may well be containers themselves.

Several foo methods of gtk.Widget (the base class for all PyGTK widgets) internally call the virtual methods do_foo. Currently this is not explicitly documented, but it should be clear from this article and examples listed in the Resources section. Likewise, our implementation overrides some of these virtual do_foo methods.

Requisition

The requisition phase is implemented by the following method:

Listing 9. WTable.do_size_request
def do_size_request(self, oreq):
    reqMgrs = (req.Manager(), req.Manager()) # (X,Y)
    for child in self.gChildren:
        request = child.widget.size_request() # compute!
        for xyi in (X, Y):
            be = child.lrtb[xyi]
            glue1d = child.glue.xy[xyi]
            sz = request[xyi] + glue1d.base()
            rr = req.RangeSize(be, sz)
            reqMgrs[xyi].addRangeReq(rr)
    self.reqs = map(lambda m: m.solve(), reqMgrs) # (X,Y)
    bw2 = 2 * self.border_width
    oreq.width  = min(sum(self.reqs[X]) + bw2, self.maxsize[0])
    oreq.height = min(sum(self.reqs[Y]) + bw2, self.maxsize[1])

Miscellaneous notes

For the sake of presentation, many possible improvements are omitted. For example, parameter validation and Python's standard optional string documentation of methods are omitted.

Similarly, we do not handle user errors gracefully, such as checking invalid ranges.

Glue objects can safely be shared since WTable neither augments them nor alters their values.

The wGrows weight values are mostly used for expanding given extra space. For rare occasions of having deficit pixels, you actually want the elements having larger wGrow to shrink less. Thus, you transform these numbers in decreasing function manner. A smarter Glue class could have explicit shrink weights instead.

It returns values through the oreq.width and oreq.height fields. The reqMgrs objects collect the child size requisitions and then they call the class req.Manager's solve() method. It determines and returns the size to require for each of the columns and rows. For now we are interested just in the sums that are returned via oreq. Note that even though we get the solution of the sizes for each of the columns and rows, the requirements may have been given in ranges of columns and rows. Ranges may consist of more than a single column or a single row.

Allocation

In this phase you get some portion of the precious screen space. The allocation you get is a result of the previously queried requisition and the ancestor containers policies (one of which could be the desktop window manager that may perform a user-interactive resizing of a top-level window).

If the allocation fits the proposed WTable requirements exactly, then you simply distribute it among the WTable's children as solved by the class req.Manager in the previous step. Otherwise, you get extra space or, less likely, a deficit in pixels. In any case, you want to distribute the difference.

The wGrow values of the glue and the widget are now being used as pseudo requirements; you collect and solve them similarly. This time the units are not pixels, but rather relative weights whose relative quotients are used for expanding the columns and rows or inversely shrinking them (see miscellaneous notes).

Listing 10. WTable.do_size_allocate
def do_size_allocate(self, allocation):
    self.allocation = allocation
    allocs = (allocation.width, allocation.height)
    alloc_offsets = (self.border_width, self.border_width)
    self.crSizes = [None, None]  # 2-lists: columns size, rows sizes.
    self.offsets = [None, None]  # alloc_offsets + partial sums of crSizes
    for xyi in (X, Y):
        a = allocs[xyi]
        reqs = self.reqs[xyi]
        gWeights = self._getGrowWeights(xyi)
        self.crSizes[xyi] = given = self._divide(allocs[xyi], reqs, gWeights)
        offsets = len(given) * [None]
        offsets[0] = alloc_offsets[xyi]
        for oi in range(1, len(offsets)):
            offsets[oi] = offsets[oi - 1] + given[oi - 1]
        self.offsets[xyi] = offsets
    for child in self.gChildren:
        self._allocate_child(child)

    if self.flags() & GTK.REALIZED:
        self.window.move_resize(*allocation)

For the steps in Listing 10, the do_size_allocate() method:

  • Determines the size of each segment (column or row) for each dimension. By trivially adding partial sums of these sizes to allocation.x and allocation.y, you get the offsets as well.
  • After the rows' and columns' allocation is determined, you set the allocation for each child. Again note that a child may occupy not necessarily a single "cell" but rather a rectangle made of ranges of columns and rows.

In the end, you call the window.move_resize() method as needed. Note that the allocation parameter is *-prefixed, utilizing the sequencing method provided by the gtk.gdk.Rectangle class. (See the fields-element in define-boxed Rectangle that is used for auto-generating the C-code that defines PyGdkRectangle_Type. This converts the allocation into a tuple matching the move_resize() method of class gtk.gdk.Window.)

Table division of allocation

In the previous requisition phase, the required pixel size for each column and row was determined. Now you need to divide a given total allocation. If it matches the requirements exactly, then you simply use them, otherwise you need to adjust by adding extra pixels or (less likely) subtracting them.

You should split the difference according to weights. Again, the user does not explicitly assign weights to columns and rows but rather assigns wGrow values to each child via a "Glue." You do this to deduce the needed weights with the help of the req.Manager class.

Listing 11. WTable._getGrowWeights
def _getGrowWeights(self, xyi):
    wMgr = req.Manager()
    for child in self.gChildren:
        be = child.lrtb[xyi]
        glue1d = child.glue.xy[xyi]
        rr = req.RangeSize(be, glue1d.total_grow_weight())
        wMgr.addRangeReq(rr)
    wMgr.solve()
    gws = wMgr.reqs
    if sum(gws) == 0:
        gws = len(gws) * [1]  # if zero weights then equalize
    return gws

Given the "cake" allocation (Listing 12), you divide it according to requirements and weights for splitting the excess. Note that in case of a deficit, you transform the weights so that larger weights will suffer less. You also need to take care of the all-zeros weights case, which is considered as all-equal.

Listing 12. WTable._divide
def _divide(self, cake, requirements, growWeights):
    n = len(requirements)   # == len(growWeights)
    given = requirements[:] # start with exact satisfaction
    reqTotal = sum(requirements)
    delta = cake - reqTotal
    if delta < 0: # rarely, "invert" weights
        growWeights = map(lambda x: max(growWeights) - x, growWeights)
        if sum(growWeights) == 0:
            growWeights = n * [1]; # equalize
    i = 0
    gwTotal = sum(growWeights)
    while gwTotal > 0 and delta != 0:
        add = (delta * growWeights[i] + gwTotal/2) / gwTotal
        gwTotal -= growWeights[i]
        given[i] += add
        delta -= add
        i += 1
    return given

Child space allocation

Now that the allocation for the columns and rows is determined and represented by crSizes and offsets, allocating the space for each child is relatively simple. The only consideration you have to deal with is the difference between the supplied allocation for the child and its requirement.

Listing 13. WTable._allocate_child
def _allocate_child(self, child):
    offsetsxy = [None, None]
    req = list( child.widget.get_child_requisition() ) # pre-calculated
    for xyi in (X, Y):
        segRange = child.lrtb[xyi]
        g1d = child.glue.xy[xyi]
        supply = sum( self.crSizes[xyi][segRange[0]: segRange[1]] )
        (oadd, cadd) = g1d.place(req[xyi], supply)
        offsetsxy[xyi] = self.offsets[xyi][ segRange[0] ] + oadd
        req[xyi] += cadd
    allocation = gdk.Rectangle(x=offsetsxy[0], y=offsetsxy[1],
                               width=req[0], height=req[1])
    child.widget.size_allocate(allocation)

In order to determine how much to grow (or shrink) the child and the margins, use the Glue1D.place method. For each child, this method is called twice: once for each (X,Y) dimension. Given a dimension, you have three wGrow values to consider: two of the sides (left and right, or top and bottom) and the child's value itself.

Listing 14. Glue1D.place
def place(self, cneed, supply):
    pads = self.base()
    need = cneed + pads
    delta = supply - need;
    if delta >= 0:
        gwTotal = self.total_grow_weight()
        oadd = self.springs[0].pad
        if gwTotal == 0:
            cadd = delta
        else:
            oadd += round_div(delta * self.springs[0].wGrow, gwTotal)
            cadd = round_div(delta * self.wGrow, gwTotal)
    else: # rare
        shrink = -delta
        if pads >= shrink:  # Cutting from the pads is sufficient
            oadd = round_div(self.springs[0].pad * delta, pads)
            cadd = 0
        else:
            oadd = 0
            cadd = delta    # reduce the child as well
    return (oadd, cadd)

The Glue1D.place method returns a 2-tuple of (oadd,cadd) integers.

  • oadd is to be added to the offset. Equivalently, it means how much the first (left or top) margin will grow.
  • cadd is to be added to the child's widget allocation (width or height).

It uses the already mentioned total_grow_weight() method and the following trivial round_div function (see PEP 238 in Resources):

Listing 15. round_div
def round_div(n, d):
    return (n + d/2) / d

Requirements resolution

Each size requirement of columns we have demands that a range of columns [b,e)—that is all c such that b <= c < e—will have sizes whose sum is at least of some value. A requirement r is represented by the following formula:

Figure 7. Columns sizes requirement
Columns sizes requirement

I want to find "minimal" sizes (Si) that will satisfy the requirement inequalities. This is essentially a linear programming problem. This case is more special than the general problem, since:

  • All factors are 0 or 1.
  • All 1 factors are in a consecutive range of variables.
  • We are interested in an integers-only solution.

Because of these special cases, a minimal solution is likely not unique, so I would also like the solution to be intuitively balanced.

I'll briefly describe the req.Manager class functionality that provides a reasonable solution to the problem. Because this is not the essence of this article and its solution method is suboptimal, I won't elaborate here.

This class lives in a separate req.py module, independent of GTK+. The module has an inside test, via Python's if __name__ == '__main__': construct.

Here are some examples of resolution testings. Each case is given a list of triads (Bi, Ei, Si), meaning that you look for an array of sizes such that each subrange of [Bi, Ei) will sum up to at least Si.

Listing 16. req.Manager solutions examples
python req.py    0 1 20    1 2 30
Solution: [20, 30]

python req.py    0 2 10    1 3 5
Solution: [5, 5, 1]

python req.py    0 2 100    1 3 50
Solution: [46, 54, 6]

python req.py    0 2 100    1 3 50    2 3 20
Solution: [49, 51, 21]

Adding requirements to req.Manager

Here is the constructor of the req.Manager class with the method for adding a requirement.

Listing 17. req.ManageraddRangeReq method
class Manager:
    def __init__(self):
        self.rangeReqs = []
        self.reqs = None

    def addRangeReq(self, rangeReq):
        self.rangeReqs += [rangeReq]

In this, rangeReq is an object of the following class:

Listing 18. req.RangeSize
class RangeSize:
    def __init__(self, be=(0,1), sz=1):
        self.begin = be[0]
        self.end = be[1]
        self.size = sz

Solve via an heuristic approach

An optimal solution would require linear programming techniques with much less trivial code.

Listing 19. req.Manager.solve
def solve(self):
    n = self.nSegments()
    m = len(self.rangeReqs)
    self.reqs = n * [0]
    self.rangeReqs.sort()

    # Satisfy single segment requirements
    dumSingle = RangeSize((n, n+1), 1)
    endSingleIndex = bisect.bisect_right(self.rangeReqs, dumSingle)
    for rr in self.rangeReqs[ : endSingleIndex]:
        curr = self.reqs[rr.begin]
        needMore = rr.size - curr
        self.reqs[rr.begin] += needMore
    bigRangeReqs = self.rangeReqs[endSingleIndex:] # non-single ranges

    self.partialSatisfy(bigRangeReqs, 1, 2) # half
    self.partialSatisfy(bigRangeReqs, 1, 2) # half
    self.partialSatisfy(bigRangeReqs, 1, 1) # complete
    return self.reqs

Listing 19 above includes a solve() method and takes a simple heuristic approach that consists of the following steps:

  1. Sort the requirements.
  2. Satisfy single element requirements for [b,e) ranges such that b+1=e.
  3. For each yet unsatisfied requirement r, add half of the r's unmet size evenly distributed among the elements of r's range. This is done by calls to the req.Manager.partialSatisfy method. Do this step twice.
  4. As in the previous step, now add the entire unmet size.
Listing 20. req.Manager.partialSatisfy
def partialSatisfy(self, bigRangeReqs, rationN, ratioD):
    # Thru reqs, add to satisfy  rationN/ratioD of requirement
    for rr in bigRangeReqs:
        curr = sum(self.reqs[rr.begin: rr.end])
        needMore = rr.size - curr
        if needMore > 0:
            give = (rationN * (needMore + ratioD - 1)) / ratioD
            q, r = divmod(give, rr.end - rr.begin)
            for si in range(rr.begin, rr.end):
                self.reqs[si] += q
            for si in range(rr.begin, rr.begin + r):
                self.reqs[si] += 1

It may be interesting to compare with the GTK+'s gtk_table_size_request C code that has:

Listing 21. Snippet from gtk_table_size_request
GTK_table_size_request_pass1 (table);
GTK_table_size_request_pass2 (table);
GTK_table_size_request_pass3 (table);
GTK_table_size_request_pass2 (table);

and the implementation of these gtk_table_size_request_pass {1,2,3} functions.


Testing

The package presented in this article comes with a test program wtbl-test.py with the following main features:

  • Create a gtk.ToggleButton and Glue via the GUI and use them to call WTable.attach().
  • Modify and delete WTable's children and glues.
  • Load definitions for gtk.ToggleButtons and Glues from a text file; create and attach them. This was used to consistently create the examples under "Weighting on a table" given earlier in this article.
  • Dump current children and their glues to a text file in loadable format.

In addition to the WTable that is edited by the user, the control window of the test program itself uses two instances of WTable with default glues, thus verifying that WTable implementation is global-state free.


Download

DescriptionNameSize
Sample files for this articlewtbl-2008-05-02-1101.tgz35KB

Resources

Learn

Get products and technologies

  • The GTK+ project site has tons of resources for GTK+, a highly usable, feature-rich toolkit for creating graphical user interfaces with cross-platform compatibility and an easy-to-use API.
  • PyGTK is GTK+ for Python; it allows you to easily create programs with a graphical user interface using the Python programming language.
  • With IBM trial software, available for download directly from developerWorks, build your next development project on Linux.

Discuss

Comments

developerWorks: Sign in

Required fields are indicated with an asterisk (*).


Need an IBM ID?
Forgot your IBM ID?


Forgot your password?
Change your password

By clicking Submit, you agree to the developerWorks terms of use.

 


The first time you sign into developerWorks, a profile is created for you. Information in your profile (your name, country/region, and company name) is displayed to the public and will accompany any content you post, unless you opt to hide your company name. You may update your IBM account at any time.

All information submitted is secure.

Choose your display name



The first time you sign in to developerWorks, a profile is created for you, so you need to choose a display name. Your display name accompanies the content you post on developerWorks.

Please choose a display name between 3-31 characters. Your display name must be unique in the developerWorks community and should not be your email address for privacy reasons.

Required fields are indicated with an asterisk (*).

(Must be between 3 – 31 characters.)

By clicking Submit, you agree to the developerWorks terms of use.

 


All information submitted is secure.

Dig deeper into Linux on developerWorks


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=1
Zone=Linux
ArticleID=309564
ArticleTitle=Manage widget geometry in PyGTK
publish-date=05202008