Unsolved 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')