1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Carla plugin host (plugin UI)
5# Copyright (C) 2013-2020 Filipe Coelho <falktx@falktx.com>
6#
7# This program is free software; you can redistribute it and/or
8# modify it under the terms of the GNU General Public License as
9# published by the Free Software Foundation; either version 2 of
10# the License, or any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# For a full copy of the GNU General Public License see the GPL.txt file
18
19# ------------------------------------------------------------------------------------------------------------
20# Imports (Global)
21
22from PyQt5.QtGui import QKeySequence, QMouseEvent
23from PyQt5.QtWidgets import QFrame, QSplitter
24
25# ------------------------------------------------------------------------------------------------------------
26# Imports (Custom Stuff)
27
28from carla_backend_qt import CarlaHostQtPlugin
29from carla_host import *
30from externalui import ExternalUI
31
32# ------------------------------------------------------------------------------------------------------------
33# Host Plugin object
34
35class PluginHost(CarlaHostQtPlugin):
36    def __init__(self):
37        CarlaHostQtPlugin.__init__(self)
38
39        if False:
40            # kdevelop likes this :)
41            self.fExternalUI = ExternalUI()
42
43        # ---------------------------------------------------------------
44
45        self.fExternalUI = None
46
47    # -------------------------------------------------------------------
48
49    def setExternalUI(self, extUI):
50        self.fExternalUI = extUI
51
52    def sendMsg(self, lines):
53        if self.fExternalUI is None:
54            return False
55
56        return self.fExternalUI.send(lines)
57
58    # -------------------------------------------------------------------
59
60    def engine_init(self, driverName, clientName):
61        return True
62
63    def engine_close(self):
64        return True
65
66    def engine_idle(self):
67        self.fExternalUI.idleExternalUI()
68
69    def is_engine_running(self):
70        if self.fExternalUI is None:
71            return False
72
73        return self.fExternalUI.isRunning()
74
75    def set_engine_about_to_close(self):
76        return True
77
78    def get_host_osc_url_tcp(self):
79        return self.tr("(OSC TCP port not provided in Plugin version)")
80
81# ------------------------------------------------------------------------------------------------------------
82# Main Window
83
84class CarlaMiniW(ExternalUI, HostWindow):
85    def __init__(self, host, isPatchbay, parent=None):
86        ExternalUI.__init__(self)
87        HostWindow.__init__(self, host, isPatchbay, parent)
88
89        if False:
90            # kdevelop likes this :)
91            host = PluginHost()
92
93        self.host = host
94
95        host.setExternalUI(self)
96
97        self.fFirstInit = True
98
99        self.setWindowTitle(self.fUiName)
100        self.ready()
101
102    # Override this as it can be called from several places.
103    # We really need to close all UIs as events are driven by host idle which is only available when UI is visible
104    def closeExternalUI(self):
105        for i in reversed(range(self.fPluginCount)):
106            self.host.show_custom_ui(i, False)
107
108        ExternalUI.closeExternalUI(self)
109
110    # -------------------------------------------------------------------
111    # ExternalUI Callbacks
112
113    def uiShow(self):
114        if self.parent() is not None:
115            return
116        self.show()
117
118    def uiFocus(self):
119        if self.parent() is not None:
120            return
121
122        self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive)
123        self.show()
124
125        self.raise_()
126        self.activateWindow()
127
128    def uiHide(self):
129        if self.parent() is not None:
130            return
131        self.hide()
132
133    def uiQuit(self):
134        self.closeExternalUI()
135        self.close()
136
137        if self != gui:
138            gui.close()
139
140        # there might be other qt windows open which will block carla-plugin from quitting
141        app.quit()
142
143    def uiTitleChanged(self, uiTitle):
144        self.setWindowTitle(uiTitle)
145
146    # -------------------------------------------------------------------
147    # Qt events
148
149    def closeEvent(self, event):
150        self.closeExternalUI()
151        HostWindow.closeEvent(self, event)
152
153        # there might be other qt windows open which will block carla-plugin from quitting
154        app.quit()
155
156    # -------------------------------------------------------------------
157    # Custom callback
158
159    def msgCallback(self, msg):
160        try:
161            self.msgCallback2(msg)
162        except Exception as e:
163            print("msgCallback error, skipped for", msg, "error was:\n", e)
164
165    def msgCallback2(self, msg):
166        msg = charPtrToString(msg)
167
168        #if not msg:
169            #return
170
171        if msg == "runtime-info":
172            values = self.readlineblock().split(":")
173            load = float(values[0])
174            xruns = int(values[1])
175            self.host._set_runtime_info(load, xruns)
176
177        elif msg == "project-folder":
178            self.fProjectFilename = self.readlineblock()
179
180        elif msg == "transport":
181            playing = self.readlineblock_bool()
182            frame, bar, beat, tick = [int(i) for i in self.readlineblock().split(":")]
183            bpm = self.readlineblock_float()
184            self.host._set_transport(playing, frame, bar, beat, tick, bpm)
185
186        elif msg.startswith("PEAKS_"):
187            pluginId = int(msg.replace("PEAKS_", ""))
188            in1, in2, out1, out2 = [float(i) for i in self.readlineblock().split(":")]
189            self.host._set_peaks(pluginId, in1, in2, out1, out2)
190
191        elif msg.startswith("PARAMVAL_"):
192            pluginId, paramId = [int(i) for i in msg.replace("PARAMVAL_", "").split(":")]
193            paramValue = self.readlineblock_float()
194            if paramId < 0:
195                self.host._set_internalValue(pluginId, paramId, paramValue)
196            else:
197                self.host._set_parameterValue(pluginId, paramId, paramValue)
198
199        elif msg.startswith("ENGINE_CALLBACK_"):
200            action   = int(msg.replace("ENGINE_CALLBACK_", ""))
201            pluginId = self.readlineblock_int()
202            value1   = self.readlineblock_int()
203            value2   = self.readlineblock_int()
204            value3   = self.readlineblock_int()
205            valuef   = self.readlineblock_float()
206            valueStr = self.readlineblock()
207
208            self.host._setViaCallback(action, pluginId, value1, value2, value3, valuef, valueStr)
209            engineCallback(self.host, action, pluginId, value1, value2, value3, valuef, valueStr)
210
211        elif msg.startswith("ENGINE_OPTION_"):
212            option = int(msg.replace("ENGINE_OPTION_", ""))
213            forced = self.readlineblock_bool()
214            value  = self.readlineblock()
215
216            if self.fFirstInit and not forced:
217                return
218
219            if option == ENGINE_OPTION_PROCESS_MODE:
220                self.host.processMode = int(value)
221            elif option == ENGINE_OPTION_TRANSPORT_MODE:
222                self.host.transportMode = int(value)
223            elif option == ENGINE_OPTION_FORCE_STEREO:
224                self.host.forceStereo = bool(value == "true")
225            elif option == ENGINE_OPTION_PREFER_PLUGIN_BRIDGES:
226                self.host.preferPluginBridges = bool(value == "true")
227            elif option == ENGINE_OPTION_PREFER_UI_BRIDGES:
228                self.host.preferUIBridges = bool(value == "true")
229            elif option == ENGINE_OPTION_UIS_ALWAYS_ON_TOP:
230                self.host.uisAlwaysOnTop = bool(value == "true")
231            elif option == ENGINE_OPTION_MAX_PARAMETERS:
232                self.host.maxParameters = int(value)
233            elif option == ENGINE_OPTION_UI_BRIDGES_TIMEOUT:
234                self.host.uiBridgesTimeout = int(value)
235            elif option == ENGINE_OPTION_PATH_BINARIES:
236                self.host.pathBinaries = value
237            elif option == ENGINE_OPTION_PATH_RESOURCES:
238                self.host.pathResources = value
239
240        elif msg.startswith("PLUGIN_INFO_"):
241            pluginId = int(msg.replace("PLUGIN_INFO_", ""))
242            self.host._add(pluginId)
243
244            type_, category, hints, uniqueId, optsAvail, optsEnabled = [int(i) for i in self.readlineblock().split(":")]
245            filename  = self.readlineblock()
246            name      = self.readlineblock()
247            iconName  = self.readlineblock()
248            realName  = self.readlineblock()
249            label     = self.readlineblock()
250            maker     = self.readlineblock()
251            copyright = self.readlineblock()
252
253            pinfo = {
254                'type': type_,
255                'category': category,
256                'hints': hints,
257                'optionsAvailable': optsAvail,
258                'optionsEnabled': optsEnabled,
259                'filename': filename,
260                'name':  name,
261                'label': label,
262                'maker': maker,
263                'copyright': copyright,
264                'iconName': iconName,
265                'patchbayClientId': 0,
266                'uniqueId': uniqueId
267            }
268            self.host._set_pluginInfo(pluginId, pinfo)
269            self.host._set_pluginRealName(pluginId, realName)
270
271        elif msg.startswith("AUDIO_COUNT_"):
272            pluginId, ins, outs = [int(i) for i in msg.replace("AUDIO_COUNT_", "").split(":")]
273            self.host._set_audioCountInfo(pluginId, {'ins': ins, 'outs': outs})
274
275        elif msg.startswith("MIDI_COUNT_"):
276            pluginId, ins, outs = [int(i) for i in msg.replace("MIDI_COUNT_", "").split(":")]
277            self.host._set_midiCountInfo(pluginId, {'ins': ins, 'outs': outs})
278
279        elif msg.startswith("PARAMETER_COUNT_"):
280            pluginId, ins, outs, count = [int(i) for i in msg.replace("PARAMETER_COUNT_", "").split(":")]
281            self.host._set_parameterCountInfo(pluginId, count, {'ins': ins, 'outs': outs})
282
283        elif msg.startswith("PARAMETER_DATA_"):
284            pluginId, paramId = [int(i) for i in msg.replace("PARAMETER_DATA_", "").split(":")]
285            paramType, paramHints, mappedControlIndex, midiChannel = [int(i) for i in self.readlineblock().split(":")]
286            mappedMinimum, mappedMaximum = [float(i) for i in self.readlineblock().split(":")]
287            paramName = self.readlineblock()
288            paramUnit = self.readlineblock()
289            paramComment = self.readlineblock()
290            paramGroupName = self.readlineblock()
291
292            paramInfo = {
293                'name': paramName,
294                'symbol': "",
295                'unit': paramUnit,
296                'comment': paramComment,
297                'groupName': paramGroupName,
298                'scalePointCount': 0,
299            }
300            self.host._set_parameterInfo(pluginId, paramId, paramInfo)
301
302            paramData = {
303                'type': paramType,
304                'hints': paramHints,
305                'index': paramId,
306                'rindex': -1,
307                'midiChannel': midiChannel,
308                'mappedControlIndex': mappedControlIndex,
309                'mappedMinimum': mappedMinimum,
310                'mappedMaximum': mappedMaximum,
311            }
312            self.host._set_parameterData(pluginId, paramId, paramData)
313
314        elif msg.startswith("PARAMETER_RANGES_"):
315            pluginId, paramId = [int(i) for i in msg.replace("PARAMETER_RANGES_", "").split(":")]
316            def_, min_, max_, step, stepSmall, stepLarge = [float(i) for i in self.readlineblock().split(":")]
317
318            paramRanges = {
319                'def': def_,
320                'min': min_,
321                'max': max_,
322                'step': step,
323                'stepSmall': stepSmall,
324                'stepLarge': stepLarge
325            }
326            self.host._set_parameterRanges(pluginId, paramId, paramRanges)
327
328        elif msg.startswith("PROGRAM_COUNT_"):
329            pluginId, count, current = [int(i) for i in msg.replace("PROGRAM_COUNT_", "").split(":")]
330            self.host._set_programCount(pluginId, count)
331            self.host._set_currentProgram(pluginId, current)
332
333        elif msg.startswith("PROGRAM_NAME_"):
334            pluginId, progId = [int(i) for i in msg.replace("PROGRAM_NAME_", "").split(":")]
335            progName = self.readlineblock()
336            self.host._set_programName(pluginId, progId, progName)
337
338        elif msg.startswith("MIDI_PROGRAM_COUNT_"):
339            pluginId, count, current = [int(i) for i in msg.replace("MIDI_PROGRAM_COUNT_", "").split(":")]
340            self.host._set_midiProgramCount(pluginId, count)
341            self.host._set_currentMidiProgram(pluginId, current)
342
343        elif msg.startswith("MIDI_PROGRAM_DATA_"):
344            pluginId, midiProgId = [int(i) for i in msg.replace("MIDI_PROGRAM_DATA_", "").split(":")]
345            bank, program = [int(i) for i in self.readlineblock().split(":")]
346            name = self.readlineblock()
347            self.host._set_midiProgramData(pluginId, midiProgId, {'bank': bank, 'program': program, 'name': name})
348
349        elif msg.startswith("CUSTOM_DATA_COUNT_"):
350            pluginId, count = [int(i) for i in msg.replace("CUSTOM_DATA_COUNT_", "").split(":")]
351            self.host._set_customDataCount(pluginId, count)
352
353        elif msg.startswith("CUSTOM_DATA_"):
354            pluginId, customDataId = [int(i) for i in msg.replace("CUSTOM_DATA_", "").split(":")]
355
356            type_ = self.readlineblock()
357            key   = self.readlineblock()
358            value = self.readlineblock()
359            self.host._set_customData(pluginId, customDataId, {'type': type_, 'key': key, 'value': value})
360
361        elif msg == "osc-urls":
362            tcp = self.readlineblock()
363            udp = self.readlineblock()
364            self.host.fOscTCP = tcp
365            self.host.fOscUDP = udp
366
367        elif msg == "max-plugin-number":
368            maxnum = self.readlineblock_int()
369            self.host.fMaxPluginNumber = maxnum
370
371        elif msg == "buffer-size":
372            bufsize = self.readlineblock_int()
373            self.host.fBufferSize = bufsize
374
375        elif msg == "sample-rate":
376            srate = self.readlineblock_float()
377            self.host.fSampleRate = srate
378
379        elif msg == "error":
380            error = self.readlineblock()
381            engineCallback(self.host, ENGINE_CALLBACK_ERROR, 0, 0, 0, 0, 0.0, error)
382
383        elif msg == "show":
384            self.fFirstInit = False
385            self.uiShow()
386
387        elif msg == "focus":
388            self.uiFocus()
389
390        elif msg == "hide":
391            self.uiHide()
392
393        elif msg == "quit":
394            self.fQuitReceived = True
395            self.uiQuit()
396
397        elif msg == "uiTitle":
398            uiTitle = self.readlineblock()
399            self.uiTitleChanged(uiTitle)
400
401        else:
402            print("unknown message: \"" + msg + "\"")
403
404# ------------------------------------------------------------------------------------------------------------
405# Embed Widget
406
407class QEmbedWidget(QWidget):
408    def __init__(self, winId):
409        QWidget.__init__(self)
410        self.setAttribute(Qt.WA_LayoutUsesWidgetRect)
411        self.move(0, 0)
412
413        self.fPos = (0, 0)
414        self.fWinId = 0
415
416    def finalSetup(self, gui, winId):
417        self.fWinId = int(self.winId())
418        gui.ui.centralwidget.installEventFilter(self)
419        gui.ui.menubar.installEventFilter(self)
420        gCarla.utils.x11_reparent_window(self.fWinId, winId)
421        self.show()
422
423    def fixPosition(self):
424        pos = gCarla.utils.x11_get_window_pos(self.fWinId)
425        if self.fPos == pos:
426            return
427        self.fPos = pos
428        self.move(pos[0], pos[1])
429        gCarla.utils.x11_move_window(self.fWinId, pos[2], pos[3])
430
431    def eventFilter(self, obj, ev):
432        if isinstance(ev, QMouseEvent):
433            self.fixPosition()
434        return False
435
436    def enterEvent(self, ev):
437        self.fixPosition()
438        QWidget.enterEvent(self, ev)
439
440# ------------------------------------------------------------------------------------------------------------
441# Embed plugin UI
442
443class CarlaEmbedW(QEmbedWidget):
444    def __init__(self, host, winId, isPatchbay):
445        QEmbedWidget.__init__(self, winId)
446
447        if False:
448            host = CarlaHostPlugin()
449
450        self.host = host
451        self.fWinId = winId
452        self.setFixedSize(1024, 712)
453
454        self.fLayout = QVBoxLayout(self)
455        self.fLayout.setContentsMargins(0, 0, 0, 0)
456        self.fLayout.setSpacing(0)
457        self.setLayout(self.fLayout)
458
459        self.gui = CarlaMiniW(host, isPatchbay, self)
460        self.gui.hide()
461
462        self.gui.ui.act_file_quit.setEnabled(False)
463        self.gui.ui.act_file_quit.setVisible(False)
464
465        self.fShortcutActions = []
466        self.addShortcutActions(self.gui.ui.menu_File.actions())
467        self.addShortcutActions(self.gui.ui.menu_Plugin.actions())
468        self.addShortcutActions(self.gui.ui.menu_PluginMacros.actions())
469        self.addShortcutActions(self.gui.ui.menu_Settings.actions())
470        self.addShortcutActions(self.gui.ui.menu_Help.actions())
471
472        if self.host.processMode == ENGINE_PROCESS_MODE_PATCHBAY:
473            self.addShortcutActions(self.gui.ui.menu_Canvas.actions())
474            self.addShortcutActions(self.gui.ui.menu_Canvas_Zoom.actions())
475
476        self.addWidget(self.gui.ui.menubar)
477        self.addLine()
478        self.addWidget(self.gui.ui.toolBar)
479
480        if self.host.processMode == ENGINE_PROCESS_MODE_PATCHBAY:
481            self.addLine()
482
483        self.fCentralSplitter = QSplitter(self)
484        policy = self.fCentralSplitter.sizePolicy()
485        policy.setVerticalStretch(1)
486        self.fCentralSplitter.setSizePolicy(policy)
487
488        self.addCentralWidget(self.gui.ui.dockWidget)
489        self.addCentralWidget(self.gui.centralWidget())
490        self.fLayout.addWidget(self.fCentralSplitter)
491
492        self.finalSetup(self.gui, winId)
493
494    def addShortcutActions(self, actions):
495        for action in actions:
496            if not action.shortcut().isEmpty():
497                self.fShortcutActions.append(action)
498
499    def addWidget(self, widget):
500        widget.setParent(self)
501        self.fLayout.addWidget(widget)
502
503    def addCentralWidget(self, widget):
504        widget.setParent(self)
505        self.fCentralSplitter.addWidget(widget)
506
507    def addLine(self):
508        line = QFrame(self)
509        line.setFrameShadow(QFrame.Sunken)
510        line.setFrameShape(QFrame.HLine)
511        line.setLineWidth(0)
512        line.setMidLineWidth(1)
513        self.fLayout.addWidget(line)
514
515    def keyPressEvent(self, event):
516        modifiers    = event.modifiers()
517        modifiersStr = ""
518
519        if modifiers & Qt.ShiftModifier:
520            modifiersStr += "Shift+"
521        if modifiers & Qt.ControlModifier:
522            modifiersStr += "Ctrl+"
523        if modifiers & Qt.AltModifier:
524            modifiersStr += "Alt+"
525        if modifiers & Qt.MetaModifier:
526            modifiersStr += "Meta+"
527
528        keyStr = QKeySequence(event.key()).toString()
529        keySeq = QKeySequence(modifiersStr + keyStr)
530
531        for action in self.fShortcutActions:
532            if not action.isEnabled():
533                continue
534            if keySeq.matches(action.shortcut()) != QKeySequence.ExactMatch:
535                continue
536            event.accept()
537            action.trigger()
538            return
539
540        QEmbedWidget.keyPressEvent(self, event)
541
542    def showEvent(self, event):
543        QEmbedWidget.showEvent(self, event)
544
545        if QT_VERSION >= 0x50600:
546            self.host.set_engine_option(ENGINE_OPTION_FRONTEND_UI_SCALE, int(self.devicePixelRatioF() * 1000), "")
547            print("Plugin UI pixel ratio is", self.devicePixelRatioF(),
548                  "with %ix%i" % (self.width(), self.height()), "in size")
549
550        # set our gui as parent for all plugins UIs
551        if self.host.manageUIs:
552            if MACOS:
553                nsViewPtr = int(self.fWinId)
554                winIdStr  = "%x" % gCarla.utils.cocoa_get_window(nsViewPtr)
555            else:
556                winIdStr = "%x" % int(self.fWinId)
557            self.host.set_engine_option(ENGINE_OPTION_FRONTEND_WIN_ID, 0, winIdStr)
558
559    def hideEvent(self, event):
560        # disable parent
561        self.host.set_engine_option(ENGINE_OPTION_FRONTEND_WIN_ID, 0, "0")
562
563        QEmbedWidget.hideEvent(self, event)
564
565    def closeEvent(self, event):
566        self.gui.close()
567        self.gui.closeExternalUI()
568        QEmbedWidget.closeEvent(self, event)
569
570        # there might be other qt windows open which will block carla-plugin from quitting
571        app.quit()
572
573    def setLoadRDFsNeeded(self):
574        self.gui.setLoadRDFsNeeded()
575
576# ------------------------------------------------------------------------------------------------------------
577# Main
578
579if __name__ == '__main__':
580    # -------------------------------------------------------------
581    # Get details regarding target usage
582
583    try:
584        winId = int(os.getenv("CARLA_PLUGIN_EMBED_WINID"))
585    except:
586        winId = 0
587
588    usingEmbed = bool(LINUX and winId != 0)
589
590    # -------------------------------------------------------------
591    # Init host backend (part 1)
592
593    isPatchbay = sys.argv[0].rsplit(os.path.sep)[-1].lower().replace(".exe","") == "carla-plugin-patchbay"
594
595    host = initHost("Carla-Plugin", None, False, True, True, PluginHost)
596    host.processMode       = ENGINE_PROCESS_MODE_PATCHBAY if isPatchbay else ENGINE_PROCESS_MODE_CONTINUOUS_RACK
597    host.processModeForced = True
598    host.nextProcessMode   = host.processMode
599
600    # -------------------------------------------------------------
601    # Set-up environment
602
603    gCarla.utils.setenv("CARLA_PLUGIN_EMBED_WINID", "0")
604
605    if usingEmbed:
606        gCarla.utils.setenv("QT_QPA_PLATFORM", "xcb")
607
608    # -------------------------------------------------------------
609    # App initialization
610
611    app = CarlaApplication("Carla2-Plugin")
612
613    # -------------------------------------------------------------
614    # Set-up custom signal handling
615
616    setUpSignals()
617
618    # -------------------------------------------------------------
619    # Init host backend (part 2)
620
621    loadHostSettings(host)
622
623    # -------------------------------------------------------------
624    # Create GUI
625
626    if usingEmbed:
627        gui = CarlaEmbedW(host, winId, isPatchbay)
628    else:
629        gui = CarlaMiniW(host, isPatchbay)
630
631    # -------------------------------------------------------------
632    # App-Loop
633
634    app.exit_exec()
635