Audio plugin host https://kx.studio/carla
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

585 lines
21KB

  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. # PatchBay Canvas engine using QGraphicsView/Scene
  4. # Copyright (C) 2010-2019 Filipe Coelho <falktx@falktx.com>
  5. #
  6. # This program is free software; you can redistribute it and/or
  7. # modify it under the terms of the GNU General Public License as
  8. # published by the Free Software Foundation; either version 2 of
  9. # the License, or any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # For a full copy of the GNU General Public License see the doc/GPL.txt file.
  17. # ------------------------------------------------------------------------------------------------------------
  18. # Imports (Global)
  19. from PyQt5.QtCore import qCritical, Qt, QPointF, QRectF, QTimer
  20. from PyQt5.QtGui import QCursor, QFont, QFontMetrics, QLinearGradient, QPainter, QPen
  21. from PyQt5.QtWidgets import QGraphicsItem, QMenu
  22. # ------------------------------------------------------------------------------------------------------------
  23. # Imports (Custom)
  24. from . import (
  25. canvas,
  26. features,
  27. options,
  28. port_dict_t,
  29. CanvasBoxType,
  30. ANTIALIASING_FULL,
  31. ACTION_PLUGIN_EDIT,
  32. ACTION_PLUGIN_SHOW_UI,
  33. ACTION_PLUGIN_CLONE,
  34. ACTION_PLUGIN_REMOVE,
  35. ACTION_PLUGIN_RENAME,
  36. ACTION_PLUGIN_REPLACE,
  37. ACTION_GROUP_INFO,
  38. ACTION_GROUP_JOIN,
  39. ACTION_GROUP_SPLIT,
  40. ACTION_GROUP_RENAME,
  41. ACTION_PORTS_DISCONNECT,
  42. EYECANDY_FULL,
  43. PORT_MODE_NULL,
  44. PORT_MODE_INPUT,
  45. PORT_MODE_OUTPUT,
  46. PORT_TYPE_NULL,
  47. PORT_TYPE_AUDIO_JACK,
  48. PORT_TYPE_MIDI_ALSA,
  49. PORT_TYPE_MIDI_JACK,
  50. PORT_TYPE_PARAMETER,
  51. MAX_PLUGIN_ID_ALLOWED,
  52. )
  53. from .canvasboxshadow import CanvasBoxShadow
  54. from .canvasicon import CanvasIcon
  55. from .canvasport import CanvasPort
  56. from .theme import Theme
  57. from .utils import CanvasItemFX, CanvasGetFullPortName, CanvasGetPortConnectionList
  58. # ------------------------------------------------------------------------------------------------------------
  59. class cb_line_t(object):
  60. def __init__(self, line, connection_id):
  61. self.line = line
  62. self.connection_id = connection_id
  63. # ------------------------------------------------------------------------------------------------------------
  64. class CanvasBox(QGraphicsItem):
  65. def __init__(self, group_id, group_name, icon, parent=None):
  66. QGraphicsItem.__init__(self)
  67. self.setParentItem(parent)
  68. # Save Variables, useful for later
  69. self.m_group_id = group_id
  70. self.m_group_name = group_name
  71. # plugin Id, < 0 if invalid
  72. self.m_plugin_id = -1
  73. self.m_plugin_ui = False
  74. # Base Variables
  75. self.p_width = 50
  76. self.p_height = canvas.theme.box_header_height + canvas.theme.box_header_spacing + 1
  77. self.m_last_pos = QPointF()
  78. self.m_splitted = False
  79. self.m_splitted_mode = PORT_MODE_NULL
  80. self.m_cursor_moving = False
  81. self.m_forced_split = False
  82. self.m_mouse_down = False
  83. self.m_port_list_ids = []
  84. self.m_connection_lines = []
  85. # Set Font
  86. self.m_font_name = QFont()
  87. self.m_font_name.setFamily(canvas.theme.box_font_name)
  88. self.m_font_name.setPixelSize(canvas.theme.box_font_size)
  89. self.m_font_name.setWeight(canvas.theme.box_font_state)
  90. self.m_font_port = QFont()
  91. self.m_font_port.setFamily(canvas.theme.port_font_name)
  92. self.m_font_port.setPixelSize(canvas.theme.port_font_size)
  93. self.m_font_port.setWeight(canvas.theme.port_font_state)
  94. # Icon
  95. if canvas.theme.box_use_icon:
  96. self.icon_svg = CanvasIcon(icon, self.m_group_name, self)
  97. else:
  98. self.icon_svg = None
  99. # Shadow
  100. if options.eyecandy:
  101. self.shadow = CanvasBoxShadow(self.toGraphicsObject())
  102. self.shadow.setFakeParent(self)
  103. self.setGraphicsEffect(self.shadow)
  104. else:
  105. self.shadow = None
  106. # Final touches
  107. self.setFlags(QGraphicsItem.ItemIsFocusable | QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable)
  108. # Wait for at least 1 port
  109. if options.auto_hide_groups:
  110. self.setVisible(False)
  111. if options.auto_select_items:
  112. self.setAcceptHoverEvents(True)
  113. self.updatePositions()
  114. canvas.scene.addItem(self)
  115. QTimer.singleShot(0, self.fixPos)
  116. def getGroupId(self):
  117. return self.m_group_id
  118. def getGroupName(self):
  119. return self.m_group_name
  120. def isSplitted(self):
  121. return self.m_splitted
  122. def getSplittedMode(self):
  123. return self.m_splitted_mode
  124. def getPortCount(self):
  125. return len(self.m_port_list_ids)
  126. def getPortList(self):
  127. return self.m_port_list_ids
  128. def setAsPlugin(self, plugin_id, hasUi):
  129. self.m_plugin_id = plugin_id
  130. self.m_plugin_ui = hasUi
  131. def setIcon(self, icon):
  132. if self.icon_svg is not None:
  133. self.icon_svg.setIcon(icon, self.m_group_name)
  134. def setSplit(self, split, mode=PORT_MODE_NULL):
  135. self.m_splitted = split
  136. self.m_splitted_mode = mode
  137. def setGroupName(self, group_name):
  138. self.m_group_name = group_name
  139. self.updatePositions()
  140. def setShadowOpacity(self, opacity):
  141. if self.shadow:
  142. self.shadow.setOpacity(opacity)
  143. def addPortFromGroup(self, port_id, port_mode, port_type, port_name, is_alternate):
  144. if len(self.m_port_list_ids) == 0:
  145. if options.auto_hide_groups:
  146. if options.eyecandy == EYECANDY_FULL:
  147. CanvasItemFX(self, True, False)
  148. self.setVisible(True)
  149. new_widget = CanvasPort(self.m_group_id, port_id, port_name, port_mode, port_type, is_alternate, self)
  150. port_dict = port_dict_t()
  151. port_dict.group_id = self.m_group_id
  152. port_dict.port_id = port_id
  153. port_dict.port_name = port_name
  154. port_dict.port_mode = port_mode
  155. port_dict.port_type = port_type
  156. port_dict.is_alternate = is_alternate
  157. port_dict.widget = new_widget
  158. self.m_port_list_ids.append(port_id)
  159. return new_widget
  160. def removePortFromGroup(self, port_id):
  161. if port_id in self.m_port_list_ids:
  162. self.m_port_list_ids.remove(port_id)
  163. else:
  164. qCritical("PatchCanvas::CanvasBox.removePort(%i) - unable to find port to remove" % port_id)
  165. return
  166. if len(self.m_port_list_ids) > 0:
  167. self.updatePositions()
  168. elif self.isVisible():
  169. if options.auto_hide_groups:
  170. if options.eyecandy == EYECANDY_FULL:
  171. CanvasItemFX(self, False, False)
  172. else:
  173. self.setVisible(False)
  174. def addLineFromGroup(self, line, connection_id):
  175. new_cbline = cb_line_t(line, connection_id)
  176. self.m_connection_lines.append(new_cbline)
  177. def removeLineFromGroup(self, connection_id):
  178. for connection in self.m_connection_lines:
  179. if connection.connection_id == connection_id:
  180. self.m_connection_lines.remove(connection)
  181. return
  182. qCritical("PatchCanvas::CanvasBox.removeLineFromGroup(%i) - unable to find line to remove" % connection_id)
  183. def checkItemPos(self):
  184. if not canvas.size_rect.isNull():
  185. pos = self.scenePos()
  186. if not (canvas.size_rect.contains(pos) and
  187. canvas.size_rect.contains(pos + QPointF(self.p_width, self.p_height))):
  188. if pos.x() < canvas.size_rect.x():
  189. self.setPos(canvas.size_rect.x(), pos.y())
  190. elif pos.x() + self.p_width > canvas.size_rect.width():
  191. self.setPos(canvas.size_rect.width() - self.p_width, pos.y())
  192. pos = self.scenePos()
  193. if pos.y() < canvas.size_rect.y():
  194. self.setPos(pos.x(), canvas.size_rect.y())
  195. elif pos.y() + self.p_height > canvas.size_rect.height():
  196. self.setPos(pos.x(), canvas.size_rect.height() - self.p_height)
  197. def removeIconFromScene(self):
  198. if self.icon_svg is None:
  199. return
  200. item = self.icon_svg
  201. self.icon_svg = None
  202. canvas.scene.removeItem(item)
  203. del item
  204. def updatePositions(self):
  205. self.prepareGeometryChange()
  206. # Check Text Name size
  207. app_name_size = QFontMetrics(self.m_font_name).width(self.m_group_name) + 30
  208. self.p_width = max(50, app_name_size)
  209. # Get Port List
  210. port_list = []
  211. for port in canvas.port_list:
  212. if port.group_id == self.m_group_id and port.port_id in self.m_port_list_ids:
  213. port_list.append(port)
  214. if len(port_list) == 0:
  215. self.p_height = canvas.theme.box_header_height
  216. else:
  217. max_in_width = max_out_width = 0
  218. port_spacing = canvas.theme.port_height + canvas.theme.port_spacing
  219. # Get Max Box Width, vertical ports re-positioning
  220. port_types = [PORT_TYPE_AUDIO_JACK, PORT_TYPE_MIDI_JACK, PORT_TYPE_MIDI_ALSA, PORT_TYPE_PARAMETER]
  221. last_in_type = last_out_type = PORT_TYPE_NULL
  222. last_in_pos = last_out_pos = canvas.theme.box_header_height + canvas.theme.box_header_spacing
  223. for port_type in port_types:
  224. for port in port_list:
  225. if port.port_type != port_type:
  226. continue
  227. size = QFontMetrics(self.m_font_port).width(port.port_name)
  228. if port.port_mode == PORT_MODE_INPUT:
  229. max_in_width = max(max_in_width, size)
  230. if port.port_type != last_in_type:
  231. if last_in_type != PORT_TYPE_NULL:
  232. last_in_pos += canvas.theme.port_spacingT
  233. last_in_type = port.port_type
  234. port.widget.setY(last_in_pos)
  235. last_in_pos += port_spacing
  236. elif port.port_mode == PORT_MODE_OUTPUT:
  237. max_out_width = max(max_out_width, size)
  238. if port.port_type != last_out_type:
  239. if last_out_type != PORT_TYPE_NULL:
  240. last_out_pos += canvas.theme.port_spacingT
  241. last_out_type = port.port_type
  242. port.widget.setY(last_out_pos)
  243. last_out_pos += port_spacing
  244. self.p_width = max(self.p_width, 30 + max_in_width + max_out_width)
  245. # Horizontal ports re-positioning
  246. inX = canvas.theme.port_offset
  247. outX = self.p_width - max_out_width - canvas.theme.port_offset - 12
  248. for port_type in port_types:
  249. for port in port_list:
  250. if port.port_mode == PORT_MODE_INPUT:
  251. port.widget.setX(inX)
  252. port.widget.setPortWidth(max_in_width)
  253. elif port.port_mode == PORT_MODE_OUTPUT:
  254. port.widget.setX(outX)
  255. port.widget.setPortWidth(max_out_width)
  256. self.p_height = max(last_in_pos, last_out_pos)
  257. self.p_height += max(canvas.theme.port_spacing, canvas.theme.port_spacingT) - canvas.theme.port_spacing
  258. self.p_height += canvas.theme.box_pen.widthF()
  259. self.repaintLines(True)
  260. self.update()
  261. def repaintLines(self, forced=False):
  262. if self.pos() != self.m_last_pos or forced:
  263. for connection in self.m_connection_lines:
  264. connection.line.updateLinePos()
  265. self.m_last_pos = self.pos()
  266. def resetLinesZValue(self):
  267. for connection in canvas.connection_list:
  268. if connection.port_out_id in self.m_port_list_ids and connection.port_in_id in self.m_port_list_ids:
  269. z_value = canvas.last_z_value
  270. else:
  271. z_value = canvas.last_z_value - 1
  272. connection.widget.setZValue(z_value)
  273. def type(self):
  274. return CanvasBoxType
  275. def contextMenuEvent(self, event):
  276. event.accept()
  277. menu = QMenu()
  278. discMenu = QMenu("Disconnect", menu)
  279. conn_list = []
  280. conn_list_ids = []
  281. for port_id in self.m_port_list_ids:
  282. tmp_conn_list = CanvasGetPortConnectionList(self.m_group_id, port_id)
  283. for tmp_conn_id, tmp_group_id, tmp_port_id in tmp_conn_list:
  284. if tmp_conn_id not in conn_list_ids:
  285. conn_list.append((tmp_conn_id, tmp_group_id, tmp_port_id))
  286. conn_list_ids.append(tmp_conn_id)
  287. if len(conn_list) > 0:
  288. for conn_id, group_id, port_id in conn_list:
  289. act_x_disc = discMenu.addAction(CanvasGetFullPortName(group_id, port_id))
  290. act_x_disc.setData(conn_id)
  291. act_x_disc.triggered.connect(canvas.qobject.PortContextMenuDisconnect)
  292. else:
  293. act_x_disc = discMenu.addAction("No connections")
  294. act_x_disc.setEnabled(False)
  295. menu.addMenu(discMenu)
  296. act_x_disc_all = menu.addAction("Disconnect &All")
  297. act_x_sep1 = menu.addSeparator()
  298. act_x_info = menu.addAction("Info")
  299. act_x_rename = menu.addAction("Rename")
  300. act_x_sep2 = menu.addSeparator()
  301. act_x_split_join = menu.addAction("Join" if self.m_splitted else "Split")
  302. if not features.group_info:
  303. act_x_info.setVisible(False)
  304. if not features.group_rename:
  305. act_x_rename.setVisible(False)
  306. if not (features.group_info and features.group_rename):
  307. act_x_sep1.setVisible(False)
  308. if self.m_plugin_id >= 0 and self.m_plugin_id <= MAX_PLUGIN_ID_ALLOWED:
  309. menu.addSeparator()
  310. act_p_edit = menu.addAction("Edit")
  311. act_p_ui = menu.addAction("Show Custom UI")
  312. menu.addSeparator()
  313. act_p_clone = menu.addAction("Clone")
  314. act_p_rename = menu.addAction("Rename...")
  315. act_p_replace = menu.addAction("Replace...")
  316. act_p_remove = menu.addAction("Remove")
  317. if not self.m_plugin_ui:
  318. act_p_ui.setVisible(False)
  319. else:
  320. act_p_edit = act_p_ui = None
  321. act_p_clone = act_p_rename = None
  322. act_p_replace = act_p_remove = None
  323. haveIns = haveOuts = False
  324. for port in canvas.port_list:
  325. if port.group_id == self.m_group_id and port.port_id in self.m_port_list_ids:
  326. if port.port_mode == PORT_MODE_INPUT:
  327. haveIns = True
  328. elif port.port_mode == PORT_MODE_OUTPUT:
  329. haveOuts = True
  330. if not (self.m_splitted or bool(haveIns and haveOuts)):
  331. act_x_sep2.setVisible(False)
  332. act_x_split_join.setVisible(False)
  333. act_selected = menu.exec_(event.screenPos())
  334. if act_selected is None:
  335. pass
  336. elif act_selected == act_x_disc_all:
  337. for conn_id in conn_list_ids:
  338. canvas.callback(ACTION_PORTS_DISCONNECT, conn_id, 0, "")
  339. elif act_selected == act_x_info:
  340. canvas.callback(ACTION_GROUP_INFO, self.m_group_id, 0, "")
  341. elif act_selected == act_x_rename:
  342. canvas.callback(ACTION_GROUP_RENAME, self.m_group_id, 0, "")
  343. elif act_selected == act_x_split_join:
  344. if self.m_splitted:
  345. canvas.callback(ACTION_GROUP_JOIN, self.m_group_id, 0, "")
  346. else:
  347. canvas.callback(ACTION_GROUP_SPLIT, self.m_group_id, 0, "")
  348. elif act_selected == act_p_edit:
  349. canvas.callback(ACTION_PLUGIN_EDIT, self.m_plugin_id, 0, "")
  350. elif act_selected == act_p_ui:
  351. canvas.callback(ACTION_PLUGIN_SHOW_UI, self.m_plugin_id, 0, "")
  352. elif act_selected == act_p_clone:
  353. canvas.callback(ACTION_PLUGIN_CLONE, self.m_plugin_id, 0, "")
  354. elif act_selected == act_p_rename:
  355. canvas.callback(ACTION_PLUGIN_RENAME, self.m_plugin_id, 0, "")
  356. elif act_selected == act_p_replace:
  357. canvas.callback(ACTION_PLUGIN_REPLACE, self.m_plugin_id, 0, "")
  358. elif act_selected == act_p_remove:
  359. canvas.callback(ACTION_PLUGIN_REMOVE, self.m_plugin_id, 0, "")
  360. def keyPressEvent(self, event):
  361. if self.m_plugin_id >= 0 and event.key() == Qt.Key_Delete:
  362. event.accept()
  363. canvas.callback(ACTION_PLUGIN_REMOVE, self.m_plugin_id, 0, "")
  364. return
  365. QGraphicsItem.keyPressEvent(self, event)
  366. def hoverEnterEvent(self, event):
  367. if options.auto_select_items:
  368. if len(canvas.scene.selectedItems()) > 0:
  369. canvas.scene.clearSelection()
  370. self.setSelected(True)
  371. QGraphicsItem.hoverEnterEvent(self, event)
  372. def mouseDoubleClickEvent(self, event):
  373. if self.m_plugin_id >= 0:
  374. event.accept()
  375. canvas.callback(ACTION_PLUGIN_SHOW_UI if self.m_plugin_ui else ACTION_PLUGIN_EDIT, self.m_plugin_id, 0, "")
  376. return
  377. QGraphicsItem.mouseDoubleClickEvent(self, event)
  378. def mousePressEvent(self, event):
  379. canvas.last_z_value += 1
  380. self.setZValue(canvas.last_z_value)
  381. self.resetLinesZValue()
  382. self.m_cursor_moving = False
  383. if event.button() == Qt.RightButton:
  384. event.accept()
  385. canvas.scene.clearSelection()
  386. self.setSelected(True)
  387. self.m_mouse_down = False
  388. return
  389. elif event.button() == Qt.LeftButton:
  390. if self.sceneBoundingRect().contains(event.scenePos()):
  391. self.m_mouse_down = True
  392. else:
  393. # FIXME: Check if still valid: Fix a weird Qt behaviour with right-click mouseMove
  394. self.m_mouse_down = False
  395. event.ignore()
  396. return
  397. else:
  398. self.m_mouse_down = False
  399. QGraphicsItem.mousePressEvent(self, event)
  400. def mouseMoveEvent(self, event):
  401. if self.m_mouse_down:
  402. if not self.m_cursor_moving:
  403. self.setCursor(QCursor(Qt.SizeAllCursor))
  404. self.m_cursor_moving = True
  405. self.repaintLines()
  406. QGraphicsItem.mouseMoveEvent(self, event)
  407. def mouseReleaseEvent(self, event):
  408. if self.m_cursor_moving:
  409. self.unsetCursor()
  410. QTimer.singleShot(0, self.fixPos)
  411. self.m_mouse_down = False
  412. self.m_cursor_moving = False
  413. QGraphicsItem.mouseReleaseEvent(self, event)
  414. def fixPos(self):
  415. self.setX(round(self.x()))
  416. self.setY(round(self.y()))
  417. def boundingRect(self):
  418. return QRectF(0, 0, self.p_width, self.p_height)
  419. def paint(self, painter, option, widget):
  420. painter.save()
  421. painter.setRenderHint(QPainter.Antialiasing, bool(options.antialiasing == ANTIALIASING_FULL))
  422. rect = QRectF(0, 0, self.p_width, self.p_height)
  423. # Draw rectangle
  424. pen = QPen(canvas.theme.box_pen_sel if self.isSelected() else canvas.theme.box_pen)
  425. pen.setWidthF(pen.widthF() + 0.00001)
  426. painter.setPen(pen)
  427. lineHinting = pen.widthF() / 2
  428. if canvas.theme.box_bg_type == Theme.THEME_BG_GRADIENT:
  429. box_gradient = QLinearGradient(0, 0, 0, self.p_height)
  430. box_gradient.setColorAt(0, canvas.theme.box_bg_1)
  431. box_gradient.setColorAt(1, canvas.theme.box_bg_2)
  432. painter.setBrush(box_gradient)
  433. else:
  434. painter.setBrush(canvas.theme.box_bg_1)
  435. rect.adjust(lineHinting, lineHinting, -lineHinting, -lineHinting)
  436. painter.drawRect(rect)
  437. # Draw pixmap header
  438. rect.setHeight(canvas.theme.box_header_height)
  439. if canvas.theme.box_header_pixmap:
  440. painter.setPen(Qt.NoPen)
  441. painter.setBrush(canvas.theme.box_bg_2)
  442. # outline
  443. rect.adjust(lineHinting, lineHinting, -lineHinting, -lineHinting)
  444. painter.drawRect(rect)
  445. rect.adjust(1, 1, -1, 0)
  446. painter.drawTiledPixmap(rect, canvas.theme.box_header_pixmap, rect.topLeft())
  447. # Draw text
  448. painter.setFont(self.m_font_name)
  449. if self.isSelected():
  450. painter.setPen(canvas.theme.box_text_sel)
  451. else:
  452. painter.setPen(canvas.theme.box_text)
  453. if canvas.theme.box_use_icon:
  454. textPos = QPointF(25, canvas.theme.box_text_ypos)
  455. else:
  456. appNameSize = QFontMetrics(self.m_font_name).width(self.m_group_name)
  457. rem = self.p_width - appNameSize
  458. textPos = QPointF(rem/2, canvas.theme.box_text_ypos)
  459. painter.drawText(textPos, self.m_group_name)
  460. self.repaintLines()
  461. painter.restore()
  462. # ------------------------------------------------------------------------------------------------------------