Move saved Resolutions parameters to global scope. Move all Program settings to global scope. Update & Refactor Settings Object. Update Whats My Name - make detection more precise & add sites. Update README.md Update version number Finalize importing tabs from TOR. Change settings labels so they look better in menus. Add Tor browser path setting. Fix bug with deselected entities when zooming out. Transition to pyqtdarktheme for theme & styling. Check if firefox snap exists if firefox package is not present. Make sure that every file from the directories holding resolutions is present. Refactor importing of tabs.
437 lines
17 KiB
Python
437 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import contextlib
|
|
from json import dumps
|
|
import math
|
|
from typing import Any, Optional
|
|
|
|
from PySide6 import QtCore, QtGui, QtWidgets
|
|
from PySide6.QtWidgets import QGraphicsItem
|
|
from PySide6.QtWidgets import QGraphicsItemGroup, QGraphicsSimpleTextItem, QGraphicsPixmapItem, QGraphicsTextItem
|
|
from PySide6.QtSvgWidgets import QGraphicsSvgItem
|
|
|
|
ENTITY_TEXT_FONT = QtGui.QFont("Mono", 11, 700)
|
|
LINK_TEXT_FONT = QtGui.QFont("Mono", 11, 700)
|
|
|
|
|
|
class BaseNode(QGraphicsItemGroup):
|
|
|
|
def __init__(self, pictureByteArray: QtCore.QByteArray, uid, primaryAttribute: str, font: QtGui.QFont,
|
|
brush: QtGui.QBrush) -> None:
|
|
super(BaseNode, self).__init__()
|
|
|
|
self.setCacheMode(QGraphicsItemGroup.CacheMode.DeviceCoordinateCache)
|
|
|
|
if pictureByteArray.data().startswith(b'<svg '):
|
|
self.iconItem = QGraphicsSvgItem()
|
|
self.iconItem.renderer().load(pictureByteArray)
|
|
# Force recalculation of geometry, else this looks like 1 pixel.
|
|
# https://stackoverflow.com/a/68182093
|
|
self.iconItem.setElementId("")
|
|
else:
|
|
pixmapItem = QtGui.QPixmap()
|
|
pixmapItem.loadFromData(pictureByteArray)
|
|
self.iconItem = QGraphicsPixmapItem(pixmapItem)
|
|
|
|
self.labelItem = QGraphicsTextItem('')
|
|
# Have to do it this way; directly assigning stuff does not work due to how PySide6 works.
|
|
labelDocument = self.labelItem.document()
|
|
textOption = labelDocument.defaultTextOption()
|
|
textOption.setWrapMode(QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
|
|
textOption.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
|
|
labelDocument.setDefaultTextOption(textOption)
|
|
self.labelItem.setDocument(labelDocument)
|
|
|
|
self.bannerIconItem = QGraphicsSvgItem()
|
|
self.bannerIconItem.setElementId("")
|
|
|
|
self.addToGroup(self.iconItem)
|
|
self.addToGroup(self.labelItem)
|
|
self.addToGroup(self.bannerIconItem)
|
|
|
|
if font is not None:
|
|
self.labelItem.setFont(font)
|
|
else:
|
|
self.labelItem.setFont(ENTITY_TEXT_FONT)
|
|
if brush is not None:
|
|
self.labelItem.setDefaultTextColor(brush.color())
|
|
|
|
self.updateLabel(primaryAttribute)
|
|
|
|
self.bannerIconItem.setPos(self.iconItem.x() + 15, self.iconItem.y() - 9)
|
|
self.bannerIconItem.setZValue(10)
|
|
|
|
self.uid = uid
|
|
self.setFlag(QGraphicsItem.ItemIsMovable, True)
|
|
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
|
|
# Check if this option makes the software feel better or worse to use.
|
|
self.setFlag(QGraphicsItem.ItemClipsToShape, True)
|
|
self.setAcceptHoverEvents(True)
|
|
|
|
self.connectors = []
|
|
|
|
def updateLabel(self, newText: str = '') -> None:
|
|
if newText != '':
|
|
if len(newText) > 50:
|
|
newText = f"{newText[:47]}..."
|
|
self.labelItem.setPlainText(newText)
|
|
self.labelItem.document().adjustSize()
|
|
self.labelItem.setPos(self.iconItem.x() + 20 - (self.labelItem.textWidth() / 2), self.iconItem.y() + 45)
|
|
|
|
def updateBanner(self, bannerHidden: bool = True, bannerGraphic: QtCore.QByteArray = None) -> None:
|
|
if bannerHidden: # No icon visible
|
|
self.bannerIconItem.hide()
|
|
self.bannerIconItem.setVisible(False)
|
|
return
|
|
self.bannerIconItem.renderer().load(bannerGraphic)
|
|
self.bannerIconItem.show()
|
|
self.bannerIconItem.setVisible(True)
|
|
self.bannerIconItem.setElementId("")
|
|
|
|
def removeConnector(self, connector) -> None:
|
|
# Exception could be thrown if the connector is already deleted.
|
|
with contextlib.suppress(ValueError):
|
|
self.connectors.remove(connector)
|
|
|
|
def addConnector(self, connector) -> None:
|
|
self.connectors.append(connector)
|
|
|
|
def getConnectorByUID(self, uid) -> None:
|
|
for connector in self.connectors:
|
|
if connector.uid == uid:
|
|
return connector
|
|
|
|
def hoverEnterEvent(self, event) -> None:
|
|
super().hoverEnterEvent(event)
|
|
self.scene().detailsWidgetCaller(uid=self.uid)
|
|
|
|
def hoverLeaveEvent(self, event) -> None:
|
|
super().hoverLeaveEvent(event)
|
|
self.scene().detailsWidgetCaller()
|
|
|
|
def mouseDoubleClickEvent(self, event) -> None:
|
|
self.scene().editEntityProperties(self.uid)
|
|
super().mouseDoubleClickEvent(event)
|
|
|
|
def shape(self) -> QtGui.QPainterPath:
|
|
returnPath = QtGui.QPainterPath()
|
|
returnPath.addRect(QtCore.QRectF(self.iconItem.x() - 20, self.iconItem.y() - 20, 80, 80))
|
|
return returnPath
|
|
|
|
def boundingRect(self) -> QtCore.QRectF:
|
|
return QtCore.QRectF(self.iconItem.x() - 20, self.iconItem.y() - 20, 80, 80)
|
|
|
|
def childrenBoundingRect(self) -> QtCore.QRectF:
|
|
return QtCore.QRectF(self.iconItem.x() - 20, self.iconItem.y() - 20, 80, 80)
|
|
|
|
def boundingRegion(self, itemToDeviceTransform: QtGui.QTransform) -> QtGui.QRegion:
|
|
return self.iconItem.boundingRegion(itemToDeviceTransform)
|
|
|
|
def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionGraphicsItem,
|
|
widget: Optional[QtWidgets.QWidget] = ...) -> None:
|
|
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
|
if self.scene().views()[0].zoom < self.scene().hideZoom:
|
|
# Looks stupid, but fixes bug where entities are deselected when zooming out past hideZoom level.
|
|
if self.isSelected():
|
|
self.labelItem.hide()
|
|
self.setSelected(True)
|
|
else:
|
|
self.labelItem.hide()
|
|
else:
|
|
self.labelItem.show()
|
|
if self.isSelected():
|
|
centerPoint = QtCore.QPointF(self.iconItem.x() + 20, self.iconItem.y() + 20)
|
|
selectionBackgroundGradient = QtGui.QRadialGradient(centerPoint, 80, centerPoint)
|
|
selectionBackgroundGradient.setColorAt(0.0, QtGui.QColor(250, 250, 255))
|
|
selectionBackgroundGradient.setColorAt(0.5, QtGui.QColor(231, 240, 253, 0))
|
|
painter.setBrush(selectionBackgroundGradient)
|
|
painter.drawRect(QtCore.QRectF(self.iconItem.x() - 20, self.iconItem.y() - 20, 80, 80))
|
|
super(BaseNode, self).paint(painter, option, widget)
|
|
|
|
|
|
class GroupNode(BaseNode):
|
|
|
|
# childNodes is a list of tuples, uid and picture, of all the nodes in the group.
|
|
def __init__(self, pictureByteArray, uid: str, label: str = 'Entity Group', font=None, brush=None) -> None:
|
|
super(GroupNode, self).__init__(pictureByteArray, uid, label, font, brush)
|
|
self.groupedNodesConnectors = []
|
|
self.itemsThatWereGrouped = []
|
|
self.groupedNodesUid = set()
|
|
|
|
self.listWidget = GroupNodeChildList()
|
|
self.listProxyWidget = None
|
|
|
|
def itemChange(self, change: QtWidgets.QGraphicsItem.GraphicsItemChange, value: Any) -> Any:
|
|
if change == QtWidgets.QGraphicsItem.GraphicsItemChange.ItemSelectedChange:
|
|
if value:
|
|
self.showList(None)
|
|
else:
|
|
self.hideList()
|
|
return value
|
|
|
|
def showList(self, pos: QtCore.QPoint = None) -> None:
|
|
if self.listWidget is None or self.listProxyWidget is None:
|
|
return
|
|
self.listWidget.itemList.clear()
|
|
for uid in self.groupedNodesUid:
|
|
entityJson = self.scene().parent().entityDB.getEntity(uid)
|
|
try:
|
|
primaryField = entityJson[list(entityJson)[1]]
|
|
except IndexError:
|
|
primaryField = ''
|
|
iconPixmap = QtGui.QPixmap()
|
|
iconPixmap.loadFromData(entityJson['Icon'])
|
|
|
|
GroupNodeListItem(icon=iconPixmap, text=primaryField, uid=uid,
|
|
listview=self.listWidget.itemList)
|
|
if pos is None:
|
|
pos = QtCore.QPoint(self.pos().x() + 60, self.pos().y())
|
|
self.listProxyWidget.setPos(pos)
|
|
self.listProxyWidget.setVisible(True)
|
|
|
|
def mouseMoveEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent) -> None:
|
|
super(GroupNode, self).mouseMoveEvent(event)
|
|
if self.listProxyWidget is not None and self.listProxyWidget.isVisible():
|
|
self.listProxyWidget.setPos(QtCore.QPoint(self.pos().x() + 60, self.pos().y()))
|
|
|
|
def hideList(self) -> None:
|
|
if self.listProxyWidget is not None:
|
|
self.listProxyWidget.hide()
|
|
self.listProxyWidget.setVisible(False)
|
|
|
|
def formGroup(self, childNodeUIDs, listProxyWidget: QtWidgets.QGraphicsProxyWidget) -> None:
|
|
[self.addItemToGroup(uid) for uid in childNodeUIDs] # Should be faster than just a for loop
|
|
self.listProxyWidget = listProxyWidget
|
|
self.listProxyWidget.setCacheMode(QGraphicsItemGroup.CacheMode.DeviceCoordinateCache)
|
|
|
|
def addItemToGroup(self, uid: str) -> None:
|
|
self.groupedNodesUid.add(uid)
|
|
self.handleAddItemConnectors(uid)
|
|
|
|
def handleAddItemConnectors(self, uid) -> None:
|
|
incomingLinks = self.scene().parent().entityDB.getIncomingLinks(uid)
|
|
outgoingLinks = self.scene().parent().entityDB.getOutgoingLinks(uid)
|
|
|
|
# Suppress exceptions, as these give false alarms when initially drawing the graph on the canvas.
|
|
for link in incomingLinks:
|
|
self.scene().addLinkProgrammatic(link, self.scene().parent().entityDB.getLink(link)['Resolution'],
|
|
suppressNonExistentEntityException=True)
|
|
for link in outgoingLinks:
|
|
self.scene().addLinkProgrammatic(link, self.scene().parent().entityDB.getLink(link)['Resolution'],
|
|
suppressNonExistentEntityException=True)
|
|
|
|
def removeSpecificItemFromGroupIfExists(self, uid) -> bool:
|
|
if uid in self.groupedNodesUid:
|
|
self.groupedNodesUid.remove(uid)
|
|
self.setSelected(False)
|
|
return True
|
|
return False
|
|
|
|
|
|
# Ref: https://doc.qt.io/qt-5/qtwidgets-graphicsview-diagramscene-example.html#arrow-class-definition
|
|
# Ref: https://github.com/PySide/Examples/blob/master/examples/graphicsview/diagramscene/diagramscene.py
|
|
class BaseConnector(QGraphicsItemGroup):
|
|
|
|
def __init__(self, origin, destination, name: str = 'None', uid=None, parent=None,
|
|
font: QtGui.QFont = None, brush: QtGui.QBrush = None) -> None:
|
|
super(BaseConnector, self).__init__(parent)
|
|
|
|
self.myStartItem = origin
|
|
self.myEndItem = destination
|
|
|
|
self.labelItem = QGraphicsSimpleTextItem('')
|
|
self.addToGroup(self.labelItem)
|
|
if font is not None:
|
|
self.labelItem.setFont(font)
|
|
else:
|
|
self.labelItem.setFont(LINK_TEXT_FONT)
|
|
if brush is not None:
|
|
self.labelItem.setBrush(brush)
|
|
|
|
self.updateLabel(name)
|
|
|
|
if uid is not None:
|
|
self.uid = set(uid) if isinstance(uid, (list, set)) else {uid}
|
|
else:
|
|
self.uid = {(origin.uid, destination.uid)}
|
|
|
|
self.setFlag(QGraphicsItem.ItemIsMovable, False)
|
|
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
|
|
self.labelItem.setFlag(QGraphicsItem.ItemIsSelectable, False)
|
|
|
|
self.setZValue(-100)
|
|
self.myStartItem.addConnector(self)
|
|
self.myEndItem.addConnector(self)
|
|
|
|
self.colorSelected = QtGui.QColor(0, 173, 238)
|
|
self.colorDefault = QtGui.QColor(200, 200, 200)
|
|
self.myColor = self.colorDefault
|
|
|
|
self.pen = QtGui.QPen(self.myColor, 2, QtCore.Qt.PenStyle.SolidLine,
|
|
QtCore.Qt.PenCapStyle.RoundCap, QtCore.Qt.PenJoinStyle.RoundJoin)
|
|
|
|
self.arrowHead = QtGui.QPolygonF()
|
|
self.line = QtCore.QLineF()
|
|
|
|
def updateLabel(self, newText: str = '') -> None:
|
|
if len(newText) > 50:
|
|
newText = f"{newText[:47]}..."
|
|
self.labelItem.setText(newText)
|
|
self.update()
|
|
|
|
def mousePressEvent(self, event) -> None:
|
|
self.scene().detailsWidgetCaller(uid=self.uid)
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseDoubleClickEvent(self, event) -> None:
|
|
if len(self.uid) == 1:
|
|
self.scene().editLinkProperties(next(iter(self.uid)))
|
|
else:
|
|
# This will cause a message to be shown to the user, telling them that links representing multiple
|
|
# connections cannot be edited.
|
|
self.scene().editLinkProperties("")
|
|
super().mouseDoubleClickEvent(event)
|
|
|
|
def startItem(self) -> BaseNode:
|
|
return self.myStartItem
|
|
|
|
def endItem(self) -> BaseNode:
|
|
return self.myEndItem
|
|
|
|
def updatePosition(self) -> None:
|
|
self.line = QtCore.QLineF(self.mapFromItem(self.myStartItem, 0, 0), self.mapFromItem(self.myEndItem, 0, 0))
|
|
self.update()
|
|
|
|
def boundingRect(self) -> QtCore.QRectF:
|
|
extra = self.pen.width() + 20
|
|
p1 = self.line.p1()
|
|
p2 = self.line.p2()
|
|
return QtCore.QRectF(p1, QtCore.QSizeF(p2.x() - p1.x(), p2.y() - p1.y())
|
|
).normalized().adjusted(-extra, -extra, extra, extra)
|
|
|
|
def shape(self) -> QtGui.QPainterPath:
|
|
path = QtGui.QPainterPath(self.line.p1())
|
|
path.lineTo(self.line.p2())
|
|
path.addPolygon(self.arrowHead)
|
|
return path
|
|
|
|
def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionGraphicsItem,
|
|
widget: Optional[QtWidgets.QWidget] = ...) -> None:
|
|
|
|
currentStartPos = self.myStartItem.pos()
|
|
currentEndPos = self.myEndItem.pos()
|
|
|
|
self.myColor = self.colorSelected if self.isSelected() else self.colorDefault
|
|
|
|
p1 = QtCore.QPointF(currentStartPos.x() + 20, currentStartPos.y() + 20)
|
|
p2 = QtCore.QPointF(currentEndPos.x() + 20, currentEndPos.y() + 20)
|
|
|
|
line = QtCore.QLineF(p1, p2)
|
|
|
|
if line.length() < 45:
|
|
if self.isSelected():
|
|
self.labelItem.hide()
|
|
self.setSelected(True)
|
|
else:
|
|
self.labelItem.hide()
|
|
return
|
|
|
|
angle = math.atan2(line.dy(), - line.dx())
|
|
|
|
if (line.length() < 50 + len(self.labelItem.text()) * 15) or \
|
|
self.scene().views()[0].zoom < self.scene().hideZoom:
|
|
if self.isSelected():
|
|
self.labelItem.hide()
|
|
self.setSelected(True)
|
|
else:
|
|
self.labelItem.hide()
|
|
else:
|
|
self.labelItem.show()
|
|
angle2 = math.degrees(math.pi - angle)
|
|
if 90 < angle2 < 270:
|
|
angle2 -= 180
|
|
# When the label flips, it starts from 0.3 and reads towards the starting node (as opposed to
|
|
# reading towards the ending node).
|
|
# To avoid overlap, we get the proportion of the text to the length of the line, and add it to the
|
|
# offset of 0.3 to keep the label at the same relative location.
|
|
self.labelItem.setPos(line.pointAt(0.3 + (self.labelItem.boundingRect().width() / line.length())))
|
|
else:
|
|
self.labelItem.setPos(line.pointAt(0.3))
|
|
self.labelItem.setRotation(angle2)
|
|
|
|
myPen = QtGui.QPen(self.myColor)
|
|
arrowSize = 20.0
|
|
painter.setPen(myPen)
|
|
painter.setBrush(self.myColor)
|
|
|
|
line.setLength(line.length() - 45)
|
|
self.line = line
|
|
|
|
arrowP1 = line.p2() + QtCore.QPointF(
|
|
math.sin(angle + math.pi / 3.0) * arrowSize,
|
|
math.cos(angle + math.pi / 3.0) * arrowSize)
|
|
arrowP2 = line.p2() + QtCore.QPointF(
|
|
math.sin(angle + math.pi - math.pi / 3.0) * arrowSize,
|
|
math.cos(angle + math.pi - math.pi / 3.0) * arrowSize)
|
|
|
|
self.arrowHead.clear()
|
|
for point in [line.p2(), arrowP1, arrowP2]:
|
|
self.arrowHead.append(point)
|
|
|
|
painter.drawLine(line)
|
|
painter.drawPolygon(self.arrowHead)
|
|
|
|
|
|
class GroupNodeListItem(QtWidgets.QListWidgetItem):
|
|
|
|
def __init__(self, icon, text, uid: str, listview=None) -> None:
|
|
super(GroupNodeListItem, self).__init__(icon, text, listview)
|
|
self.uid = uid
|
|
|
|
|
|
class GroupNodeChildList(QtWidgets.QWidget):
|
|
|
|
def __init__(self) -> None:
|
|
super(GroupNodeChildList, self).__init__()
|
|
|
|
self.setLayout(QtWidgets.QVBoxLayout())
|
|
titleLabel = QtWidgets.QLabel('Child Items')
|
|
titleLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
|
self.layout().addWidget(titleLabel)
|
|
|
|
self.itemList = ChildListWidget()
|
|
self.layout().addWidget(self.itemList)
|
|
|
|
|
|
class ChildListWidget(QtWidgets.QListWidget):
|
|
|
|
def __init__(self) -> None:
|
|
super(ChildListWidget, self).__init__()
|
|
self.setAlternatingRowColors(True)
|
|
self.setSortingEnabled(True)
|
|
|
|
def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
|
|
super().mouseMoveEvent(event)
|
|
|
|
itemDragged = None
|
|
if event.buttons() == QtCore.Qt.MouseButton.LeftButton:
|
|
itemDragged = self.itemAt(event.pos())
|
|
|
|
if itemDragged is None:
|
|
return
|
|
|
|
self.setCurrentItem(itemDragged)
|
|
drag = QtGui.QDrag(self)
|
|
mimeData = QtCore.QMimeData()
|
|
|
|
mimeData.setText(dumps({'uid': itemDragged.uid}))
|
|
drag.setMimeData(mimeData)
|
|
|
|
# All Entities should have icons, but you never know.
|
|
pixmap = None
|
|
if itemDragged.icon() is not None:
|
|
pixmap = itemDragged.icon().pixmap(100, 100)
|
|
drag.setPixmap(pixmap)
|
|
drag.setHotSpot(QtCore.QPoint(pixmap.rect().width() / 2, pixmap.rect().height() / 2))
|
|
drag.exec_()
|