|  | #!/usr/bin/env python3
# SPDX-FileCopyrightText: 2011-2024 Filipe Coelho <falktx@falktx.com>
# SPDX-License-Identifier: GPL-2.0-or-later
# ------------------------------------------------------------------------------------------------------------
# Imports (Global)
from qt_compat import qt_config
if qt_config == 5:
    from PyQt5.QtCore import pyqtSignal, pyqtSlot, QT_VERSION, Qt, QPointF, QRectF, QSize, QTimer
    from PyQt5.QtGui import QColor, QPainter, QPen
    from PyQt5.QtWidgets import QGraphicsScene, QGraphicsSceneMouseEvent, QMainWindow
elif qt_config == 6:
    from PyQt6.QtCore import pyqtSignal, pyqtSlot, QT_VERSION, Qt, QPointF, QRectF, QSize, QTimer
    from PyQt6.QtGui import QColor, QPainter, QPen
    from PyQt6.QtWidgets import QGraphicsScene, QGraphicsSceneMouseEvent, QMainWindow
# -----------------------------------------------------------------------
# Imports (Custom)
from carla_shared import *
from carla_utils import *
import ui_xycontroller
# -----------------------------------------------------------------------
# Imports (ExternalUI)
from carla_app import CarlaApplication
from externalui import ExternalUI
from widgets.paramspinbox import ParamSpinBox
# ------------------------------------------------------------------------------------------------------------
XYCONTROLLER_PARAMETER_X = 0
XYCONTROLLER_PARAMETER_Y = 1
# ------------------------------------------------------------------------------------------------------------
class XYGraphicsScene(QGraphicsScene):
    # signals
    cursorMoved = pyqtSignal(float,float)
    def __init__(self, parent):
        QGraphicsScene.__init__(self, parent)
        self.cc_x = 1
        self.cc_y = 2
        self.rparent = parent
        self.m_channels  = []
        self.m_mouseLock = False
        self.m_smooth    = False
        self.m_smooth_x  = 0.0
        self.m_smooth_y  = 0.0
        self.setBackgroundBrush(Qt.black)
        cursorPen = QPen(QColor(255, 255, 255), 2)
        cursorBrush = QColor(255, 255, 255, 50)
        self.m_cursor = self.addEllipse(QRectF(-10, -10, 20, 20), cursorPen, cursorBrush)
        linePen = QPen(QColor(200, 200, 200, 100), 1, Qt.DashLine)
        self.m_lineH = self.addLine(-9999, 0, 9999, 0, linePen)
        self.m_lineV = self.addLine(0, -9999, 0, 9999, linePen)
        self.p_size = QRectF(-100, -100, 100, 100)
    # -------------------------------------------------------------------
    def setControlX(self, x: int):
        self.cc_x = x
    def setControlY(self, y: int):
        self.cc_y = y
    def setChannels(self, channels):
        self.m_channels = channels
    def setPosX(self, x: float, forward: bool = True):
        if self.m_mouseLock:
            return
        posX = x * (self.p_size.x() + self.p_size.width())
        self.m_cursor.setPos(posX, self.m_cursor.y())
        self.m_lineV.setX(posX)
        if forward:
            value = posX / (self.p_size.x() + self.p_size.width());
            self.sendMIDI(value, None)
        else:
            self.m_smooth_x = posX;
    def setPosY(self, y: float, forward: bool = True):
        if self.m_mouseLock:
            return;
        posY = y * (self.p_size.y() + self.p_size.height())
        self.m_cursor.setPos(self.m_cursor.x(), posY)
        self.m_lineH.setY(posY)
        if forward:
            value = posY / (self.p_size.y() + self.p_size.height())
            self.sendMIDI(None, value)
        else:
            self.m_smooth_y = posY
    def setSmooth(self, smooth: bool):
        self.m_smooth = smooth
    def setSmoothValues(self, x: float, y: float):
        self.m_smooth_x = x * (self.p_size.x() + self.p_size.width());
        self.m_smooth_y = y * (self.p_size.y() + self.p_size.height());
    # -------------------------------------------------------------------
    def updateSize(self, size: QSize):
        self.p_size.setRect(-(float(size.width())/2),
                            -(float(size.height())/2),
                            size.width(),
                            size.height());
    def updateSmooth(self):
        if not self.m_smooth:
            return
        if self.m_cursor.x() == self.m_smooth_x and self.m_cursor.y() == self.m_smooth_y:
            return
        same = 0
        if abs(self.m_cursor.x() - self.m_smooth_x) <= 0.0005:
            self.m_smooth_x = self.m_cursor.x()
            same += 1
        if abs(self.m_cursor.y() - self.m_smooth_y) <= 0.0005:
            self.m_smooth_y = self.m_cursor.y()
            same += 1
        if same == 2:
            return
        newX = float(self.m_smooth_x + self.m_cursor.x()*7) / 8
        newY = float(self.m_smooth_y + self.m_cursor.y()*7) / 8
        pos  = QPointF(newX, newY)
        self.m_cursor.setPos(pos)
        self.m_lineH.setY(pos.y())
        self.m_lineV.setX(pos.x())
        xp = pos.x() / (self.p_size.x() + self.p_size.width())
        yp = pos.y() / (self.p_size.y() + self.p_size.height())
        self.sendMIDI(xp, yp)
        self.cursorMoved.emit(xp, yp)
    # -------------------------------------------------------------------
    def handleMousePos(self, pos: QPointF):
        if not self.p_size.contains(pos):
            if pos.x() < self.p_size.x():
                pos.setX(self.p_size.x())
            elif pos.x() > (self.p_size.x() + self.p_size.width()):
                pos.setX(self.p_size.x() + self.p_size.width());
            if pos.y() < self.p_size.y():
                pos.setY(self.p_size.y())
            elif pos.y() > (self.p_size.y() + self.p_size.height()):
                pos.setY(self.p_size.y() + self.p_size.height())
        self.m_smooth_x = pos.x()
        self.m_smooth_y = pos.y()
        if not self.m_smooth:
            self.m_cursor.setPos(pos)
            self.m_lineH.setY(pos.y())
            self.m_lineV.setX(pos.x())
            xp = pos.x() / (self.p_size.x() + self.p_size.width());
            yp = pos.y() / (self.p_size.y() + self.p_size.height());
            self.sendMIDI(xp, yp)
            self.cursorMoved.emit(xp, yp)
    def sendMIDI(self, xp, yp):
        rate = float(0xff) / 4
        msgd = ["cc2" if xp is not None and yp is not None else "cc"]
        if xp is not None:
            msgd.append(self.cc_x)
            msgd.append(int(xp * rate + rate))
        if yp is not None:
            msgd.append(self.cc_y)
            msgd.append(int(yp * rate + rate))
        self.rparent.send(msgd)
    # -------------------------------------------------------------------
    def keyPressEvent(self, event): # QKeyEvent
        event.accept()
    def wheelEvent(self, event): # QGraphicsSceneWheelEvent
        event.accept()
    def mousePressEvent(self, event: QGraphicsSceneMouseEvent):
        self.m_mouseLock = True
        self.handleMousePos(event.scenePos())
        self.rparent.setCursor(Qt.CrossCursor)
        QGraphicsScene.mousePressEvent(self, event);
    def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent):
        self.handleMousePos(event.scenePos())
        QGraphicsScene.mouseMoveEvent(self, event);
    def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent):
        self.m_mouseLock = False
        self.rparent.setCursor(Qt.ArrowCursor)
        QGraphicsScene.mouseReleaseEvent(self, event)
# -----------------------------------------------------------------------
# External UI
class XYControllerUI(ExternalUI, QMainWindow):
    def __init__(self):
        ExternalUI.__init__(self)
        QMainWindow.__init__(self)
        self.ui = ui_xycontroller.Ui_XYControllerW()
        self.ui.setupUi(self)
        self.fSaveSizeNowChecker = -1
        # ---------------------------------------------------------------
        # Set-up GUI stuff
        self.scene = XYGraphicsScene(self)
        self.ui.dial_x.setImage(2)
        self.ui.dial_y.setImage(2)
        self.ui.dial_x.setLabel("X")
        self.ui.dial_y.setLabel("Y")
        self.ui.keyboard.setOctaves(10)
        self.ui.graphicsView.setScene(self.scene)
        self.ui.graphicsView.setRenderHints(QPainter.Antialiasing)
        for MIDI_CC in MIDI_CC_LIST:
            self.ui.cb_control_x.addItem(MIDI_CC)
            self.ui.cb_control_y.addItem(MIDI_CC)
        # ---------------------------------------------------------------
        # Initial state
        self.m_channels = [1]
        self.ui.act_ch_01.setChecked(True)
        self.ui.act_show_keyboard.setChecked(True)
        self.ui.cb_control_y.setCurrentIndex(1)
        # ---------------------------------------------------------------
        # Connect actions to functions
        self.scene.cursorMoved.connect(self.slot_sceneCursorMoved)
        self.ui.keyboard.noteOn.connect(self.slot_noteOn)
        self.ui.keyboard.noteOff.connect(self.slot_noteOff)
        self.ui.cb_smooth.clicked.connect(self.slot_setSmooth)
        self.ui.dial_x.realValueChanged.connect(self.slot_knobValueChangedX)
        self.ui.dial_y.realValueChanged.connect(self.slot_knobValueChangedY)
        if QT_VERSION >= 0x60000:
            self.ui.cb_control_x.currentTextChanged.connect(self.slot_checkCC_X)
            self.ui.cb_control_y.currentTextChanged.connect(self.slot_checkCC_Y)
        else:
            self.ui.cb_control_x.currentIndexChanged[str].connect(self.slot_checkCC_X)
            self.ui.cb_control_y.currentIndexChanged[str].connect(self.slot_checkCC_Y)
        self.ui.act_ch_01.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_02.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_03.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_04.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_05.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_06.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_07.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_08.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_09.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_10.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_11.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_12.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_13.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_14.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_15.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_16.triggered.connect(self.slot_checkChannel)
        self.ui.act_ch_all.triggered.connect(self.slot_checkChannel_all)
        self.ui.act_ch_none.triggered.connect(self.slot_checkChannel_none)
        self.ui.act_show_keyboard.triggered.connect(self.slot_showKeyboard)
        # ---------------------------------------------------------------
        # Final stuff
        self.fIdleTimer = self.startTimer(60)
        self.setWindowTitle(self.fUiName)
        self.ready()
    # -------------------------------------------------------------------
    @pyqtSlot()
    def slot_updateScreen(self):
        self.ui.graphicsView.centerOn(0, 0)
        self.scene.updateSize(self.ui.graphicsView.size())
        dial_x = self.ui.dial_x.rvalue()
        dial_y = self.ui.dial_y.rvalue()
        self.scene.setPosX(dial_x / 100, False)
        self.scene.setPosY(dial_y / 100, False)
        self.scene.setSmoothValues(dial_x / 100, dial_y / 100)
    @pyqtSlot(int)
    def slot_noteOn(self, note):
        self.send(["note", True, note])
    @pyqtSlot(int)
    def slot_noteOff(self, note):
        self.send(["note", False, note])
    @pyqtSlot(float)
    def slot_knobValueChangedX(self, x: float):
        self.sendControl(XYCONTROLLER_PARAMETER_X, x)
        self.scene.setPosX(x / 100, True)
        self.scene.setSmoothValues(x / 100, self.ui.dial_y.rvalue() / 100)
    @pyqtSlot(float)
    def slot_knobValueChangedY(self, y: float):
        self.sendControl(XYCONTROLLER_PARAMETER_Y, y)
        self.scene.setPosY(y / 100, True)
        self.scene.setSmoothValues(self.ui.dial_x.rvalue() / 100, y / 100)
    @pyqtSlot(str)
    def slot_checkCC_X(self, text: str):
        if not text:
            return
        cc_x = int(text.split(" ",1)[0])
        self.scene.setControlX(cc_x)
        self.sendConfigure("cc_x", str(cc_x))
    @pyqtSlot(str)
    def slot_checkCC_Y(self, text: str):
        if not text:
            return
        cc_y = int(text.split(" ",1)[0])
        self.scene.setControlY(cc_y)
        self.sendConfigure("cc_y", str(cc_y))
    @pyqtSlot(bool)
    def slot_checkChannel(self, clicked):
        if not self.sender():
            return
        channel = int(self.sender().text())
        if clicked and channel not in self.m_channels:
            self.m_channels.append(channel)
        elif not clicked and channel in self.m_channels:
            self.m_channels.remove(channel)
        self.scene.setChannels(self.m_channels)
        self.sendConfigure("channels", ",".join(str(c) for c in self.m_channels))
    @pyqtSlot()
    def slot_checkChannel_all(self):
        self.ui.act_ch_01.setChecked(True)
        self.ui.act_ch_02.setChecked(True)
        self.ui.act_ch_03.setChecked(True)
        self.ui.act_ch_04.setChecked(True)
        self.ui.act_ch_05.setChecked(True)
        self.ui.act_ch_06.setChecked(True)
        self.ui.act_ch_07.setChecked(True)
        self.ui.act_ch_08.setChecked(True)
        self.ui.act_ch_09.setChecked(True)
        self.ui.act_ch_10.setChecked(True)
        self.ui.act_ch_11.setChecked(True)
        self.ui.act_ch_12.setChecked(True)
        self.ui.act_ch_13.setChecked(True)
        self.ui.act_ch_14.setChecked(True)
        self.ui.act_ch_15.setChecked(True)
        self.ui.act_ch_16.setChecked(True)
        self.m_channels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
        self.scene.setChannels(self.m_channels)
        self.sendConfigure("channels", ",".join(str(c) for c in self.m_channels))
    @pyqtSlot()
    def slot_checkChannel_none(self):
        self.ui.act_ch_01.setChecked(False)
        self.ui.act_ch_02.setChecked(False)
        self.ui.act_ch_03.setChecked(False)
        self.ui.act_ch_04.setChecked(False)
        self.ui.act_ch_05.setChecked(False)
        self.ui.act_ch_06.setChecked(False)
        self.ui.act_ch_07.setChecked(False)
        self.ui.act_ch_08.setChecked(False)
        self.ui.act_ch_09.setChecked(False)
        self.ui.act_ch_10.setChecked(False)
        self.ui.act_ch_11.setChecked(False)
        self.ui.act_ch_12.setChecked(False)
        self.ui.act_ch_13.setChecked(False)
        self.ui.act_ch_14.setChecked(False)
        self.ui.act_ch_15.setChecked(False)
        self.ui.act_ch_16.setChecked(False)
        self.m_channels = []
        self.scene.setChannels(self.m_channels)
        self.sendConfigure("channels", "")
    @pyqtSlot(bool)
    def slot_setSmooth(self, smooth):
        self.scene.setSmooth(smooth)
        self.sendConfigure("smooth", "yes" if smooth else "no")
        if smooth:
            dial_x = self.ui.dial_x.rvalue()
            dial_y = self.ui.dial_y.rvalue()
            self.scene.setSmoothValues(dial_x / 100, dial_y / 100)
    @pyqtSlot(float, float)
    def slot_sceneCursorMoved(self, xp: float, yp: float):
        self.ui.dial_x.setValue(xp * 100, False)
        self.ui.dial_y.setValue(yp * 100, False)
        self.sendControl(XYCONTROLLER_PARAMETER_X, xp * 100)
        self.sendControl(XYCONTROLLER_PARAMETER_Y, yp * 100)
    @pyqtSlot(bool)
    def slot_showKeyboard(self, yesno):
        self.ui.scrollArea.setVisible(yesno)
        self.sendConfigure("show-midi-keyboard", "yes" if yesno else "no")
        QTimer.singleShot(0, self.slot_updateScreen)
    # -------------------------------------------------------------------
    # DSP Callbacks
    def dspParameterChanged(self, index: int, value: float):
        if index == XYCONTROLLER_PARAMETER_X:
            self.ui.dial_x.setValue(value, False)
            self.scene.setPosX(value / 100, False)
        elif index == XYCONTROLLER_PARAMETER_Y:
            self.ui.dial_y.setValue(value, False)
            self.scene.setPosY(value / 100, False)
        else:
            return
        self.scene.setSmoothValues(self.ui.dial_x.rvalue() / 100,
                                   self.ui.dial_y.rvalue() / 100)
    def dspStateChanged(self, key: str, value: str):
        if key == "guiWidth":
            try:
                width = int(value)
            except:
                width = 0
            if width > 0:
                self.resize(width, self.height())
        elif key == "guiHeight":
            try:
                height = int(value)
            except:
                height = 0
            if height > 0:
                self.resize(self.width(), height)
        elif key == "smooth":
            smooth = (value == "yes")
            self.ui.cb_smooth.blockSignals(True)
            self.ui.cb_smooth.setChecked(smooth)
            self.ui.cb_smooth.blockSignals(False)
            self.scene.setSmooth(smooth)
            if smooth:
                dial_x = self.ui.dial_x.rvalue()
                dial_y = self.ui.dial_y.rvalue()
                self.scene.setSmoothValues(dial_x / 100, dial_y / 100)
        elif key == "show-midi-keyboard":
            show = (value == "yes")
            self.ui.act_show_keyboard.blockSignals(True)
            self.ui.act_show_keyboard.setChecked(show)
            self.ui.act_show_keyboard.blockSignals(False)
            self.ui.scrollArea.setVisible(show)
        elif key == "channels":
            if value:
                self.m_channels = [int(c) for c in value.split(",")]
            else:
                self.m_channels = []
            self.scene.setChannels(self.m_channels)
            self.ui.act_ch_01.setChecked(bool(1 in self.m_channels))
            self.ui.act_ch_02.setChecked(bool(2 in self.m_channels))
            self.ui.act_ch_03.setChecked(bool(3 in self.m_channels))
            self.ui.act_ch_04.setChecked(bool(4 in self.m_channels))
            self.ui.act_ch_05.setChecked(bool(5 in self.m_channels))
            self.ui.act_ch_06.setChecked(bool(6 in self.m_channels))
            self.ui.act_ch_07.setChecked(bool(7 in self.m_channels))
            self.ui.act_ch_08.setChecked(bool(8 in self.m_channels))
            self.ui.act_ch_09.setChecked(bool(9 in self.m_channels))
            self.ui.act_ch_10.setChecked(bool(10 in self.m_channels))
            self.ui.act_ch_11.setChecked(bool(11 in self.m_channels))
            self.ui.act_ch_12.setChecked(bool(12 in self.m_channels))
            self.ui.act_ch_13.setChecked(bool(13 in self.m_channels))
            self.ui.act_ch_14.setChecked(bool(14 in self.m_channels))
            self.ui.act_ch_15.setChecked(bool(15 in self.m_channels))
            self.ui.act_ch_16.setChecked(bool(16 in self.m_channels))
        elif key == "cc_x":
            cc_x = int(value)
            self.scene.setControlX(cc_x)
            for cc_index in range(len(MIDI_CC_LIST)):
                if cc_x == int(MIDI_CC_LIST[cc_index].split(" ",1)[0]):
                    self.ui.cb_control_x.blockSignals(True)
                    self.ui.cb_control_x.setCurrentIndex(cc_index)
                    self.ui.cb_control_x.blockSignals(False)
                    break
        elif key == "cc_y":
            cc_y = int(value)
            self.scene.setControlY(cc_y)
            for cc_index in range(len(MIDI_CC_LIST)):
                if cc_y == int(MIDI_CC_LIST[cc_index].split(" ",1)[0]):
                    self.ui.cb_control_y.blockSignals(True)
                    self.ui.cb_control_y.setCurrentIndex(cc_index)
                    self.ui.cb_control_y.blockSignals(False)
                    break
    def dspNoteReceived(self, onOff, channel, note, velocity):
        if channel+1 not in self.m_channels:
            return
        if onOff:
            self.ui.keyboard.sendNoteOn(note, False)
        else:
            self.ui.keyboard.sendNoteOff(note, False)
    # -------------------------------------------------------------------
    # ExternalUI Callbacks
    def uiShow(self):
        self.show()
    def uiFocus(self):
        self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive)
        self.show()
        self.raise_()
        self.activateWindow()
    def uiHide(self):
        self.hide()
    def uiQuit(self):
        self.closeExternalUI()
        self.close()
        app.quit()
    def uiTitleChanged(self, uiTitle):
        self.setWindowTitle(uiTitle)
    # -------------------------------------------------------------------
    # Qt events
    def showEvent(self, event):
        self.slot_updateScreen()
        QMainWindow.showEvent(self, event)
    def resizeEvent(self, event):
        self.fSaveSizeNowChecker = 0
        self.slot_updateScreen()
        QMainWindow.resizeEvent(self, event)
    def timerEvent(self, event):
        if event.timerId() == self.fIdleTimer:
            self.idleExternalUI()
            self.scene.updateSmooth()
            if self.fSaveSizeNowChecker == 11:
                self.sendConfigure("guiWidth", str(self.width()))
                self.sendConfigure("guiHeight", str(self.height()))
                self.fSaveSizeNowChecker = -1
            elif self.fSaveSizeNowChecker >= 0:
                self.fSaveSizeNowChecker += 1
        QMainWindow.timerEvent(self, event)
    def closeEvent(self, event):
        self.closeExternalUI()
        QMainWindow.closeEvent(self, event)
        # there might be other qt windows open which will block the UI from quitting
        app.quit()
#--------------- main ------------------
if __name__ == '__main__':
    import resources_rc
    pathBinaries, _ = getPaths()
    gCarla.utils = CarlaUtils(os.path.join(pathBinaries, "libcarla_utils." + DLL_EXTENSION))
    gCarla.utils.set_process_name("XYController")
    app = CarlaApplication("XYController")
    gui = XYControllerUI()
    app.exit_exec()
 |