Friday, August 31, 2018

A new PyQt post: Retaining widget shape

Just for fun I'm working on a Tetris game. I based this on the code originally published by Jan Bodnar, cleaning it up and commenting the living bejeezus out of it.

My code is here on Github. The file standard.py is playable, but it has one big flaw. If you start it up and then drag on the corner of the window, the tetris pieces become misshapen, distorted.

So how can I make (Py)Qt5 retain the aspect ratio of a widget while still allowing the user to stretch or shrink its container?

Not surprisingly, I am not the first to wonder this. (It would come up when coding any kind of game, I should think.) Search the Qt General Forum on the string "heightForWidth" (which is the method name that turns up in most answers to this issue) and you'll find several postings from as long as seven years ago, and as recently as one year. A more general search turns up Stack Overflow posts. Almost all the proposed solutions are wrong. However, the solution offered in this S.O. post has code that works.

Most of the solutions say that to make a widget keep its aspect ratio, you

  • Give it an explicit SizePolicy in which
  • you call setHeightForWidth(True) and
  • in the widget itself, override the hasHeightForWidth() and heightForWidth() methods:
    def hasHeightForWidth(self):
        return True
    def heightForWidth(self, width):
        return width

Except some posts say the widget has to be in a layout (e.g. QVBoxLayout), and others seem to say that you have to implement a custom version of a box layout yourself. I've got a simple test case right here in which I implemented all those things in every combination. You're welcome to play with it.

Bottom line is, no heightForWidth() method is ever called, that I could find. The whole approach probably works for some combination of options—it certainly appears to have been devised to do this—but it flat doesn't work in any combination that I could devise.

Like I said, download that test code and see if you can find the magic.

What does work is this: in the widget class that you want to always be square, implement the resizeEvent() handler. In it, check the new size of the widget and adjust its contentsMargins appropriately to compensate. Thus:

    def resizeEvent(self, event):
        # setContentsMargins(left,top,right,bottom)
        d = self.width()-self.height()
        if d : # is not zero,
            mod1 = abs(d)//2
            mod2 = abs(d)-mod1
            if d > 0 : # width is greater, reduce it
                self.setContentsMargins(mod1,0,mod2,0)
            else : # height is greater, reduce it
                self.setContentsMargins(0,mod1,0,mod2)
        super().resizeEvent(event)

The same code, with a little more math, could be used to maintain some other aspect ratio than a square.