diff --git a/.gitignore b/.gitignore index 431e4395b6e6a5ccdbbed6d7c978e86af6fc4f5d..026d091724e79fbb527d8330235ed394c2c5a3a2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ dist build eggs parts -bin +bin/[a-b,d-z] var sdist develop-eggs @@ -35,6 +35,7 @@ nosetests.xml .project .pydevproject .settings +.idea # Generated files src/cutecoin/gen_resources/* diff --git a/bin/cutecoin b/bin/cutecoin new file mode 100755 index 0000000000000000000000000000000000000000..e8fa6c9da337427a4c9d4efa5c03bf256cfd6990 --- /dev/null +++ b/bin/cutecoin @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Created on 1 févr. 2014 + +@author: inso +""" +import signal +import sys +import os +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../lib'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../src'))) +from PyQt5.QtWidgets import QApplication, QDialog +from cutecoin.gui.mainWindow import MainWindow +from cutecoin.core.app import Application + + +if __name__ == '__main__': + # activate ctrl-c interrupt + signal.signal(signal.SIGINT, signal.SIG_DFL) + cutecoin = QApplication(sys.argv) + app = Application(sys.argv) + window = MainWindow(app) + window.show() + sys.exit(cutecoin.exec_()) + pass diff --git a/res/ui/communityTabWidget.ui b/res/ui/communityTabWidget.ui index 8a16e9120d7baf5717067aa9e09c5ce8615b78e8..8cec0bb4205f3f08363bd05b952b82bce0502b20 100644 --- a/res/ui/communityTabWidget.ui +++ b/res/ui/communityTabWidget.ui @@ -6,8 +6,8 @@ <rect> <x>0</x> <y>0</y> - <width>400</width> - <height>300</height> + <width>421</width> + <height>369</height> </rect> </property> <property name="windowTitle"> @@ -15,45 +15,55 @@ </property> <layout class="QHBoxLayout" name="horizontalLayout"> <item> - <layout class="QVBoxLayout" name="verticalLayout_6"> - <item> - <widget class="QLabel" name="label"> - <property name="text"> - <string>Members</string> - </property> - </widget> - </item> - <item> - <widget class="QListView" name="list_community_members"/> - </item> - <item> - <layout class="QHBoxLayout" name="horizontalLayout_2"> - <property name="leftMargin"> - <number>0</number> - </property> - <property name="topMargin"> - <number>5</number> - </property> + <widget class="QTabWidget" name="tabs_community"> + <property name="currentIndex"> + <number>1</number> + </property> + <widget class="QWidget" name="tab_members"> + <attribute name="title"> + <string>Members</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout"> <item> - <widget class="QLabel" name="label_quality"> - <property name="text"> - <string>Quality : </string> - </property> - </widget> - </item> - <item> - <widget class="QPushButton" name="button_membership"> - <property name="text"> - <string>Send membership demand</string> - </property> - </widget> + <layout class="QVBoxLayout" name="verticalLayout_6"> + <item> + <widget class="QListView" name="list_community_members"/> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>5</number> + </property> + <item> + <widget class="QLabel" name="label_quality"> + <property name="text"> + <string>Quality : </string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="button_membership"> + <property name="text"> + <string>Send membership demand</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> </item> </layout> - </item> - </layout> - </item> - <item> - <layout class="QVBoxLayout" name="verticalLayout"/> + </widget> + <widget class="QWidget" name="tab_wot"> + <attribute name="title"> + <string>Wot</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_2"/> + </widget> + </widget> </item> </layout> </widget> diff --git a/res/ui/wot_form.ui b/res/ui/wot_form.ui new file mode 100644 index 0000000000000000000000000000000000000000..ff58cb34a110af213ad4e0e14a7498670a75e0fe --- /dev/null +++ b/res/ui/wot_form.ui @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>Form</class> + <widget class="QWidget" name="Form"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>522</width> + <height>442</height> + </rect> + </property> + <property name="windowTitle"> + <string>Form</string> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="0" column="0"> + <widget class="QComboBox" name="comboBoxSearch"> + <property name="editable"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QPushButton" name="pushButtonReset"> + <property name="maximumSize"> + <size> + <width>85</width> + <height>27</height> + </size> + </property> + <property name="text"> + <string>Me</string> + </property> + </widget> + </item> + <item row="1" column="0" colspan="2"> + <widget class="View" name="graphicsView"> + <property name="viewportUpdateMode"> + <enum>QGraphicsView::BoundingRectViewportUpdate</enum> + </property> + </widget> + </item> + </layout> + </widget> + <customwidgets> + <customwidget> + <class>View</class> + <extends>QGraphicsView</extends> + <header>cutecoin.wot.qt.view.h</header> + </customwidget> + </customwidgets> + <resources/> + <connections> + <connection> + <sender>pushButtonReset</sender> + <signal>clicked()</signal> + <receiver>Form</receiver> + <slot>reset()</slot> + <hints> + <hint type="sourcelabel"> + <x>516</x> + <y>23</y> + </hint> + <hint type="destinationlabel"> + <x>284</x> + <y>198</y> + </hint> + </hints> + </connection> + <connection> + <sender>comboBoxSearch</sender> + <signal>currentIndexChanged(int)</signal> + <receiver>Form</receiver> + <slot>select_node()</slot> + <hints> + <hint type="sourcelabel"> + <x>215</x> + <y>22</y> + </hint> + <hint type="destinationlabel"> + <x>260</x> + <y>220</y> + </hint> + </hints> + </connection> + </connections> + <slots> + <slot>reset()</slot> + <slot>search()</slot> + <slot>select_node()</slot> + </slots> +</ui> diff --git a/setup.py b/setup.py index bcf1812ff6eb922e57ec5440dcb5a203e340c71b..c19b45b44dd84c33dff6837abf7eb3ed20da75f6 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ print(sys.path) includes = ["sip", "re", "json", "logging", "hashlib", "os", "urllib", "ucoinpy", "requests", "cutecoin.core"] excludes = [] packages = ["libnacl", "pylibscrypt"] + includefiles = [] options = {"path": sys.path, diff --git a/src/cutecoin/core/community.py b/src/cutecoin/core/community.py index 3561dc677c3dde4ecd9109f25130399feb54d1e7..5ccf5d0e06cc62a0dc7fb66be0745fba7cc15060 100644 --- a/src/cutecoin/core/community.py +++ b/src/cutecoin/core/community.py @@ -126,3 +126,6 @@ class Community(object): data = {'currency': self.currency, 'peers': self.jsonify_peers_list()} return data + + def get_parameters(self): + return self.request(bma.blockchain.Parameters) diff --git a/src/cutecoin/gui/communityTabWidget.py b/src/cutecoin/gui/communityTabWidget.py index 40ed31f1903b99bd751258e161a51c033ce744b2..1424d8408cbb2359fa9322d3b7b4d6fb89832d9e 100644 --- a/src/cutecoin/gui/communityTabWidget.py +++ b/src/cutecoin/gui/communityTabWidget.py @@ -9,6 +9,7 @@ from PyQt5.QtWidgets import QWidget, QErrorMessage from cutecoin.models.members import MembersListModel from cutecoin.gen_resources.communityTabWidget_uic import Ui_CommunityTabWidget from cutecoin.gui.addContactDialog import AddContactDialog +from cutecoin.wot.qt.form import Form class CommunityTabWidget(QWidget, Ui_CommunityTabWidget): @@ -34,6 +35,9 @@ class CommunityTabWidget(QWidget, Ui_CommunityTabWidget): self.button_membership.setText("Send membership demand") self.button_membership.clicked.connect(self.send_membership_demand) + # create wot widget + self.verticalLayout_2.addWidget(Form(account, community)) + def add_member_as_contact(self, index): members_model = self.list_community_members.model() members = members_model.members diff --git a/src/cutecoin/wot/__init__.py b/src/cutecoin/wot/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/cutecoin/wot/qt/__init__.py b/src/cutecoin/wot/qt/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/cutecoin/wot/qt/form.py b/src/cutecoin/wot/qt/form.py new file mode 100644 index 0000000000000000000000000000000000000000..67c14a4127cc79cd8015ba14caa8a7336a0dad9d --- /dev/null +++ b/src/cutecoin/wot/qt/form.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- + +import time +import datetime +from PyQt5.QtWidgets import QWidget + +from cutecoin.gen_resources.wot_form_uic import Ui_Form +from cutecoin.wot.qt.view import NODE_STATUS_HIGHLIGHTED, NODE_STATUS_SELECTED, ARC_STATUS_STRONG, ARC_STATUS_WEAK +from ucoinpy.api import bma + + +class Form(QWidget, Ui_Form): + def __init__(self, account, community, parent=None): + """ + + :param cutecoin.core.account.Account account: + :param cutecoin.core.community.Community community: + :param parent: + :return: + """ + super(Form, self).__init__(parent) + + # construct from qtDesigner + self.setupUi(self) + + # add combobox events + self.comboBoxSearch.lineEdit().textEdited.connect(self.search) + self.comboBoxSearch.lineEdit().returnPressed.connect(self.combobox_return_pressed) + + # add scene events + self.graphicsView.scene().node_signed.connect(self.sign_node) + self.graphicsView.scene().node_clicked.connect(self.draw_graph) + + self.account = account + self.community = community + + # nodes list for menu from search + self.nodes = list() + self.signature_validity = self.community.get_parameters()['sigValidity'] + # arc considered strong during 75% of signature validity time + self.ARC_STATUS_STRONG_time = int(self.signature_validity * 0.75) + self.draw_graph(self.account.pubkey) + + def draw_graph(self, public_key): + """ + Draw community graph centered on public_key identity + + :param public_key: Public key of the identity + """ + graph = dict() + # add wallet node + node_status = (NODE_STATUS_HIGHLIGHTED and (public_key == self.account.pubkey)) or 0 + node_status += NODE_STATUS_SELECTED + certifiers = self.community.request(bma.wot.CertifiersOf, {'search': public_key}) + + graph[public_key] = {'arcs': [], 'text': certifiers['uid'], 'tooltip': public_key, 'status': node_status} + + # add certifiers of uid + for certifier in certifiers['certifications']: + if (time.time() - certifier['cert_time']['medianTime']) > self.ARC_STATUS_STRONG_time: + arc_status = ARC_STATUS_WEAK + else: + arc_status = ARC_STATUS_STRONG + arc = { + 'id': public_key, + 'status': arc_status, + 'tooltip': datetime.datetime.fromtimestamp( + certifier['cert_time']['medianTime'] + self.signature_validity + ).strftime("%Y/%m/%d") + } + if certifier['pubkey'] not in graph.keys(): + node_status = (NODE_STATUS_HIGHLIGHTED and (certifier['pubkey'] == self.account.pubkey)) or 0 + graph[certifier['pubkey']] = { + 'arcs': [arc], + 'text': certifier['uid'], + 'tooltip': certifier['pubkey'], + 'status': node_status + } + + # add certified by uid + for certified in self.community.request(bma.wot.CertifiedBy, {'search': public_key})['certifications']: + if (time.time() - certified['cert_time']['medianTime']) > self.ARC_STATUS_STRONG_time: + arc_status = ARC_STATUS_WEAK + else: + arc_status = ARC_STATUS_STRONG + arc = { + 'id': certified['pubkey'], + 'status': arc_status, + 'tooltip': datetime.datetime.fromtimestamp( + certified['cert_time']['medianTime'] + self.signature_validity + ).strftime("%Y/%m/%d") + } + graph[public_key]['arcs'].append(arc) + if certified['pubkey'] not in graph.keys(): + node_status = (NODE_STATUS_HIGHLIGHTED and (certified['pubkey'] == self.account.pubkey)) or 0 + graph[certified['pubkey']] = { + 'arcs': list(), + 'text': certified['uid'], + 'tooltip': certified['pubkey'], + 'status': node_status + } + + # draw graph in qt scene + self.graphicsView.scene().update_wot(graph) + + def reset(self): + """ + Reset graph scene to wallet identity + """ + self.draw_graph( + self.account.pubkey + ) + + def combobox_return_pressed(self): + """ + Search nodes when return is pressed in combobox lineEdit + """ + self.search(self.comboBoxSearch.lineEdit().text()) + + def search(self, text): + """ + Search nodes when text is edited in combobox lineEdit + """ + if len(text) < 2: + return False + + response = self.community.request(bma.wot.Lookup, {'search': text}) + nodes = {} + for identity in response['results']: + if identity['uids'][0]['others']: + nodes[identity['pubkey']] = identity['uids'][0]['uid'] + + if nodes: + self.nodes = list() + self.comboBoxSearch.clear() + self.comboBoxSearch.lineEdit().setText(text) + for pubkey, uid in nodes.items(): + self.nodes.append({'pubkey': pubkey, 'uid': uid}) + self.comboBoxSearch.addItem(uid) + self.comboBoxSearch.showPopup() + + def select_node(self, index): + """ + Select node in graph when item is selected in combobox + """ + if index < 0 or index >= len(self.nodes): + return False + node = self.nodes[index] + self.draw_graph( + node['pubkey'] + ) + + def sign_node(self, public_key): + print('sign node {} not implemented'.format(public_key)) diff --git a/src/cutecoin/wot/qt/view.py b/src/cutecoin/wot/qt/view.py new file mode 100644 index 0000000000000000000000000000000000000000..9aa5a4e57edf866102648f6fec752bf4083e97e3 --- /dev/null +++ b/src/cutecoin/wot/qt/view.py @@ -0,0 +1,417 @@ +# -*- coding: utf-8 -*- + +import math +from PyQt5.QtGui import QPainter, QBrush, QPen, QPolygonF, QColor, QRadialGradient,\ + QPainterPath, QMouseEvent, QWheelEvent, QTransform +from PyQt5.QtCore import Qt, QRectF, QLineF, QPoint, QPointF, QSizeF, qFuzzyCompare, pyqtSignal +from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsEllipseItem,\ + QGraphicsSimpleTextItem, QGraphicsLineItem, QMenu, QAction, QGraphicsSceneHoverEvent,\ + QGraphicsSceneContextMenuEvent + +NODE_STATUS_HIGHLIGHTED = 1 +NODE_STATUS_SELECTED = 2 +ARC_STATUS_STRONG = 1 +ARC_STATUS_WEAK = 2 + + +class View(QGraphicsView): + def __init__(self, parent=None): + """ + Create View to display scene + + :param parent: [Optional, default=None] Parent widget + """ + super(View, self).__init__(parent) + + self.setScene(Scene(self)) + + self.setCacheMode(QGraphicsView.CacheBackground) + self.setViewportUpdateMode(QGraphicsView.BoundingRectViewportUpdate) + self.setRenderHint(QPainter.Antialiasing) + self.setRenderHint(QPainter.SmoothPixmapTransform) + + def wheelEvent(self, event: QWheelEvent): + """ + Zoom in/out on the mouse cursor + """ + # zoom only when CTRL key pressed + if (event.modifiers() & Qt.ControlModifier) == Qt.ControlModifier: + steps = event.angleDelta().y() / 15 / 8 + + if steps == 0: + event.ignore() + return + + # scale factor 1.25 + sc = pow(1.25, steps) + self.scale(sc, sc) + self.centerOn(self.mapToScene(event.pos())) + event.accept() + # act normally on scrollbar + else: + # transmit event to parent class wheelevent + super(QGraphicsView, self).wheelEvent(event) + + +class Scene(QGraphicsScene): + + # This defines a signal called 'nodeSigned' that takes on string argument + node_signed = pyqtSignal(str, name='nodeSigned') + node_clicked = pyqtSignal(str, name='nodeClicked') + + def __init__(self, parent=None): + """ + Create scene of the graph + + :param parent: [Optional, default=None] Parent view + """ + super(Scene, self).__init__(parent) + + self.lastDragPos = QPoint() + self.setItemIndexMethod(QGraphicsScene.NoIndex) + + # axis of the scene for debug purpose + # self.addLine(-100, 0, 100, 0) + # self.addLine(0, -100, 0, 100) + + def add_node(self, _id, node, pos): + """ + Add a node item in the graph + + :param str _id: Node id + :param dict node: Node data + :param tuple pos: Position (x,y) of the node + + :return: Node + """ + node = Node(_id, node, pos) + self.addItem(node) + return node + + def add_arc(self, source_node, destination_node, arc): + """ + Add an arc between two nodes + + :param Node source_node: Source node of the arc + :param Node destination_node: Destination node of the arc + :param dict arc: Arc data + + :return: Arc + """ + arc = Arc(source_node, destination_node, arc) + self.addItem(arc) + return arc + + def update_wot(self, graph): + """ + draw community graph + + :param dict graph: graph to draw + """ + # clear scene + self.clear() + + # capture selected node (to draw it in the center) + for _id, node in graph.items(): + if node['status'] & NODE_STATUS_SELECTED: + selected_id = _id + selected_node = node + + root_node = self.add_node(selected_id, selected_node, (0, 0)) + + # add certified by selected node + y = 0 + x = 200 + # capture nodes for sorting by text + nodes = dict() + for arc in selected_node['arcs']: + nodes[arc['id']] = {'node': graph[arc['id']], 'arc': arc} + # sort by text + nodes = ((k, v) for (k, v) in sorted(nodes.items(), key=lambda kv: kv[1]['node']['text'].lower())) + # add nodes and arcs + for _id, items in nodes: + node = self.add_node(_id, items['node'], (x, y)) + self.add_arc(root_node, node, items['arc']) + y += 50 + + # add certifiers of selected node + y = 0 + x = -200 + # sort by text + nodes = ((k, v) for (k, v) in sorted(graph.items(), key=lambda kv: kv[1]['text'].lower()) if selected_id in (arc['id'] for arc in v['arcs'])) + # add nodes and arcs + for _id, certifier_node in nodes: + node = self.add_node(_id, certifier_node, (x, y)) + for arc in certifier_node['arcs']: + if arc['id'] == selected_id: + self.add_arc(node, root_node, arc) + y += 50 + + self.update() + + +class Node(QGraphicsEllipseItem): + def __init__(self, _id, data, x_y): + """ + Create node in the graph scene + + :param dict data: Node data + :param x_y: Position of the node + """ + # unpack tuple + x, y = x_y + + super(Node, self).__init__() + self.id = _id + self.status_wallet = data['status'] & NODE_STATUS_HIGHLIGHTED + self.text = data['text'] + self.setToolTip(data['tooltip']) + self.arcs = [] + self.menu = None + self.action_sign = None + + # color around ellipse + outline_color = QColor('grey') + if self.status_wallet: + outline_color = QColor('black') + self.setPen(QPen(outline_color)) + + # text inside ellipse + self.text_item = QGraphicsSimpleTextItem(self) + self.text_item.setText(self.text) + text_color = QColor('grey') + if self.status_wallet == NODE_STATUS_HIGHLIGHTED: + text_color = QColor('black') + self.text_item.setBrush(QBrush(text_color)) + # center ellipse around text + self.setRect( + 0, + 0, + self.text_item.boundingRect().width() * 2, + self.text_item.boundingRect().height() * 2 + ) + + # set anchor to the center + self.setTransform(QTransform().translate(-self.boundingRect().width()/2.0, -self.boundingRect().height()/2.0)) + self.setPos(x, y) + #print(x, y) + # center text in ellipse + self.text_item.setPos(self.boundingRect().width() / 4.0, self.boundingRect().height() / 4.0) + + # create gradient inside the ellipse + gradient = QRadialGradient(QPointF(0, self.boundingRect().height() / 4), self.boundingRect().width()) + gradient.setColorAt(0, QColor('white')) + gradient.setColorAt(1, QColor('darkgrey')) + self.setBrush(QBrush(gradient)) + + # cursor change on hover + self.setAcceptHoverEvents(True) + self.setZValue(1) + + def mousePressEvent(self, event: QMouseEvent): + """ + Click on mouse button + + :param event: mouse event + """ + if event.button() == Qt.LeftButton: + # trigger scene signal + self.scene().node_clicked.emit(self.id) + + def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): + """ + Mouse enter on node zone + + :param event: scene hover event + """ + self.setCursor(Qt.ArrowCursor) + + def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent): + """ + Right click on node to show node menu + + :param event: scene context menu event + """ + # create node context menus + self.menu = QMenu() + # action sign identity + self.action_sign = QAction('Sign identity', self.scene()) + self.menu.addAction(self.action_sign) + self.action_sign.triggered.connect(self.sign_action) + self.menu.exec(event.screenPos()) + + def add_arc(self, arc): + """ + Add arc to the arc list + + :param arc: Arc + """ + self.arcs.append(arc) + + def sign_action(self): + """ + Sign identity node + """ + # trigger scene signal + self.scene().node_signed.emit(self.id) + + +class Arc(QGraphicsLineItem): + def __init__(self, source_node, destination_node, data): + """ + Create an arc between two nodes + + :param Node source_node: Source node of the arc + :param Node destination_node: Destination node of the arc + :param dict data: Arc data + """ + super(Arc, self).__init__() + + self.source = source_node + self.destination = destination_node + self.setToolTip(data['tooltip']) + self.source.add_arc(self) + + self.status = data['status'] + + self.source_point = None + self.destination_point = None + self.arrow_size = 5.0 + + self.setAcceptedMouseButtons(Qt.NoButton) + + # cursor change on hover + self.setAcceptHoverEvents(True) + self.adjust() + self.setZValue(0) + + def adjust(self): + """ + Draw the arc line + """ + if not self.source or not self.destination: + return + line = QLineF( + self.mapFromItem( + self.source, + self.source.boundingRect().width() - (self.source.boundingRect().width() / 2.0), + self.source.boundingRect().height() / 2.0 + ), + self.mapFromItem( + self.destination, + self.destination.boundingRect().width() / 2.0, + self.destination.boundingRect().height() / 2.0 + ) + ) + self.prepareGeometryChange() + self.source_point = line.p1() + self.destination_point = line.p2() + + # mouse over on line only + self.setLine(line) + + # virtual function require subclassing + def boundingRect(self): + """ + Return the bounding rectangle size + + :return: QRectF + """ + if not self.source or not self.destination: + return QRectF() + pen_width = 1.0 + extra = (pen_width + self.arrow_size) / 2.0 + + return QRectF( + self.source_point, QSizeF( + self.destination_point.x() - self.source_point.x(), + self.destination_point.y() - self.source_point.y() + ) + ).normalized().adjusted( + -extra, + -extra, + extra, + extra + ) + + def paint(self, painter, option, widget): + """ + Customize line adding an arrow head + + :param QPainter painter: Painter instance of the item + :param option: Painter option of the item + :param widget: Widget instance + """ + if not self.source or not self.destination: + return + line = QLineF(self.source_point, self.destination_point) + if qFuzzyCompare(line.length(), 0): + return + + # Draw the line itself + color = QColor() + style = Qt.SolidLine + if self.status == ARC_STATUS_STRONG: + color.setNamedColor('blue') + if self.status == ARC_STATUS_WEAK: + color.setNamedColor('salmon') + style = Qt.DashLine + + painter.setPen(QPen(color, 1, style, Qt.RoundCap, Qt.RoundJoin)) + painter.drawLine(line) + painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + + # Draw the arrows + angle = math.acos(line.dx() / line.length()) + if line.dy() >= 0: + angle = (2.0 * math.pi) - angle + + # arrow in the middle of the arc + hpx = (line.p2().x() + line.p1().x()) / 2.0 + hpy = (line.p2().y() - line.p1().y()) / 2.0 + if line.dy() < 0: + hpy = -hpy + head_point = QPointF(hpx, hpy) + + painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) + destination_arrow_p1 = head_point + QPointF( + math.sin(angle - math.pi / 3) * self.arrow_size, + math.cos(angle - math.pi / 3) * self.arrow_size) + destination_arrow_p2 = head_point + QPointF( + math.sin(angle - math.pi + math.pi / 3) * self.arrow_size, + math.cos(angle - math.pi + math.pi / 3) * self.arrow_size) + + painter.setBrush(color) + painter.drawPolygon(QPolygonF([head_point, destination_arrow_p1, destination_arrow_p2])) + + def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent): + """ + Mouse enter on arc zone + + :param event: scene hover event + """ + self.setCursor(Qt.ArrowCursor) + + def shape(self): + """ + Return real shape of the item to detect collision or hover accurately + + :return: QPainterPath + """ + # detection mouse hover on arc path + path = QPainterPath() + path.addPolygon(QPolygonF([self.line().p1(), self.line().p2()])) + # add handles at the start and end of arc + path.addRect(QRectF( + self.line().p1().x()-5, + self.line().p1().y()-5, + self.line().p1().x()+5, + self.line().p1().y()+5 + )) + path.addRect(QRectF( + self.line().p2().x()-5, + self.line().p2().y()-5, + self.line().p2().x()+5, + self.line().p2().y()+5 + )) + return path