Olarn's DevBlog
Writing Qt UI layout in pymel's 'with layout' style.
Sorry, failed to load main article image
Bantukul Olarn
    
Nov 26 2017 01:04
  
Extending and modifying Qt's layout class, making UI object constructible using pymel's 'with' style.


Goal & assumptions: 

Construct nested Qt layout with only 'with' statement:

In with statement's exit(), automatically connect the the (new) local variable in the previous scope (the code section contained within that specific with statement) and bind them to layout. Additionally, if the with statement were declared in a class, bind the local variables and the layout itself to the owner instance.

  • For demonstration, only vertical and horizontal layout will be implemented.
  • It should also be possible to collect newly create bound variables in addition to just the local variables, but would not be included in this demo.
  • It also should be possible to implement some mechanism for with statement reuse, but this is not implemented in this demonstration.
  • because of how with exit() were called, layout object must have a parent or it would be collected by the CG

Original Pymel UI Code
pymel with statement ui example

Pymel (cmds) UI

from __future__ import print_function, absolute_import, division

from pymel.core import *

def main():

    with window(menuBar=True, menuBarVisible=True,) as win:
        # start the template block
        # with template:
        with columnLayout(adjustableColumn=True):
            button(label='One')
            button(label='Two')
            with columnLayout(adjustableColumn=True):
                button(label='sub1V1')
                with horizontalLayout():
                    button(label='sub2H1')
                    button(label='sub2H2')
            button(label='Three')
            with horizontalLayout():
                button(label='sub3H1')
                button(label='sub3H2')
                with columnLayout(adjustableColumn=True):
                    button(label='sub4H1')
                    button(label='sub4H2')
            button(label='Four')

main()
Sample UI boilerplates
pyside2 empty window

Empty skeletal UI

Minimal standalone Pyside2 window
from __future__ import absolute_import, division, print_function
import sys
from PySide2 import QtWidgets, QtGui, QtCore

class _MinimalPySide2Window(object):
    def __init__(self):
        self.qapp = QtWidgets.QApplication(sys.argv)

        self.win = QtWidgets.QDialog(
            windowTitle="Example!",
            objectName="exampleLayoutDialog"
        )

    def show(self):
        self.win.show()
        sys.exit(self.qapp.exec_())


demo = _MinimalPySide2Window()
demo.show()
Minimal Maya PySide2 Window
from __future__ import absolute_import, division, print_function

from PySide2 import QtWidgets, QtGui, QtCore
import maya.OpenMayaUI as omui
import shiboken2

def maya_window_kudasai():
    return shiboken2.wrapInstance(
        long(omui.MQtUtil.mainWindow()),
        QtWidgets.QWidget
    )

class _MinimalPySide2Window(object):
    def __init__(self):
        self.win = QtWidgets.QDialog(
            parent=maya_window_kudasai(),
            windowTitle="Example!",
            objectName="exampleLayoutDialog"
        )

    def show(self):
        self.win.show()

demo = _MinimalPySide2Window()
demo.show()

How does it work?

The first obvious mechanism to implement are the means of accessing local variable declared in the previous stack (with statement enter() and exit() runs one stack above the with block's code, similar to a function call). After some playing around I decided to do this using by using inspection to manually grab the previous stack's frame.

from __future__ import absolute_import, division, print_function
import inspect, pprint

def afunction():
    stack = inspect.stack()
    previous_frame = stack[1][0]

    print("All stacks")
    pprint.pprint(stack)

    print("Previous frame")
    pprint.pprint(previous_frame)

    print("Previous frame locals")
    pprint.pprint(previous_frame.f_locals)

def containingfunction():

    # Target local variable
    local_a = 0
    local_b = 1
    local_c = "aaaa"

    # With statement's exit() statement equivalent
    afunction()

containingfunction()

"""
example output:

All stacks
[(<frame object at 0x000001CE9BD41200>,
  'fol/config/scratches/scratch_5.py',
  5,
  'afunction',
  ['    stack = inspect.stack()\n'],
  0),
 (<frame object at 0x000001CE9BD41048>,
  'fol/config/scratches/scratch_5.py',
  25,
  'containingfunction',
  ['    afunction()\n'],
  0),
 (<frame object at 0x000001CE9980EA38>,
  'fol/config/scratches/scratch_5.py',
  27,
  '<module>',
  ['containingfunction()\n'],
  0)]
Previous frame
<frame object at 0x000001CE9BD41048>
Previous frame locals
{'local_a': 0, 'local_b': 1, 'local_c': 'aaaa'}

"""

Keeping track of nested with block's scope:

Problem #2 encountered was how to deal with the fact that nested with block actually runs in the same scope. After some more experimentation, I decided to use a couple of global variable described below to accomplish this.

g_with_parent_ctx_stack = []

Since nested with's enter/exit() were call in order, a global list variable used as stack to keep track of logical 'with' stack.

g_ext_ctx = set()

Another auxiliary global set variable are used to keep track of all parent context's variable already seen and local variable processed in child with block. In other words, this variable represents the currently active logical 'with' by recording every local variable that should be outside of the current active block.


This variable would be updated and maintained at every block's enter() and exit()


Dynamically adding generic widget object to layout:

This is simple. Determine whether the collected variable is a derived class of QWidget or QLayout and use the appropriate method to add it to current layout.

# 'self' is a layout object
    def _auto_add(self, obj):
        if issubclass(type(obj), QtWidgets.QWidget):
            self.addWidget(obj)
        elif issubclass(type(obj), QtWidgets.QLayout):
            self.addLayout(obj)
        else:
            raise NotImplementedError(self, obj)
Mini demonstration of operation used on python set type.
#encoding: utf8
from pprint import pprint

dict_from_dict_comprehension = {key:val for key, val in [
    (0,1),(3,4),(5,6)
]}

pprint(dict_from_dict_comprehension)
# {0: 1, 3: 4, 5: 6}

# set converted from dict only contains key
# Dictでセットを生成されるとKey値のセットになる
set_from_dict= set(dict_from_dict_comprehension)
pprint(set_from_dict)
# set([0, 3, 5])

# set comprehension (Not dict comprehension!)
# ↓DictではなくSetタイプ
set_from_comprehension = {val for val in [0,1,2,3,4]}
pprint(set_from_comprehension)
# set([0, 1, 2, 3, 4])

del dict_from_dict_comprehension, set_from_dict, set_from_comprehension

set_a = set([1,2,3])
set_b = set([3,4,5])
set_c = set([5,6,7,8])

# UNION
pprint( set_a | set_b )
# set([1, 2, 3, 4, 5])
pprint( set_a | set_b | set_c )
# set([1, 2, 3, 4, 5, 6, 7, 8])

# DIFFERENCE l
pprint( set_a - set_b )
# set([1, 2])


# DIFFERENCE r
pprint( set_b - set_a)
# set([4, 5])


# INTERSECT
pprint( set_a & set_b )
# set([3])

# CHAINED
pprint(set_c & set_b | set_a)
# set([1, 2, 3, 5])

Recovering local variable declaration order from the various stack

Since f_locals were dictionary they're obviously(in retrospect..) the keys were unordered, therefore declaration order are lost through the stack.  This information is critical to this implementation since UI widget position are dictated by insertion order. Fortunately code object inside previous python frame contains a variable called co_varnames that preserves declaration order so ordering was reconstructed based on this variable.

Final working code

# -*- coding: utf-8 -*-

# !/usr/bin/env python
# coding=utf-8

from __future__ import absolute_import, division, print_function

import inspect

import maya.OpenMayaUI as omui
import shiboken2
from PySide2 import QtWidgets, QtGui, QtCore

import sys

g_with_parent_ctx_stack = []
g_ext_ctx = set()


def maya_window_kudasai():
    return shiboken2.wrapInstance(
        long(omui.MQtUtil.mainWindow()),
        QtWidgets.QWidget
    )


def previous_locals(stack):
    # return deep copy to ensure original stack aren't disturbed
    return stack[1][0].f_locals


class AutoScopedLayoutMixin(object):
    def _auto_add(self, obj):
        if issubclass(type(obj), QtWidgets.QWidget):
            self.addWidget(obj)
        elif issubclass(type(obj), QtWidgets.QLayout):
            self.addLayout(obj)
        else:
            raise NotImplementedError(self, obj)

    def __enter__(self, *args):
        global g_with_parent_ctx_stack, g_ext_ctx

        raw_locals = previous_locals(inspect.stack())

        owner_instance = raw_locals.get('self', None)

        # records owner instace's __dict__ state
        self._owner_instance_dict = set(owner_instance.__dict__.keys())

        _ctx = set(raw_locals) - g_ext_ctx
        g_with_parent_ctx_stack.append(_ctx)

        # local parent context recorded, add current locals to ext context
        g_ext_ctx |= _ctx

        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        global g_with_parent_ctx_stack, g_ext_ctx

        raw_stack = inspect.stack()
        raw_locals = previous_locals(raw_stack)

        # get owner instace, if any
        owner_instance = raw_locals.get('self', None)

        # different between previous local stack at enter and exit
        new_exit_key = {key for key in set(raw_locals.keys()) - g_ext_ctx if not raw_locals[key] == self}

        # last stack -> frame -> internals -> f_code -> co_names
        co_varnames = raw_stack[1][0].f_code.co_varnames
        # print(self, co_varnames)

        # rearrange new keys according to last stack's co_varname order
        reordered_new_exit_key = [co_vn for co_vn in co_varnames if co_vn in new_exit_key]

        for n in reordered_new_exit_key:
            if n == 'self':
                continue

            if owner_instance:
                # add to parent object if context is an object
                # print("self in prev locals, add to self", self, n, owner_instance, raw_locals)
                setattr(owner_instance, n, raw_locals[n])

            item = raw_locals[n]
            # add to layout
            self._auto_add(item)

        # should corresponds to correct enter-exit pair since it behave like stacks..
        parent_ctx = g_with_parent_ctx_stack.pop()
        # make parent context active again (for parent layout to use)
        g_ext_ctx = g_ext_ctx - parent_ctx
        # push processed keys out of scope
        g_ext_ctx |= new_exit_key
        # g_oi_ext_ctx |= new_oi_exit_key

        # reset ext context on last exit
        if not g_with_parent_ctx_stack:
            # print("resetting ext context")
            g_ext_ctx = set()

            # add root layout in last stack
            if owner_instance:
                for key in raw_locals:
                    if raw_locals[key] == self:
                        setattr(owner_instance, key, self)


class AutoScopedQVBoxLayout(QtWidgets.QVBoxLayout, AutoScopedLayoutMixin):
    pass


class AutoScopedQHBoxLayout(QtWidgets.QHBoxLayout, AutoScopedLayoutMixin):
    pass


MAX_STRETCH_SIZE_POLICY = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
def button(text):
    return QtWidgets.QPushButton(sizePolicy=MAX_STRETCH_SIZE_POLICY, text=text)


class _AutoScopedLayoutDemo(object):
    def __init__(self):
        self.qapp = QtWidgets.QApplication(sys.argv)

        self.win = QtWidgets.QDialog(
            # parent=maya_window_kudasai(),
            windowTitle="Example!",
            objectName="exampleLayoutDialog"
        )

        ####

        with AutoScopedQVBoxLayout(self.win) as root_layout:
            # btn1 = button("One")
            btn1 = button("One")
            btn2 = button("Two")
            with AutoScopedQVBoxLayout(self.win) as layout2:
                s1_btn1 = button("sub1V1")
                with AutoScopedQHBoxLayout(self.win) as sub2hlayout:
                    s2_btn1 = button("sub2H1")
                    s2_btn2 = button("sub2H2")
            btn3 = button("Three")
            with AutoScopedQHBoxLayout(self.win) as sub3hlayout:
                s3_btn1 = button("sub3H1")
                s3_btn2 = button("sub3H2")
                with AutoScopedQVBoxLayout(self.win) as vlayout3:
                    s4_btn1 = button("sub4H1")
                    s4_btn2 = button("sub4H2")
            btn4 = button("Four")

    def show(self):
        self.win.show()
        sys.exit(self.qapp.exec_())

demo = _AutoScopedLayoutDemo()
demo.show()
Final result
pyside2 with statement final result

Final result

Just to show that local variable has been added to __dict__
pprint output of self.__dict__ after self.win.show():

{'btn1': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB35688>,
 'btn2': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB35EC8>,
 'btn3': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB359C8>,
 'btn4': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB3A1C8>,
 'layout2': <sc_mayapyqtexample.AutoScopedQVBoxLayout object at 0x0000022B0CB35748>,
 'qapp': <PySide2.QtWidgets.QApplication object at 0x0000022B0CB315C8>,
 'root_layout': <sc_mayapyqtexample.AutoScopedQVBoxLayout object at 0x0000022B0CB31688>,
 's1_btn1': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB35808>,
 's2_btn1': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB35708>,
 's2_btn2': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB35948>,
 's3_btn1': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB35B08>,
 's3_btn2': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB35CC8>,
 's4_btn1': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB35C48>,
 's4_btn2': <PySide2.QtWidgets.QPushButton object at 0x0000022B0CB35D48>,
 'sub2hlayout': <sc_mayapyqtexample.AutoScopedQHBoxLayout object at 0x0000022B0CB35908>,
 'sub3hlayout': <sc_mayapyqtexample.AutoScopedQHBoxLayout object at 0x0000022B0CB35A88>,
 'vlayout3': <sc_mayapyqtexample.AutoScopedQVBoxLayout object at 0x0000022B0CB35BC8>,
 'win': <PySide2.QtWidgets.QDialog object at 0x0000022B0CB31608>}
This demonstration has show that, with some effort, it's possible to write PySide/PyQt UI in pymel's 'with' syntax. (although I would not recommend using this in your production code!)

Tags: