diff --git a/README.md b/README.md index 857535f..3cf9f88 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[//]: # (Generated 2022-04-08T17:30:22.829359. Changes belong into template/documentation/readme.template) +[//]: # (Generated 2022-04-08T17:43:00.491140. Changes belong into template/documentation/readme.template) # Tembro diff --git a/documentation/out/english.html b/documentation/out/english.html index 83ad32a..f335a5a 100644 --- a/documentation/out/english.html +++ b/documentation/out/english.html @@ -717,7 +717,7 @@ The program is split in two parts. A shared "template" between the Laborejo Soft diff --git a/documentation/out/german.html b/documentation/out/german.html index 4997e95..be181e5 100644 --- a/documentation/out/german.html +++ b/documentation/out/german.html @@ -711,7 +711,7 @@ Ansonsten starten Sie tembro mit diesem Befehl, Sprachcode ändern, vom Terminal diff --git a/engine/api.py b/engine/api.py index f03619c..16f1783 100644 --- a/engine/api.py +++ b/engine/api.py @@ -51,6 +51,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks self.auditionerVolumeChanged = [] self.instrumentMidiNoteOnActivity = [] self.instrumentMidiNoteOffActivity = [] + self.instrumentCCActivity = [] def _tempCallback(self): """Just for copy paste during development""" @@ -133,6 +134,10 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks for func in self.instrumentMidiNoteOffActivity: func(idKey, pitch, velocity) + def _instrumentCCActivity(self, idKey, ccNumber, value): + for func in self.instrumentCCActivity: + func(idKey, ccNumber, value) + #Inject our derived Callbacks into the parent module template.engine.api.callbacks = ClientCallbacks() from template.engine.api import callbacks @@ -140,7 +145,7 @@ from template.engine.api import callbacks _templateStartEngine = startEngine def startEngine(nsmClient, additionalData): - session.data.parseAndLoadInstrumentLibraries(additionalData["baseSamplePath"], callbacks._instrumentMidiNoteOnActivity, callbacks._instrumentMidiNoteOffActivity ) + session.data.parseAndLoadInstrumentLibraries(additionalData["baseSamplePath"], callbacks._instrumentMidiNoteOnActivity, callbacks._instrumentMidiNoteOffActivity, callbacks._instrumentCCActivity ) _templateStartEngine(nsmClient) #loads save files or creates empty structure. @@ -193,7 +198,7 @@ def rescanSampleDirectory(newBaseSamplePath): #In Tembro instruments never musically change through updates. #There is no musical danger of keeping an old version alive, even if a newly downloaded .tar #contains updates. A user must load/reload these manually or restart the program. - session.data.parseAndLoadInstrumentLibraries(newBaseSamplePath, session.data.instrumentMidiNoteOnActivity, session.data.instrumentMidiNoteOffActivity) + session.data.parseAndLoadInstrumentLibraries(newBaseSamplePath, session.data.instrumentMidiNoteOnActivity, session.data.instrumentMidiNoteOffActivity, session.data.instrumentCCActivity ) callbacks._rescanSampleDir() #instructs the GUI to forget all cached data and start fresh. callbacks._instrumentListMetadata() #The big "build the database" callback @@ -364,3 +369,21 @@ def sendNoteOffToInstrument(idKey:tuple, midipitch:int): if instrument.enabled: instrument.scene.send_midi_event(0x80, midipitch, 0) callbacks._instrumentMidiNoteOffActivity(idKey, midipitch, 0) + + +def ccTrackingState(idKey:tuple): + """Get the current values of all CCs that have been modified through midi-in so far. + This also includes values we changed ourselves through sentCCToInstrument""" + instrument = _instr(idKey) + if instrument.enabled: + return instrument.midiProcessor.ccState + else: + return None + +def sentCCToInstrument(idKey:tuple, ccNumber:int, value:int): + instrument = _instr(idKey) + if instrument.enabled: + instrument.scene.send_midi_event(0xB0, ccNumber, value) + instrument.midiProcessor.ccState[ccNumber] = value #midi processor doesn't get send_midi_event + else: + return None diff --git a/engine/instrument.py b/engine/instrument.py index 6356486..fb7707c 100644 --- a/engine/instrument.py +++ b/engine/instrument.py @@ -384,6 +384,7 @@ class Instrument(object): self.playableKeys = tuple(sorted(allKeys)) self.controlLabels = self.program.get_control_labels() #opcode label_cc# in + self.keyLabels = self.program.get_key_labels() #opcode label_cc# in #Add some defaults. for k,v in {60:"Middle C", 53:"𝄢", 67:"𝄞"}.items(): @@ -504,6 +505,7 @@ class Instrument(object): self.midiProcessor = MidiProcessor(parentInput = self) #works through self.cboxMidiPortUid self.midiProcessor.register_NoteOn(self.triggerNoteOnCallback) self.midiProcessor.register_NoteOff(self.triggerNoteOffCallback) + self.midiProcessor.register_CC(self.triggerCCCallback) #self.midiProcessor.notePrinter(True) self.parentLibrary.parentData.parentSession.eventLoop.fastConnect(self.midiProcessor.processEvents) @@ -560,7 +562,10 @@ class Instrument(object): consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()""" self.parentLibrary.parentData.instrumentMidiNoteOffActivity(self.idKey, pitch, velocity) - + def triggerCCCallback(self, timestamp, channel, ccNumber, value): + """args are: timestamp, channel, ccNumber, value + consider to change eventloop.slowConnect to fastConnect. And also disconnect in self.disable()""" + self.parentLibrary.parentData.instrumentCCActivity(self.idKey, ccNumber, value) def getAvailablePorts(self)->dict: """This function queries JACK each time it is called. diff --git a/engine/main.py b/engine/main.py index 338c069..08e29cc 100644 --- a/engine/main.py +++ b/engine/main.py @@ -69,7 +69,7 @@ class Data(TemplateData): for instrId, instr in lib.instruments.items(): yield instr - def parseAndLoadInstrumentLibraries(self, baseSamplePath, instrumentMidiNoteOnActivity, instrumentMidiNoteOffActivity): + def parseAndLoadInstrumentLibraries(self, baseSamplePath, instrumentMidiNoteOnActivity, instrumentMidiNoteOffActivity, instrumentCCActivity): """Called first by api.startEngine, which receives the global sample path from the GUI. @@ -170,6 +170,7 @@ class Data(TemplateData): self.instrumentMidiNoteOnActivity = instrumentMidiNoteOnActivity # the api will inject a callback function here which takes (libId, instrId) as parameter to indicate midi noteOn activity for non-critical information like a GUI LED blinking or checking for new keyswitch states. The instruments individiual midiprocessor will call this as a parent-call. self.instrumentMidiNoteOffActivity = instrumentMidiNoteOffActivity #see above + self.instrumentCCActivity = instrumentCCActivity #see above if firstRun: #in case of re-scan we don't need to do this a second time. The default lib cannot be updated through the download manager and will always be present. self._createGlobalPorts() #in its own function for readability self._createCachedJackMetadataSorting() diff --git a/engine/resources/000 - Default.tembro b/engine/resources/000 - Default.tembro index 5ac70c6..3d1551b 100644 Binary files a/engine/resources/000 - Default.tembro and b/engine/resources/000 - Default.tembro differ diff --git a/qtgui/designer/mainwindow.py b/qtgui/designer/mainwindow.py index 0ff6c04..a091c18 100644 --- a/qtgui/designer/mainwindow.py +++ b/qtgui/designer/mainwindow.py @@ -14,7 +14,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets class Ui_MainWindow(object): def setupUi(self, MainWindow): MainWindow.setObjectName("MainWindow") - MainWindow.resize(1175, 651) + MainWindow.resize(1003, 668) self.centralwidget = QtWidgets.QWidget(MainWindow) self.centralwidget.setObjectName("centralwidget") self.horizontalLayout_3 = QtWidgets.QHBoxLayout(self.centralwidget) @@ -104,30 +104,59 @@ class Ui_MainWindow(object): self.verticalLayout_3.setSpacing(0) self.verticalLayout_3.setObjectName("verticalLayout_3") self.details_scrollArea = QtWidgets.QScrollArea(self.details_groupBox) + self.details_scrollArea.setFrameShape(QtWidgets.QFrame.NoFrame) + self.details_scrollArea.setFrameShadow(QtWidgets.QFrame.Plain) + self.details_scrollArea.setLineWidth(0) + self.details_scrollArea.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) self.details_scrollArea.setWidgetResizable(True) self.details_scrollArea.setObjectName("details_scrollArea") self.scrollAreaWidgetContents = QtWidgets.QWidget() - self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 993, 125)) + self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 823, 136)) self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents") - self.formLayout = QtWidgets.QFormLayout(self.scrollAreaWidgetContents) + self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.scrollAreaWidgetContents) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.widget = QtWidgets.QWidget(self.scrollAreaWidgetContents) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(1) + sizePolicy.setHeightForWidth(self.widget.sizePolicy().hasHeightForWidth()) + self.widget.setSizePolicy(sizePolicy) + self.widget.setObjectName("widget") + self.formLayout = QtWidgets.QFormLayout(self.widget) + self.formLayout.setSizeConstraint(QtWidgets.QLayout.SetMinAndMaxSize) + self.formLayout.setContentsMargins(-1, 20, 15, -1) self.formLayout.setObjectName("formLayout") - self.variants_comboBox = QtWidgets.QComboBox(self.scrollAreaWidgetContents) - self.variants_comboBox.setObjectName("variants_comboBox") - self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.variants_comboBox) - self.variant_label = QtWidgets.QLabel(self.scrollAreaWidgetContents) + self.variant_label = QtWidgets.QLabel(self.widget) self.variant_label.setObjectName("variant_label") - self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.variant_label) - self.info_label = QtWidgets.QLabel(self.scrollAreaWidgetContents) + self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.variant_label) + self.variants_comboBox = QtWidgets.QComboBox(self.widget) + self.variants_comboBox.setObjectName("variants_comboBox") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.variants_comboBox) + self.keySwitch_label = QtWidgets.QLabel(self.widget) + self.keySwitch_label.setObjectName("keySwitch_label") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.keySwitch_label) + self.keySwitch_comboBox = QtWidgets.QComboBox(self.widget) + self.keySwitch_comboBox.setObjectName("keySwitch_comboBox") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.keySwitch_comboBox) + self.info_label = QtWidgets.QLabel(self.widget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.info_label.sizePolicy().hasHeightForWidth()) + self.info_label.setSizePolicy(sizePolicy) self.info_label.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) self.info_label.setWordWrap(True) self.info_label.setObjectName("info_label") - self.formLayout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.info_label) - self.keySwitch_comboBox = QtWidgets.QComboBox(self.scrollAreaWidgetContents) - self.keySwitch_comboBox.setObjectName("keySwitch_comboBox") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.keySwitch_comboBox) - self.keySwitch_label = QtWidgets.QLabel(self.scrollAreaWidgetContents) - self.keySwitch_label.setObjectName("keySwitch_label") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.keySwitch_label) + self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.info_label) + self.horizontalLayout_2.addWidget(self.widget) + self.controls_groupBox = QtWidgets.QGroupBox(self.scrollAreaWidgetContents) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(1) + sizePolicy.setHeightForWidth(self.controls_groupBox.sizePolicy().hasHeightForWidth()) + self.controls_groupBox.setSizePolicy(sizePolicy) + self.controls_groupBox.setObjectName("controls_groupBox") + self.horizontalLayout_2.addWidget(self.controls_groupBox) self.details_scrollArea.setWidget(self.scrollAreaWidgetContents) self.verticalLayout_3.addWidget(self.details_scrollArea) self.verticalLayout.addWidget(self.splitter) @@ -142,7 +171,7 @@ class Ui_MainWindow(object): self.horizontalLayout_3.addWidget(self.rightFrame) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1175, 20)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1003, 20)) self.menubar.setObjectName("menubar") MainWindow.setMenuBar(self.menubar) self.statusbar = QtWidgets.QStatusBar(MainWindow) @@ -172,5 +201,6 @@ class Ui_MainWindow(object): self.iinstruments_tabWidget.setTabText(self.iinstruments_tabWidget.indexOf(self.Favorites), _translate("MainWindow", "Favorites")) self.details_groupBox.setTitle(_translate("MainWindow", "NamePlaceholder")) self.variant_label.setText(_translate("MainWindow", "Variants")) - self.info_label.setText(_translate("MainWindow", "TextLabel")) self.keySwitch_label.setText(_translate("MainWindow", "KeySwitch")) + self.info_label.setText(_translate("MainWindow", "TextLabel")) + self.controls_groupBox.setTitle(_translate("MainWindow", "Controls")) diff --git a/qtgui/designer/mainwindow.ui b/qtgui/designer/mainwindow.ui index 0b84ea8..a831774 100644 --- a/qtgui/designer/mainwindow.ui +++ b/qtgui/designer/mainwindow.ui @@ -6,8 +6,8 @@ 0 0 - 1175 - 651 + 1003 + 668 @@ -320,6 +320,18 @@ + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + QAbstractScrollArea::AdjustToContents + true @@ -328,41 +340,81 @@ 0 0 - 993 - 125 + 823 + 136 - - - - - - - - Variants + + + + + + 0 + 1 + + + + QLayout::SetMinAndMaxSize + + + 20 + + + 15 + + + + + Variants + + + + + + + + + + KeySwitch + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + + - - - - TextLabel + + + + + 0 + 1 + - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - true - - - - - - - - - - KeySwitch + + Controls @@ -401,7 +453,7 @@ 0 0 - 1175 + 1003 20 diff --git a/qtgui/selectedinstrumentcontroller.py b/qtgui/selectedinstrumentcontroller.py index 1fe32f7..ba6ab59 100644 --- a/qtgui/selectedinstrumentcontroller.py +++ b/qtgui/selectedinstrumentcontroller.py @@ -28,6 +28,8 @@ from PyQt5 import QtCore, QtGui, QtWidgets #Our Qt from .instrument import GuiInstrument, GuiLibrary #for the types +from template.qtgui.flowlayout import FlowLayout + #Engine import engine.api as api @@ -48,18 +50,23 @@ class SelectedInstrumentController(object): self.currentIdKey = None self.engineData = {} # idKey tuple : engine library metadata dict. self.statusUpdates = {} # same as engineData, but with incremental status updates. One is guranteed to exist at startup + self.controlWidgets = {} # ccNumber or other identifier : ControlWidget (our class from this file) #Our Widgets self.ui = parentMainWindow.ui self.ui.details_groupBox.setTitle("") self.ui.details_scrollArea.hide() #until the first instrument was selected + self.ui.controls_groupBox.setLayout(FlowLayout(margin=1)) self.ui.variants_comboBox.activated.connect(self._newVariantChosen) self.ui.keySwitch_comboBox.activated.connect(self._newKeySwitchChosen) + #Callbacks api.callbacks.instrumentListMetadata.append(self.react_initialInstrumentList) api.callbacks.instrumentStatusChanged.append(self.react_instrumentStatusChanged) + api.callbacks.instrumentCCActivity.append(self.react_instrumentCCActivity) + def directLibrary(self, idKey:tuple): """User clicked on a library treeItem""" @@ -69,6 +76,8 @@ class SelectedInstrumentController(object): self.ui.variants_comboBox.hide() self.ui.variant_label.hide() + self.ui.controls_groupBox.hide() + self.ui.keySwitch_label.hide() self.ui.keySwitch_comboBox.hide() @@ -129,6 +138,33 @@ class SelectedInstrumentController(object): self.ui.keySwitch_comboBox.setCurrentIndex(curIdx) + def _populateControls(self, instrumentStatus:dict): + """Remove all CC Knobs, Faders and other control widgets and draw them again for + the current GUI-Instrument. + + This is called when activating an instrument, switching the variant or simply + coming back to an already loaded instrument in the GUI.""" + + for controlWidget in self.controlWidgets.values(): + self.ui.controls_groupBox.layout().removeWidget(controlWidget) + controlWidget.hide() + controlWidget.setParent(None) + del controlWidget + self.controlWidgets = {} + + if not instrumentStatus["state"] or not instrumentStatus["controlLabels"] : #Not activated yet. + return + + ccNow = api.ccTrackingState(instrumentStatus["idKey"]) + + for ccNumber, ccLabel in instrumentStatus["controlLabels"].items(): + w = ControlWidget(self.ui.controls_groupBox, instrumentStatus, ccNumber, ccLabel) + self.controlWidgets[ccNumber] = w + self.ui.controls_groupBox.layout().addWidget(w) + if ccNumber in ccNow: + w.setValue(ccNow[ccNumber], sendToEngine=False) #don't send the engine values it already knows. + + def currentTreeItemChanged(self, currentTreeItem:QtWidgets.QTreeWidgetItem): """ Program wide GUI-only callback from @@ -179,6 +215,9 @@ class SelectedInstrumentController(object): self.ui.variants_comboBox.clear() self.ui.variants_comboBox.addItems(instrumentData["variantsWithoutSfzExtension"]) + self.ui.controls_groupBox.show() + self._populateControls(instrumentStatus) + self.ui.info_label.setText(self._metadataToDescriptionLabel(instrumentData)) #Dynamic @@ -232,6 +271,7 @@ class SelectedInstrumentController(object): self.ui.variants_comboBox.setCurrentIndex(defaultVariantIndex) self._populateKeySwitchComboBox(instrumentStatus) + self._populateControls(instrumentStatus) def react_initialInstrumentList(self, data:dict): """For data form see docstring of instrument.py buildTree() @@ -246,3 +286,65 @@ class SelectedInstrumentController(object): self.react_instrumentStatusChanged """ self.engineData = data + + def react_instrumentCCActivity(self, idKey, ccNumber, value): + if not idKey == self.currentIdKey: + return #not for us + + if ccNumber in self.controlWidgets: + self.controlWidgets[ccNumber].setValue(value) + + +class ControlWidget(QtWidgets.QWidget): + + def __init__(self, parentWidget, instrumentStatus, ccNumber, ccLabel): + super().__init__(parentWidget) + + self.instrumentStatus = instrumentStatus #static info about the complete instrument. + assert self.instrumentStatus["state"], instrumentStatus + assert self.instrumentStatus["controlLabels"], instrumentStatus + + self.idKey = instrumentStatus["idKey"] + self.ccNumber = ccNumber + + self.setLayout(QtWidgets.QVBoxLayout()) + + self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.slider.setTracking(True) + self.slider.valueChanged.connect(lambda v: self.setValue(v, sendToEngine=True)) + self.layout().addWidget(self.slider) + + self.spinBox = QtWidgets.QSpinBox() + self.spinBox.valueChanged.connect(lambda v: self.setValue(v, sendToEngine=True)) + self.spinBox.setPrefix(f"[{ccNumber}] ") + self.layout().addWidget(self.spinBox) + + self.label = QtWidgets.QLabel(ccLabel) + self.layout().addWidget(self.label) + + self.setFixedSize(132,96) + + self.setRange(0,127) + + #self.setValue(0) #No need. We don't want to destroy the instruments defaults. + self.spinBox.setSpecialValueText(f"[CC {ccNumber}]") #Never touched by human hands + + def setRange(self, floor:int, ceiling:int): + self.spinBox.setRange(floor, ceiling) + self.slider.setRange(floor, ceiling) + + def setValue(self, value:int, sendToEngine=False): + self.spinBox.blockSignals(True) + self.slider.blockSignals(True) + + self.spinBox.setSpecialValueText("") #0 is 0 + + self.spinBox.setValue(value) + self.slider.setValue(value) + + #Send to Engine + if sendToEngine: + api.sentCCToInstrument(self.idKey, self.ccNumber, value) + + self.spinBox.blockSignals(False) + self.slider.blockSignals(False) diff --git a/template/qtgui/flowlayout.py b/template/qtgui/flowlayout.py new file mode 100644 index 0000000..5173a49 --- /dev/null +++ b/template/qtgui/flowlayout.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python + + +############################################################################# +## +## Copyright (C) 2013 Riverbank Computing Limited. +## Copyright (C) 2010 Nokia Corporation and/or its subsidiary(-ies). +## All rights reserved. +## +## This file is part of the examples of PyQt. +## +## $QT_BEGIN_LICENSE:BSD$ +## You may use this file under the terms of the BSD license as follows: +## +## "Redistribution and use in source and binary forms, with or without +## modification, are permitted provided that the following conditions are +## met: +## * Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## * Redistributions in binary form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. +## * Neither the name of Nokia Corporation and its Subsidiary(-ies) nor +## the names of its contributors may be used to endorse or promote +## products derived from this software without specific prior written +## permission. +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +## $QT_END_LICENSE$ +## +############################################################################# + + +from PyQt5.QtCore import QPoint, QRect, QSize, Qt +from PyQt5.QtWidgets import (QApplication, QLayout, QPushButton, QSizePolicy, + QWidget) + + +class Window(QWidget): + def __init__(self): + super(Window, self).__init__() + + flowLayout = FlowLayout() + flowLayout.addWidget(QPushButton("Short")) + flowLayout.addWidget(QPushButton("Longer")) + flowLayout.addWidget(QPushButton("Different text")) + flowLayout.addWidget(QPushButton("More text")) + flowLayout.addWidget(QPushButton("Even longer button text")) + self.setLayout(flowLayout) + + self.setWindowTitle("Flow Layout") + + +class FlowLayout(QLayout): + def __init__(self, parent=None, margin=0, spacing=-1): + super(FlowLayout, self).__init__(parent) + + if parent is not None: + self.setContentsMargins(margin, margin, margin, margin) + + self.setSpacing(spacing) + + self.itemList = [] + + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) + + def addItem(self, item): + self.itemList.append(item) + + def count(self): + return len(self.itemList) + + def itemAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList[index] + + return None + + def takeAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList.pop(index) + + return None + + def expandingDirections(self): + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + height = self.doLayout(QRect(0, 0, width, 0), True) + return height + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + self.doLayout(rect, False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + + for item in self.itemList: + size = size.expandedTo(item.minimumSize()) + + margin, _, _, _ = self.getContentsMargins() + + size += QSize(2 * margin, 2 * margin) + return size + + def doLayout(self, rect, testOnly): + x = rect.x() + y = rect.y() + lineHeight = 0 + + for item in self.itemList: + wid = item.widget() + spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal) + spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical) + nextX = x + item.sizeHint().width() + spaceX + if nextX - spaceX > rect.right() and lineHeight > 0: + x = rect.x() + y = y + lineHeight + spaceY + nextX = x + item.sizeHint().width() + spaceX + lineHeight = 0 + + if not testOnly: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = nextX + lineHeight = max(lineHeight, item.sizeHint().height()) + + return y + lineHeight - rect.y() + + +if __name__ == '__main__': + + import sys + + app = QApplication(sys.argv) + mainWin = Window() + mainWin.show() + sys.exit(app.exec_())