Important: Please read the Qt Code of Conduct - https://forum.qt.io/topic/113070/qt-code-of-conduct

My PyQt/Designer custom widget workflow, perhaps it will help someone?



  • I'm new to this forum, so sorry if these details have been hashed out before (and sorry for the long post), but from my googling it seems that PyQt designer widgets plugins aren't super common, so perhaps these details help someone's workflow.

    I'm a big fan of python and Qt and, if it make sense and if I can, I like to keep all my dev in python, it just means less context switching on my part... So currently I'm not a fan of Qt Creator, I haven't delved into QML and my projects don't need C++.

    But I do love Qt Designer and .ui files! For me, it's a waste of time hashing out custom widget layout/naming/defaults in python. From designer you can export to .ui and use uic to compile to python, but if it just stops there it's holding me back. Really that custom widget needs to go right back into Qt Designer so that it can be connected and tested with the rest of my custom widgets to build my application.

    This is where the PyQt5.dll/.dylib/[linux variant] plugin with PYQTDESIGNERPATH env variable come into play. Install this plugin into the designer plugin dir and create a matching widgetplugin.py file (and setup your env properly) and it becomes usable in Qt Designer.

    That said, the uic compiler doesn't exactly give you a custom widget and once you start coding inside your widget.py file you can't really go back to your .ui file and make the changes you know will always happen. (change a layout/default/naming/add, remove something).

    So I've tooled a little script that essentially separates the View/Controller of a custom widget:

    • Create a .ui file
    • compile it with the script
    • a widget.py / widgetplugin.py file are created
    • fire up Qt Designer and they will be there for you to use (if you've set your PYQTDESIGNERPATH)
    • hooks are created inside your widget.py file where you can add all your code
    • if you go back and change your .ui file, recompile and everything from your .ui will be imported into your widget
    • code with signals and slots and hook all your widgets together in designer

    Here is a link of my steps getting pyqt5 designer plugin working on a mac (maybe it will help someone): https://stackoverflow.com/questions/54097475/custom-qwidgets-how-do-i-build-get-the-pyqt5-plugin-for-qt-designer-on-mac

    I know Qt has just sided with PySide2, I've searched a bit and couldn't find anything on a PySide2 designer plugin... perhaps it's there. There's no preference on my part, I just need that designer plugin.

    If I've missed something or there's an even better workflow that I'm missing, please let me know.

    Disclaimer:

    • I know this could be coded WAY better, if someone else finds this useful I fully encourage a re-write!
    • It assumes this file is in a base directory and your .ui files are in ./widgets and ./widgets/bak exists
    • It will only compile .ui files that end in "Widget.ui"
    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """
    Created on Tue Jan 15 18:39:40 2019
    
    @author: danv
    """
    
    from PyQt5 import uic
    import glob
    import os
    import ntpath
    import subprocess
    
    
    def swapClass(line):
        sub1 = line.split()[1]
        sub2 = sub1.split('(')[0]
        className = sub2.split('_')[1]
    
        classInit = ['class '+className+'(QtWidgets.QWidget):\n',
                     '\n',
                     '    # >> ClassVars\n',
                     '    # << ClassVars\n',
                     '\n',
                     '    def __init__(self, parent=None):\n',
                     '        super('+className+', self).__init__(parent)\n',
                     '\n',
                     '        # >> WidgetSignals\n',
                     '        # << WidgetSignals\n',
                     '\n',
                     '        # >> InstanceVars\n',
                     '        # << InstanceVars\n',
                     '\n']
    
        return className, classInit
    
    
    def updateMatchLogic(matchLogic, className):
        matchLogic[className+'.'] = {'action': 'replace', 'with': 'self.'}
        matchLogic['('+className+')'] = {'action': 'replace', 'with': '(self)'}
    
    
    def mainSwap(className):
        mainInit = [
                    '        # >> CustomInit\n',
                    '        # << CustomInit\n',
                    '\n',
                    '    # >> WidgetDefs\n',
                    '    # << WidgetDefs\n',
                    '\n',
                    '    # >> WidgetOverloads\n',
                    '    # << WidgetOverloads\n',
                    '\n',
                    '    # >> WidgetSlots\n',
                    '    # << WidgetSlots\n',
                    '\n',
                    '    # >> DesignerProps\n',
                    '    # << DesignerProps\n',
                    '\n\n',
                    'if __name__ == "__main__":\n',
                    '    import sys\n',
                    '\n',
                    '    app = QtWidgets.QApplication(sys.argv)\n',
                    '    app.setStyle(QtWidgets.QStyleFactory.create("Fusion"))\n'
                    '    wg = '+className+'()\n',
                    '    wg.show()\n',
                    '    sys.exit(app.exec_())\n',
                    '\n']
        return mainInit
    
    
    def createPlugin(filename, className):
        template = '''# -*- coding: utf-8 -*-
    
    from PyQt5.QtGui import QIcon
    from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
    
    from <widgetfileimport> import <widgetClassName>
    
    
    class <pluginClassName>(QPyDesignerCustomWidgetPlugin):
    
        def __init__(self, parent=None):
            super(<pluginClassName>, self).__init__(parent)
    
            self.initialized = False
    
        def initialize(self, core):
            if self.initialized:
                return
    
            self.initialized = True
    
        def isInitialized(self):
            return self.initialized
    
        def createWidget(self, parent):
            return <widgetClassName>(parent)
    
        def name(self):
            return "<widgetClassName>"
    
        def group(self):
            return "LPA Custom Widgets"
    
        def icon(self):
            return QIcon()
    
        def toolTip(self):
            return ""
    
        def whatsThis(self):
            return ""
    
        def isContainer(self):
            return False
    
        def includeFile(self):
            return "<widgetfileimport>"
    '''
        text = template.replace('<widgetfileimport>', filename.split('.')[0])
        text = text.replace('<widgetClassName>', className)
        pluginClassName = className+'Plugin'
        text = text.replace('<pluginClassName>', pluginClassName)
    
        pluginFilename = filename.replace('Widget', 'plugin')
    
        return pluginFilename, text
    
    
    matchLogic = {
            '#\n': {'action': 'remove'},
            '# WARNING! All changes': {'action': 'remove'},
            'from PyQt5 import': {'action': 'wrap', 'start': '# >> Imports\n', 'end': '# << Imports\n'},
            'class ': {'action': 'classParse', 'def': swapClass},
            'def setupUi': {'action': 'remove'},
            'self.retranslateUi': {'action': 'remove'},
            'QtCore.QMetaObject.': {'action': 'remove'},
            'def retranslateUi': {'action': 'remove'},
            'if __name__ == "__main__":': {'action': 'mainParse', 'def': mainSwap}
            }
    
    basePath = './widgets/'
    bakPath = './widgets/bak/'
    uiFiles = glob.glob(basePath+'*.ui')
    
    print('Welcome to the Qt Designer PyQt5 widget compiler')
    print('Base Path = '+basePath+', Backup Path = '+bakPath+'\n')
    
    for uiFile in uiFiles:
        uiFile = ntpath.basename(uiFile)
        pluginExists = False
        if 'Widget' in uiFile:
            print(uiFile+' -> ', end='', flush=True)
            pyName = uiFile.split('.')[0]+'.py'
            if os.path.isfile(basePath+pyName):
                pluginExists = True
    
            # compile ui
            output = subprocess.run(['python','-m','PyQt5.uic.pyuic','-x',basePath+uiFile,],capture_output=True,text=True)
            tmpLines = output.stdout.split('\n')
            tmpLines = [line+'\n' for line in tmpLines]
    
            widgetLines = []
            className = None
            done = False
            for line in tmpLines:
                found = False
                keyList = matchLogic.keys()
                for key in keyList:
                    if key in line:
                        action = matchLogic[key]['action']
                        if action == 'wrap':
                            widgetLines.append(matchLogic[key]['start'])
                            widgetLines.append(line)
                            widgetLines.append(matchLogic[key]['end'])
                            found = True
                            break
                        elif action == 'classParse':
                            d = matchLogic[key]['def']
                            className, classInit = d(line)
                            widgetLines += classInit
                            updateMatchLogic(matchLogic, className)
                            found = True
                            break
                        elif action == 'mainParse':
                            d = matchLogic[key]['def']
                            mainInit = d(className)
                            widgetLines += mainInit
                            done = True
                            found = True
                            break
                        elif action == 'replace':
                            widgetLines.append(line.replace(key,matchLogic[key]['with']))
                            found = True
                            break
                        elif action == 'remove':
                            found = True
                            break
    
                if done is True:
                    break
                if found is False:
                    widgetLines.append(line)
    
            # generate plugin file (always re-gen since you may change the name of your widget and you want that to show in designer)
            pluginFilename, pluginText = createPlugin(pyName, className)
    
            f = open(basePath+pluginFilename, 'w')
            f.write('%s' % pluginText)
            f.close()
    
            if pluginExists is False:
                widgetText = ''.join(widgetLines)
                f = open(basePath+pyName, 'w')
                f.write('%s' % widgetText)
                f.close()
                print(' OK')
    
            else:  # plugin does exits!
                saveLines = {
                        '# >> Imports': [],
                        '# >> ClassVars': [],
                        '# >> WidgetSignals': [],
                        '# >> InstanceVars': [],
                        '# >> CustomInit': [],
                        '# >> WidgetDefs': [],
                        '# >> WidgetOverloads': [],
                        '# >> WidgetSlots': [],
                        '# >> DesignerProps': []
                        }
    
                with open(basePath+pyName) as f:
                    currentLines = f.readlines()
                f.close()
                # save to bak folder
                backupText = ''.join(currentLines)
                f = open(bakPath+pyName+'.bak', 'w')
                f.write('%s' % backupText)
                f.close()
    
                closeTag = '# <<'
                searchForClose = False
                listInUse = None
                for line in currentLines:
                    if searchForClose is False:
                        for key in saveLines.keys():
                            if key in line:
                                searchForClose = True
                                listInUse = saveLines[key]
                                break
                    elif searchForClose is True:
                        if closeTag in line:
                            searchForClose = False
                        else:
                            listInUse.append(line)
    
                integratedLines = []
                insideImports = False
                for line in widgetLines:
                    if insideImports is True:
                        if closeTag in line:
                            insideImports = False
                            integratedLines.append(line)
    
                        continue
                    else:
                        integratedLines.append(line)
    
                    for key in saveLines.keys():
                        if key in line and len(saveLines[key]) > 0:
                            integratedLines += saveLines[key]
                            if key == '# >> Imports':
                                insideImports = True
                                break
    
                # save integrated plugin file
                finalText = ''.join(integratedLines)
                f = open(basePath+pyName, 'w')
                f.write('%s' % finalText)
                f.close()
                print(' OK')
    
    

Log in to reply