Simple declarative UI layout engine in Python

> Coding, hacking, computer graphics, game dev, and such...
User avatar
fips
Site Admin
Posts: 170
Joined: Wed Nov 12, 2008 9:49 pm
Location: Prague
Contact:

Simple declarative UI layout engine in Python

Post by fips »

Image

When it comes to rapid prototyping, Python is the go-to tool for me these days. It's fully equipped, very expressive and portable across desktop and even mobile platforms/iOS, so it represents a perfect fit for writing all kinds of small tools and applications, especially when productivity counts and raw performance does not matter that much.

Although writing the application back end in Python tends to be pretty straightforward, the real challenge arises when it comes to the UI front end. One can choose between various cross-platform toolkits like TkInter, Qt or wxPython, or use a direct binding to the native UI on the given platform.

For the best user experience, I prefer to use what is native to the target platform with a fallback to a cross-platform UI toolkit for maximum portability. This gives one great flexibility, however, maintaining multiple UI front ends introduces some verbosity, code duplication and mental overhead due to the different UI idioms imposed by the frameworks.

This made me think what would be the best way to help me reduce the amount of UI-specific code to a bare minimum in order to simplify the maintenance and also make the whole experience somewhat more pythonic. So eventually, I've come up with a simple declarative UI layout engine, which allows to define a tree of UI meta elements that then gets transformed into the final layout. The transformation is based on a couple of simple rules (captured in the internal nodes), which control the layout. This can be directly expressed in Python like this:

Code: Select all

Border(margin=Margin(6, 6, 6, 6), children=[
    VStack([
        Border(margin=Margin(0, 0, 0, 4), children=[
            VStack([
                make_button(self, "Question:", stretch=Vec2(1, 0)),
                make_button(self, "<...multi-line-text...>", stretch=Vec2(1, 1)),
                make_button(self, "Hint:", stretch=Vec2(1, 0)),
                make_button(self, "<...multi-line-text...>", stretch=Vec2(1, 1)),
                make_button(self, "Answer:", stretch=Vec2(1, 0)),
                make_button(self, "<...text...>", stretch=Vec2(1, 0)),
            ]),
        ]),
        VStack(stretch=Vec2(1, 0), children=[
            HStack(stretch=Vec2(1, 0), children=[
                make_button(self, "Browse >", stretch=Vec2(1, 0)),
                make_button(self, "Test >", stretch=Vec2(1, 0)),
            ]),
            HStack(stretch=Vec2(1, 0), children=[
                make_button(self, "Graph >", stretch=Vec2(1, 0)),
                make_button(self, "Setup >", stretch=Vec2(1, 0)),
                make_button(self, "About >", stretch=Vec2(1, 0)),
            ]),
        ]),
    ]),
])
Each of the meta elements (leaf nodes) is bound to the actual UI control (QPushButton in the above example) via a simple factory function like this:

Code: Select all

def make_button(parent, title, stretch):
    btn = QPushButton(title, parent)
    return Elem(btn, min_size=Vec2(*btn.size().toTuple()), stretch=stretch)
Besides that, there are no other links between the layout engine and the actual UI framework, which makes the whole thing highly decoupled and pretty compact.

The engine itself (in its current state) uses just two parameters 'min_size' and 'stretch' to control the layout. 'min_size' defines the minimum size under which the element cannot be collapsed, 'stretch' tells whether the element is fixed or flexible in size. So far these two parameters have enabled me to express quite complex layouts so I haven't needed to extend the engine any further, it's still just a proof of concept though, consisting of just a little above 100 lines of code, which is pretty encouraging I would say. Here it is:

VIEW THE CODE BELOW IN FULL-SCREEN (layout.py)

Code: Select all

#!/usr/bin/env python
#[DECLARATIVE UI LAYOUT ENGINE, URL: http://forums.4fips.com/viewtopic.php?f=3&t=6896]
#[CODE BY FIPS @ 4FIPS.COM, (c) 2016 FILIP STOKLAS, MIT-LICENSED]

from collections import namedtuple

Vec2 = namedtuple("Vec2", "x y")
Rect = namedtuple("Rect", "x y w h")
Margin = namedtuple("Margin", "l t r b")

class Layout(object):
    def __init__(self, children, min_size=Vec2(0, 0), stretch=Vec2(1, 1)):
        self.children = children
        self.min_size = min_size
        self.stretch = stretch
        self.exp_size = None # product of expand()
        self.rect = None # product of parent's layout()

    def reshape(self, rect):
        self.expand()
        self.layout(rect)

    def expand(self):
        for ch in self.children:
            ch.expand()
        self.expand_children()

    def layout(self, rect):
        self.layout_children(rect)
        for ch in self.children:
            ch.layout(ch.rect)

    @staticmethod
    def spread(target_size, size_stretch_pairs):
        sizes, stretches = zip(*size_stretch_pairs)
        fixed_size = sum(sz for sz in sizes)
        flexy_size = max(0, target_size - fixed_size)
        num_flexy_parts = sum(st for st in stretches)
        flexy_add = flexy_size // num_flexy_parts if num_flexy_parts else 0
        flexy_adds = [st * flexy_add for st in stretches]
        flexy_err = flexy_size - sum(flexy_adds)
        if flexy_err: # distribute the rounding error introduced by fixed-point division
            flexy_adds[[idx for idx, st in enumerate(stretches) if st][-1]] += flexy_err
        return [sum(sz_fa) for sz_fa in zip(sizes, flexy_adds)]

class Elem(Layout):
    def __init__(self, widget, min_size=Vec2(0, 0), stretch=Vec2(1, 1)):
        super(Elem, self).__init__([], min_size, stretch)
        widget.meta_elem = self
        self.widget = widget

    def expand_children(self):
        self.exp_size = self.min_size

    def layout_children(self, rect):
        pass

class Border(Layout):
    def __init__(self, children, min_size=Vec2(0, 0), stretch=Vec2(1, 1), margin=Margin(0, 0, 0, 0)):
        super(Border, self).__init__(children, min_size, stretch)
        self.margin = margin

    def expand_children(self):
        w, h = self.margin.l + self.margin.r, self.margin.t + self.margin.b
        for ch in self.children:
            w = max(w, ch.exp_size.x)
            h = max(h, ch.exp_size.y)
        self.exp_size = Vec2(w, h)

    def layout_children(self, rect):
        l, t, r, b, = self.margin
        for ch in self.children:
            ch.rect = Rect(rect.x + l, rect.y + t, rect.w - l - r, rect.h - t - b)

class VStack(Layout):
    def __init__(self, children, min_size=Vec2(0, 0), stretch=Vec2(1, 1)):
        super(VStack, self).__init__(children, min_size, stretch)

    def expand_children(self):
        w, h = 0, 0
        for ch in self.children:
            w = max(w, ch.exp_size.x)
            h += ch.exp_size.y
        self.exp_size = Vec2(w, h)

    def layout_children(self, rect):
        x, y = rect.x, rect.y
        ch_heights = Layout.spread(rect.h, [(ch.exp_size.y, ch.stretch.y) for ch in self.children])
        for ch, h in zip(self.children, ch_heights):
            w = rect.w if ch.stretch.x else ch.exp_size.x
            ch.rect = Rect(x, y, w, h)
            y += h

class HStack(Layout):
    def __init__(self, children, min_size=Vec2(0, 0), stretch=Vec2(1, 1)):
        super(HStack, self).__init__(children, min_size, stretch)

    def expand_children(self):
        w, h = 0, 0
        for ch in self.children:
            w += ch.exp_size.x
            h = max(h, ch.exp_size.y)
        self.exp_size = Vec2(w, h)

    def layout_children(self, rect):
        x, y = rect.x, rect.y
        ch_widths = Layout.spread(rect.w, [(ch.exp_size.x, ch.stretch.x) for ch in self.children])
        for ch, w in zip(self.children, ch_widths):
            h = rect.h if ch.stretch.y else ch.exp_size.y
            ch.rect = Rect(x, y, w, h)
            x += w
And of course, full source of the demo follows. The demo is just 65 lines of code and uses PySide as the UI front end, as below:

VIEW THE CODE BELOW IN FULL-SCREEN (layout_demo.py)

Code: Select all

#!/usr/bin/env python
#[DECLARATIVE UI LAYOUT ENGINE, URL: http://forums.4fips.com/viewtopic.php?f=3&t=6896]
#[CODE BY FIPS @ 4FIPS.COM, (c) 2016 FILIP STOKLAS, MIT-LICENSED]

from layout import Vec2, Rect, Margin, Elem, Border, VStack, HStack
import sys

from PySide.QtCore import *
from PySide.QtGui import *

def make_button(parent, title, stretch):
    btn = QPushButton(title, parent)
    return Elem(btn, min_size=Vec2(*btn.size().toTuple()), stretch=stretch)

class MainWindow(QWidget):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        self.setWindowTitle("LAYOUT DEMO BY FIPS @ 4FIPS.COM 2016")
        self.resize(600, 480)
        self.setup()

    def setup(self):
        self.meta_layout = \
        Border(margin=Margin(6, 6, 6, 6), children=[
            VStack([
                Border(margin=Margin(0, 0, 0, 4), children=[
                    VStack([
                        make_button(self, "Question:", stretch=Vec2(1, 0)),
                        make_button(self, "<...multi-line-text...>", stretch=Vec2(1, 1)),
                        make_button(self, "Hint:", stretch=Vec2(1, 0)),
                        make_button(self, "<...multi-line-text...>", stretch=Vec2(1, 1)),
                        make_button(self, "Answer:", stretch=Vec2(1, 0)),
                        make_button(self, "<...text...>", stretch=Vec2(1, 0)),
                    ]),
                ]),
                VStack(stretch=Vec2(1, 0), children=[
                    HStack(stretch=Vec2(1, 0), children=[
                        make_button(self, "Browse >", stretch=Vec2(1, 0)),
                        make_button(self, "Test >", stretch=Vec2(1, 0)),
                    ]),
                    HStack(stretch=Vec2(1, 0), children=[
                        make_button(self, "Graph >", stretch=Vec2(1, 0)),
                        make_button(self, "Setup >", stretch=Vec2(1, 0)),
                        make_button(self, "About >", stretch=Vec2(1, 0)),
                    ]),
                ]),
            ]),
        ])

    def resizeEvent(self, event):
        self.meta_layout.reshape(Rect(0, 0, *event.size().toTuple()))
        for ch in self.children():
            ch.setGeometry(*ch.meta_elem.rect)

def main():
    app = QApplication(sys.argv)
    win = MainWindow()
    win.show()
    win.raise_()
    return app.exec_()

if __name__ == "__main__":
    main()