Responsive layouts with Cassowary

12 Mar 2019

This is Part 1. Part 2 shows how to use Cassowary with wxPython. Part 3 shows how to handle widget collisions.

Designing user interfaces that look good on different screen sizes and at different resolutions can be a challenge. The Cassowary layout algorithm is easy to use and can handle layouts of any complexity.

Layout options

Fixed

The 1990’s way of doing layouts: you manually position each widget by specifying its x,y coordinates. Resizing the window or even displaying in a different resolution usually wrecks the layout.

Springs & Struts

Used by pre-Lion Apple until the advent of Auto Layout (which uses Cassowary).

Sizers

Boxes inside boxes inside boxes: it works but isn’t pretty and is hard to visualize (you spend a lot of time debugging your layout). Used by GTK, wxPython, and Tkinter.

Cassowary

It would be easier and more intuitive if we could create our layout the way we sketch it on paper, by describing how each widget relates to the others: “The textbox should resize with the parent frame. The two buttons should stay aligned with the vertical edges of the textbox.” That sort of thing.

Cassowary works by feeding it a list of requirements or contraints in the form of linear equations. In our code example below when the frame is resized, Cassowary recalculates the position and size of each widget on the frame based on the contraints you fed it.

Use as few contraints as possible. The goal is to feed Cassowary a series of linear equations with only one solution.

A simple layout

Let’s create a simple layout with a textbox and two buttons.

A simple UI example

The Python script below uses the kiwisolver package which uses the Cassowary algorithm. To confuse matters there is a cassowary package but I don’t recommend using it: the version is 0.51 and isn’t under active development.

For simplicity the code doesn’t handle height, only width.

  1. Create a frame, a textbox, and two buttons.
  2. Create a Solver() object and feed it a list of constraints for our layout.
    1. The left edge of the textbox is 50 pixels from the left edge of the frame.
    2. The textbox width is 100 pixels less than the frame width (50 pixel margin on each side.)
    3. Both buttons are 25 pixels wide.
    4. The left edge of Button1 aligns with the left edge of the textbox.
    5. The right edge of Button2 aligns with the right edge of the textbox.
  3. Give suggestValue() a new frame width to simulate resizing the frame.
  4. Call updateVariables() to recalculate the new layout.

Python code

import kiwisolver

class Widget(object):
    def __init__(self, identifier):
        self.x = kiwisolver.Variable('x-' + identifier)
        self.width = kiwisolver.Variable('width-' + identifier)

if __name__ == '__main__':
    frame = Widget('frame')
    textbox = Widget('textbox')
    button1 = Widget('button1')
    button2 = Widget('button2')

    constraints = [
        textbox.x == 50,
        textbox.width == frame.width - 100,
        button1.width == 25,
        button2.width == 25,
        button1.x == textbox.x,
        button2.x == textbox.x + textbox.width - button2.width
    ]

    solver = kiwisolver.Solver()
    for constraint in constraints:
        solver.addConstraint(constraint)

    solver.addEditVariable(frame.width, 'strong')

    frame_widths = [200, 250, 300, 350, 400]

    for val in frame_widths:
        solver.suggestValue(frame.width, val)
        solver.updateVariables()
        print('frame width: %s' %frame.width.value())
        print('textbox width: %s, x: %s' %(textbox.width.value(), textbox.x.value()))
        print('button1 width: %s, x: %s' %(button1.width.value(), button1.x.value()))
        print('button2 width: %s, x: %s' %(button2.width.value(), button2.x.value()))
        print()