qtpyqticonsqtoolbutton

How to center QToolButton icon?


when I connect a Qt QToolButton to a QAction that has an icon assigned to it, the icon shows in the QToolButton, but the icon is off-center and misaligned, see the image below:

Icons are clearly shifted a few pixels to the left

I am setting up the buttons like this (Python code):

self.actionExpand_All.setIcon(QIcon("icons_16:arrow-out.png"))
self.actionCollapse_All.setIcon(QIcon("icons_16:arrow-in.png"))

self.toolButton_expandAll.setDefaultAction(self.actionExpand_All)
self.toolButton_collapseAll.setDefaultAction(self.actionCollapse_All)

The icons come from the Fugue set and are 16x16 pngs. The toolButtonStyle is set to 'ToolButtonIconOnly'. The QActions and the QToolButtons are defined via Qt Designer in a .ui file which I convert to Python via pyuic command. I am using PyQt 6.4.

I googled but could not find any solution, only mention of this problem from 2017 on StackExchange had some suggestions but none worked. I also tried centering the icon myself via QToolButton stylesheet fields such as 'margin' and 'padding' but to no avail. I would be happy with making the QToolButton a bit bigger to center the icon but the QToolButton size seems to 'automatically' fit the icon and is not controlled from Qt Designer.

Thanks


Solution

  • For some reason, Qt developers chose to return sizes that may result in odd numbers for the size hint of QToolButton.

    To understand that, we need to remember that Qt uses QStyle for many aspects, including indirect size management: many widgets query the current style() of the widget in order to compute correct size requirements for their contents, by calling styleFromContents().

    Almost all styles use a default QCommonStyle as basis for many aspects, and here we have the first issue.

    According to the sources, QCommonStyle does that when sizeFromContents() is called along with CT_ToolButton:

    QSize QCommonStyle::sizeFromContents(ContentsType ct, const QStyleOption *opt,
                                         const QSize &csz, const QWidget *widget) const
    {
        Q_D(const QCommonStyle);
        QSize sz(csz);
        switch (ct) {
    
        # ...
    
        case CT_ToolButton:
            sz = QSize(sz.width() + 6, sz.height() + 5);
    
        # ...
    
        }
        return sz;
    }
    

    As you can see, we already have a problem: assuming that the given csz is based on the icon size alone (which usually has even values, usually 16x16), we will get a final hint with an odd value for the height (eg. 22x21).

    This happens even in styles that don't rely on QCommonStyle, which is the case of the "Windows" style:

        case CT_ToolButton:
            if (qstyleoption_cast<const QStyleOptionToolButton *>(opt))
                return sz += QSize(7, 6);
    

    Which seems partially consistent with your case, with the button border occupying 21 pixels: I suppose that the "missing" 2 pixels (16 + 7 = 23) are used as a margin, or for the "outset" border shown when the button is hovered.

    Now, there are various possible solutions, depending on your needs.

    Subclass QToolButton

    If you explicitly need to use QToolButton, you can use a subclass that will "correct" the size hint:

    class ToolButton(QToolButton):
        def sizeHint(self):
            hint = super().sizeHint()
            if hint.width() & 1:
                hint.setWidth(hint.width() + 1)
            if hint.height() & 1:
                hint.setHeight(hint.height() + 1)
            return hint
    

    This is the simplest solution, but it only works for QToolButtons created in python: it will not work for buttons of QToolBar.

    You could even make it a default behavior without subclassing, with a little hack that uses some "monkey patching":

    def toolButtonSizeHint(btn):
        hint = btn.__sizeHint(btn)
        if hint.width() & 1:
            hint.setWidth(hint.width() + 1)
        if hint.height() & 1:
            hint.setHeight(hint.height() + 1)
        return hint
    QToolButton.__sizeHint = QToolButton.sizeHint
    QToolButton.sizeHint = toolButtonSizeHint
    

    Note that the above code must be put as soon as possible in your script (possibly, in the main script, right after importing Qt), and should be used with extreme care, as "monkey patching" using class methods may result in unexpected behavior, and may be difficult to debug.

    Use a proxy style

    QProxyStyle works as an "intermediary" within the current style and a custom implementation, which potentially overrides the default "base style". You can create a QProxyStyle subclass and override sizeFromContents():

    class ToolButtonStyle(QProxyStyle):
        def sizeFromContents(self, ct, opt, csz, w):
            size = super().sizeFromContents(ct, opt, csz, w)
            if ct == QStyle.CT_ToolButton:
                w = size.width()
                if w & 1:
                    size.setWidth(w + 1)
                h = size.height()
                if h & 1:
                    size.setHeight(h + 1)
            return size
    
    # ...
    if __name__ == "__main__":
        app = QApplication(sys.argv)
        app.setStyle(ToolButtonStyle())
    

    This has the benefit of working for any QToolButton, including those internally created for QToolBar. But it's not perfect:

    Subclass QToolBar and set minimum sizes

    Another possibility is to subclass QToolBar and explicitly set a minimum size (based on their hints) for all QToolButton created for its actions. In order to do that, we need a small hack: access to functions (and overwriting virtuals) on objects created outside Python is not allowed, meaning that we cannot just try to "monkey patch" things like sizeHint() at runtime; the only solution is to react to LayoutRequest events and always set an explicit minimum size whenever the hint of the QToolButton linked to the action does not use even numbers for its values.

    class ToolBar(QToolBar):
        def event(self, event):
            if event.type() == event.LayoutRequest:
                for action in self.actions():
                    if not isinstance(action, QWidgetAction):
                        btn = self.widgetForAction(action)
                        hint = btn.sizeHint()
                        w = hint.width()
                        h = hint.height()
                        if w & 1 or h & 1:
                            if w & 1:
                                w += 1
                            if h & 1:
                                h += 1
                            btn.setMinimumSize(w, h)
                        else:
                            btn.setMinimumSize(0, 0)
            return super().event(event)
    

    This will work even if style sheets have effect on tool bars and QToolButtons. It will obviously have no effect for non-toolbar buttons, but you can still use the first solution above. In the rare case you explicitly set a minimum size on a tool button (not added using addWidget(), that size would be potentially reset.

    Final notes

    Note that while the linked post could make this as a duplicate, I don't know C++ enough to provide an answer to that. To anybody reading this, feel free to make that one as a duplicate of this, or post a related answer with appropriate code in that language.