Search code examples
pythonpyqtpyside2

Replace existing button in pyQT, keep parent widget, position etc


Im struggling a bit with replacing widgets(Qpushbutton) with a custom button that has a enter event for qIcon and keeping the nested layout structure from a external ui.

The replaced button shows up in the ui, but it doesnt retain the exact same position as the existing button.

from PySide2.QtWidgets import QPushButton, QDialog, QVBoxLayout, QApplication
from PySide2.QtGui import QPixmap, QIcon
from PySide2.QtCore import QSize
from PySide2.QtUiTools import QUiLoader

class HoverPushButton(QPushButton):
    def __init__(self, normal_pixmap, hover_pixmap, button_size):
        super(HoverPushButton, self).__init__()
        self.icon_normal = QIcon(normal_pixmap)
        self.icon_over = QIcon(hover_pixmap)
        self.setIcon(self.icon_normal)
        self.setIconSize(QSize(button_size, button_size))
        
    def enterEvent(self, event):
        self.setIcon(self.icon_over)
        return super(HoverPushButton, self).enterEvent(event)

    def leaveEvent(self, event):
        self.setIcon(self.icon_normal)
        return super(HoverPushButton, self).leaveEvent(event)

class MyDialog(QDialog):
    def __init__(self):
        super(MyDialog, self).__init__()
        self.setup_ui()

    def setup_ui(self):
        # Load your UI from .ui file
        loader = QUiLoader()
        ui_directory = 'D:/Scripts.Python/UI/'
        ui_file = ui_directory+ 'form.ui'
        self.ui = loader.load(ui_file)
        self.resize(900, 800)

        # Set QVBoxLayout for the QDialog
        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 30, 0, 0)
        layout.addWidget(self.ui)

        # Load sprite sheet
        self.sprite_sheet = QPixmap(ui_directory+ 'sprite_canvas.png')
        self.sprite_sheet_hover = QPixmap(ui_directory+ 'sprite_canvas_hover.png')

        icon_size = 85
        button_size = 35

        # Call the function to replace buttons
        self.brush_button = self.ui.findChild(QPushButton, 'pushButton_penBrush')
        self.brush_button_replaced = self.replace_custom_button(self.brush_button, 1, 1, icon_size, button_size)

    def replace_custom_button(self, existing_button, row, column, icon_size, button_size):
        # Crop images from sprite sheet
        normal_pixmap = self.sprite_sheet.copy(column * icon_size, row * icon_size, icon_size, icon_size)
        hover_pixmap = self.sprite_sheet_hover.copy(column * icon_size, row * icon_size, icon_size, icon_size)
        # Create an instance of HoverPushButton with the specified button_size
        new_button = HoverPushButton(normal_pixmap, hover_pixmap, button_size)
        # Clone minimum size
        new_button.setMinimumSize(existing_button.minimumSize())
        # Clone maximum size
        new_button.setMaximumSize(existing_button.maximumSize())
        new_button.setText(existing_button.text())

        # Replace the existing button with the new custom button   
        replaced = (self.layout().replaceWidget(existing_button, new_button)) 
        existing_button.deleteLater()
        return new_button

if __name__ == '__main__':
    #~ app = QApplication([])
    dialog = MyDialog()
    dialog.show()
    #~ app.exec_()


UI

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>Form</class>
 <widget class="QWidget" name="Form">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>400</width>
    <height>300</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>Form</string>
  </property>
  <layout class="QHBoxLayout" name="horizontalLayout">
   <item>
    <layout class="QHBoxLayout" name="horizontalLayout_2">
     <property name="sizeConstraint">
      <enum>QLayout::SetDefaultConstraint</enum>
     </property>
     <item>
      <layout class="QVBoxLayout" name="verticalLayout">
       <item>
        <widget class="QGroupBox" name="groupBox">
         <property name="title">
          <string>GroupBox</string>
         </property>
         <layout class="QHBoxLayout" name="horizontalLayout_3">
          <item>
           <widget class="QPushButton" name="pushButton_penBrush">
            <property name="minimumSize">
             <size>
              <width>35</width>
              <height>35</height>
             </size>
            </property>
            <property name="maximumSize">
             <size>
              <width>35</width>
              <height>35</height>
             </size>
            </property>
            <property name="text">
             <string/>
            </property>
           </widget>
          </item>
         </layout>
        </widget>
       </item>
      </layout>
     </item>
    </layout>
   </item>
  </layout>
 </widget>
 <resources/>
 <connections/>
</ui>



Whats the proper way of replacing a existing widget, maintaining the nested structure (parent widget) so it get the correct layout placement and position?

I tried to use move and it works if the layout is broken (x,y) but when using vertical, horizontal layout etc then the button doesnt align with the existing button

Button not appearing


Solution

  • The documentation of replaceWidget() actually explains it, but it has to be read and understood very carefully (I underestimated it myself):

    If options contains Qt::FindChildrenRecursively (the default), sub-layouts are searched for doing the replacement.

    What the above phrase implies is that the function only looks for layouts that are directly nested within the given one: children layouts added with addLayout(); it does not consider the layouts of widgets contained in that layout.

    In fact, the original sources of QLayout::replaceWidget() fundamentally do the following:

        int index = -1;
        for (int u = 0; u < count(); ++u) { // iterate through all layout items
            item = itemAt(u);
            if (!item)
                continue;
            if (item->widget() == from) {
                index = u; // "source" widget has been found, break to replace
                break;
            }
            // if the item is a nested layout, call the function recursively
            if (item->layout() && (options & Qt::FindChildrenRecursively)) {
                QLayoutItem *r = item->layout()->replaceWidget(from, to, options);
                if (r)
                    return r;
            }
            // IMPORTANT!!! the for loop ends here, implying that a layout item 
            // containing a widget will *always* be ignored!!!
        }
        if (index == -1)
            return nullptr; // no match found, no substitution
    
        ... // eventually replace the widget
    

    Note: code edited for the purpose of explanation; comments are mine.

    So, assuming that the existing widget is actually part of a properly set layout (so, it has a parent widget with that layout set for it, even if the "parent layout" is a nested one within the main one), the appropriate code should be:

            existing_button.parentWidget().layout().replaceWidget(
                existing_button, new_button)
            existing_button.deleteLater()
    

    As noted in the comments, though, this should only be done when actually required, which is replacing widget at any time during the program lifespan.

    If you want to alter the behavior of a widget with a custom one, then you should consider widget promotion, or, at least, installing an event filter.