From df9dd73a2903ebaf606e4d58d931e2561a3386f1 Mon Sep 17 00:00:00 2001 From: Zsolt Kovari <7029304+zkovari@users.noreply.github.com> Date: Thu, 17 Aug 2023 09:32:02 +0200 Subject: [PATCH] Basic events mindmap (#664) * Empty graphics view and scene * Remove coveralls * Character node item * Basic connector * Start eventitem * Event sockets * Display sockets on linking hover event * Edit event text * bg color * paint socket * remove helpers * connect sockets * Rearrange connectors * zoombar * Add new event * alt hover socket * Fix tests --- .github/workflows/test.yml | 2 - src/main/python/plotlyst/view/common.py | 19 +- src/main/python/plotlyst/view/novel_view.py | 6 +- src/main/python/plotlyst/view/style/base.py | 5 + src/main/python/plotlyst/view/style/button.py | 20 + .../python/plotlyst/view/widget/events_map.py | 549 ++++++++++++++++++ .../python/plotlyst/view/widget/graphics.py | 81 ++- 7 files changed, 673 insertions(+), 9 deletions(-) create mode 100644 src/main/python/plotlyst/view/widget/events_map.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30bc7f581..350680685 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,8 +46,6 @@ jobs: DISPLAY: ':99.0' run: | ./test.sh - pip install coveralls - coveralls --service=github core-windows-test: runs-on: windows-2019 diff --git a/src/main/python/plotlyst/view/common.py b/src/main/python/plotlyst/view/common.py index 5b486d9fd..e209ae806 100644 --- a/src/main/python/plotlyst/view/common.py +++ b/src/main/python/plotlyst/view/common.py @@ -31,7 +31,7 @@ from fbs_runtime import platform from overrides import overrides from qtanim import fade_out -from qthandy import hbox, vbox, margins, gc +from qthandy import hbox, vbox, margins, gc, transparent from src.main.python.plotlyst.env import app_env @@ -372,7 +372,8 @@ def insert_after(parent: QWidget, widget: QWidget, reference: QWidget): def tool_btn(icon: QIcon, tooltip: str = '', checkable: bool = False, base: bool = False, - icon_resize: bool = True) -> QToolButton: + icon_resize: bool = True, transparent_: bool = False, properties: List[str] = None, + parent=None) -> QToolButton: btn = QToolButton() btn.setIcon(icon) btn.setToolTip(tooltip) @@ -382,10 +383,22 @@ def tool_btn(icon: QIcon, tooltip: str = '', checkable: bool = False, base: bool btn.setProperty('base', True) if icon_resize: btn.installEventFilter(ButtonPressResizeEventFilter(btn)) - + if transparent_: + transparent(btn) + if properties: + for prop in properties: + btn.setProperty(prop, True) + if parent: + btn.setParent(parent) return btn +def frame(parent=None): + frame_ = QFrame(parent) + frame_.setFrameShape(QFrame.Shape.StyledPanel) + return frame_ + + class ExclusiveOptionalButtonGroup(QButtonGroup): def __init__(self, parent=None): super().__init__(parent) diff --git a/src/main/python/plotlyst/view/novel_view.py b/src/main/python/plotlyst/view/novel_view.py index b3eb68675..70463d4a1 100644 --- a/src/main/python/plotlyst/view/novel_view.py +++ b/src/main/python/plotlyst/view/novel_view.py @@ -38,6 +38,7 @@ from src.main.python.plotlyst.view.icons import IconRegistry from src.main.python.plotlyst.view.style.base import apply_border_image from src.main.python.plotlyst.view.widget.button import SecondaryActionToolButton +from src.main.python.plotlyst.view.widget.events_map import EventsMindMapView from src.main.python.plotlyst.view.widget.plot import PlotEditor @@ -115,6 +116,9 @@ def __init__(self, novel: Novel): self.ui.lblSynopsisWords.setWordCount(self.ui.textSynopsis.textEdit.statistics().word_count) self.ui.textSynopsis.textEdit.textChanged.connect(self._synopsis_changed) + self._eventsMap = EventsMindMapView(self.novel) + self.ui.wdgEventsMapParent.layout().addWidget(self._eventsMap) + self.ui.wdgStructure.setNovel(self.novel) self.ui.wdgTitle.setFixedHeight(150) apply_border_image(self.ui.wdgTitle, resource_registry.frame1) @@ -123,7 +127,7 @@ def __init__(self, novel: Novel): self.ui.wdgPlotContainer.layout().addWidget(self.plot_editor) self.ui.wdgTagsContainer.setNovel(self.novel) - self.ui.tabWidget.setCurrentWidget(self.ui.tabSynopsis) + self.ui.tabWidget.setCurrentWidget(self.ui.tabEvents) @overrides def refresh(self): diff --git a/src/main/python/plotlyst/view/style/base.py b/src/main/python/plotlyst/view/style/base.py index a3c8701a3..4f6b26a0c 100644 --- a/src/main/python/plotlyst/view/style/base.py +++ b/src/main/python/plotlyst/view/style/base.py @@ -74,6 +74,11 @@ background-color: #f8f9fa; } +QFrame[rounded=true] { + border: 1px solid lightgrey; + border-radius: 6px; +} + QDialog[relaxed-white-bg] { background-color: #f8f9fa; } diff --git a/src/main/python/plotlyst/view/style/button.py b/src/main/python/plotlyst/view/style/button.py index b3594e72a..22b680721 100644 --- a/src/main/python/plotlyst/view/style/button.py +++ b/src/main/python/plotlyst/view/style/button.py @@ -257,6 +257,26 @@ padding: 4px; } +QToolButton[transparent-rounded-bg-on-hover=true] { + border-radius: 4px; + border: 1px hidden lightgrey; + padding: 2px; +} +QToolButton::menu-indicator[transparent-rounded-bg-on-hover=true] { + width:0px; +} +QToolButton:hover[transparent-rounded-bg-on-hover=true] { + background: #EDEDED; +} + +QToolButton:hover[events-sidebar=true] { + background: lightgrey; +} + +QToolButton:checked[events-sidebar=true] { + background: #D4B8E0 +} + QToolButton:hover[analysis-top-selector=true] { background: lightgrey; } diff --git a/src/main/python/plotlyst/view/widget/events_map.py b/src/main/python/plotlyst/view/widget/events_map.py new file mode 100644 index 000000000..26fb760cd --- /dev/null +++ b/src/main/python/plotlyst/view/widget/events_map.py @@ -0,0 +1,549 @@ +""" +Plotlyst +Copyright (C) 2021-2023 Zsolt Kovari + +This file is part of Plotlyst. + +Plotlyst is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Plotlyst is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from enum import Enum +from typing import Optional, List + +from PyQt6.QtCore import QRectF, Qt, QPointF, pyqtSignal, QRect, QPoint +from PyQt6.QtGui import QColor, QPainter, QPen, QKeyEvent, QFontMetrics, QResizeEvent +from PyQt6.QtWidgets import QGraphicsScene, QWidget, QAbstractGraphicsShapeItem, QGraphicsSceneHoverEvent, \ + QGraphicsSceneMouseEvent, QStyleOptionGraphicsItem, QGraphicsTextItem, QApplication +from overrides import overrides +from qthandy import transparent, hbox, vbox, sp, margins, incr_icon +from qtmenu import MenuWidget + +from src.main.python.plotlyst.common import PLOTLYST_SECONDARY_COLOR +from src.main.python.plotlyst.core.domain import Novel, Character, CharacterNode, Node +from src.main.python.plotlyst.view.common import tool_btn, shadow, frame, ExclusiveOptionalButtonGroup, \ + TooltipPositionEventFilter +from src.main.python.plotlyst.view.icons import avatars, IconRegistry +from src.main.python.plotlyst.view.widget.graphics import BaseGraphicsView, NodeItem, ConnectorItem +from src.main.python.plotlyst.view.widget.input import AutoAdjustableLineEdit + + +def draw_rect(painter: QPainter, item: QAbstractGraphicsShapeItem): + painter.setPen(QPen(Qt.GlobalColor.red, 1, Qt.PenStyle.DashLine)) + painter.drawRoundedRect(item.boundingRect(), 2, 2) + + +def draw_center(painter: QPainter, item: QAbstractGraphicsShapeItem): + painter.setPen(QPen(Qt.GlobalColor.red, 1, Qt.PenStyle.DashLine)) + painter.drawEllipse(item.boundingRect().center(), 1, 1) + + +def draw_zero(painter: QPainter): + painter.setPen(QPen(Qt.GlobalColor.blue, 1, Qt.PenStyle.DashLine)) + painter.drawEllipse(QPointF(0, 0), 1, 1) + + +def draw_helpers(painter: QPainter, item: QAbstractGraphicsShapeItem): + draw_rect(painter, item) + draw_center(painter, item) + draw_zero(painter) + + +class ItemType(Enum): + Event = 0 + + +class MindMapNode(NodeItem): + def mindMapScene(self) -> 'EventsMindMapScene': + return self.scene() + + def linkMode(self) -> bool: + return self.mindMapScene().linkMode() + + +class SocketItem(QAbstractGraphicsShapeItem): + def __init__(self, parent: 'ConnectableNode'): + super(SocketItem, self).__init__(parent) + + self._size = 16 + self.setAcceptHoverEvents(True) + self._hovered = False + self._linkAvailable = True + self.setToolTip('Connect') + + self._connectors: List[ConnectorItem] = [] + + @overrides + def boundingRect(self): + return QRectF(0, 0, self._size, self._size) + + @overrides + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = ...) -> None: + if self._linkAvailable: + painter.setPen(QPen(QColor(PLOTLYST_SECONDARY_COLOR), 2)) + else: + painter.setPen(QPen(QColor('lightgrey'), 2)) + + radius = 7 if self._hovered else 5 + painter.drawEllipse(QPointF(self._size / 2, self._size // 2), radius, radius) + if self._hovered and self.mindMapScene().linkMode(): + painter.drawEllipse(QPointF(self._size / 2, self._size // 2), 2, 2) + + @overrides + def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent) -> None: + self._hovered = True + if self.mindMapScene().linkMode() and self.mindMapScene().linkSource().parentItem() == self.parentItem(): + self._linkAvailable = False + else: + self._linkAvailable = True + self.setToolTip('Connect' if self._linkAvailable else 'Cannot connect to itself') + self.update() + + @overrides + def hoverLeaveEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: + self._hovered = False + self._linkAvailable = True + self.setToolTip('Connect') + self.update() + + @overrides + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + event.accept() + + @overrides + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if self.mindMapScene().linkMode(): + if self.mindMapScene().linkSource().parentItem() != self.parentItem(): + self.mindMapScene().link(self) + else: + self.mindMapScene().startLink(self) + + def addConnector(self, connector: ConnectorItem): + self._connectors.append(connector) + + def rearrangeConnectors(self): + for con in self._connectors: + con.rearrange() + + def mindMapScene(self) -> 'EventsMindMapScene': + return self.scene() + + +class PlaceholderItem(SocketItem): + def __init__(self, parent=None): + super().__init__(parent) + self.setEnabled(False) + self.setAcceptHoverEvents(False) + self.setToolTip('Click to add a new node') + + +class ConnectableNode(MindMapNode): + def __init__(self, node: Node, parent=None): + super().__init__(node, parent) + self._sockets: List[SocketItem] = [] + + @overrides + def hoverEnterEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: + if self.linkMode() or event.modifiers() & Qt.KeyboardModifier.AltModifier: + self._setSocketsVisible() + + @overrides + def hoverLeaveEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: + if not self.isSelected(): + self._setSocketsVisible(False) + + @overrides + def _onPosChanged(self): + for socket in self._sockets: + socket.rearrangeConnectors() + + @overrides + def _onSelection(self, selected: bool): + self._setSocketsVisible(selected) + + def _setSocketsVisible(self, visible: bool = True): + for socket in self._sockets: + socket.setVisible(visible) + + +class EventItem(ConnectableNode): + Margin: int = 30 + Padding: int = 20 + + def __init__(self, node: Node, parent=None): + super().__init__(node, parent) + self._text: str = 'New event' + self.setPos(node.x, node.y) + + self._metrics = QFontMetrics(QApplication.font()) + self._textRect: QRect = QRect(0, 0, 1, 1) + self._width = 1 + self._height = 1 + self._nestedRectWidth = 1 + self._nestedRectHeight = 1 + + self._socketLeft = SocketItem(self) + self._socketTopLeft = SocketItem(self) + self._socketTopCenter = SocketItem(self) + self._socketTopRight = SocketItem(self) + self._socketRight = SocketItem(self) + self._socketBottomLeft = SocketItem(self) + self._socketBottomCenter = SocketItem(self) + self._socketBottomRight = SocketItem(self) + self._sockets.extend([self._socketLeft, + self._socketTopLeft, self._socketTopCenter, self._socketTopRight, + self._socketRight, + self._socketBottomRight, self._socketBottomCenter, self._socketBottomLeft]) + self._setSocketsVisible(False) + + self._recalculateRect() + + def text(self) -> str: + return self._text + + def setText(self, text: str): + self._text = text + self._recalculateRect() + self.prepareGeometryChange() + self.setSelected(False) + self.update() + + def textRect(self) -> QRect: + return self._textRect + + def textSceneRect(self) -> QRectF: + return self.mapRectToScene(self._textRect.toRectF()) + + @overrides + def boundingRect(self) -> QRectF: + return QRectF(0, 0, self._width, self._height) + + @overrides + def paint(self, painter: QPainter, option: 'QStyleOptionGraphicsItem', widget: Optional[QWidget] = ...) -> None: + if self.isSelected(): + painter.setPen(QPen(Qt.GlobalColor.gray, 2, Qt.PenStyle.DashLine)) + painter.drawRoundedRect(self.Margin, self.Margin, self._nestedRectWidth, self._nestedRectHeight, 2, 2) + + painter.setPen(QPen(Qt.GlobalColor.black, 1)) + painter.drawText(self._textRect, Qt.AlignmentFlag.AlignCenter, self._text) + painter.drawRoundedRect(self.Margin, self.Margin, self._nestedRectWidth, self._nestedRectHeight, 24, 24) + + @overrides + def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: + self.mindMapScene().editEventText(self) + + def _recalculateRect(self): + self._textRect = self._metrics.boundingRect(self._text) + self._textRect.moveTopLeft(QPoint(self.Margin + self.Padding, self.Margin + self.Padding)) + self._width = self._textRect.width() + self.Margin * 2 + self.Padding * 2 + self._height = self._textRect.height() + self.Margin * 2 + self.Padding * 2 + + self._nestedRectWidth = self._textRect.width() + self.Padding * 2 + self._nestedRectHeight = self._textRect.height() + self.Padding * 2 + + socketWidth = self._socketLeft.boundingRect().width() + socketRad = socketWidth / 2 + socketPadding = (self.Margin - socketWidth) / 2 + self._socketTopCenter.setPos(self._width / 2 - socketRad, socketPadding) + self._socketTopLeft.setPos(self._nestedRectWidth / 3 - socketRad, socketPadding) + self._socketTopRight.setPos(self._nestedRectWidth, socketPadding) + self._socketRight.setPos(self._width - self.Margin + socketPadding, self._height / 2 - socketRad) + self._socketBottomCenter.setPos(self._width / 2 - socketRad, self._height - self.Margin + socketPadding) + self._socketBottomLeft.setPos(self._nestedRectWidth / 3 - socketRad, + self._height - self.Margin + socketPadding) + self._socketBottomRight.setPos(self._nestedRectWidth, self._height - self.Margin + socketPadding) + self._socketLeft.setPos(socketPadding, self._height / 2 - socketRad) + + +class CharacterItem(ConnectableNode): + def __init__(self, character: Character, node: CharacterNode, parent=None): + super().__init__(node, parent) + self._character = character + + self._size: int = 108 + self._margin = 30 + + self._socketTop = SocketItem(self) + self._socketRight = SocketItem(self) + self._socketBottom = SocketItem(self) + self._socketLeft = SocketItem(self) + self._sockets.extend([self._socketLeft, self._socketTop, self._socketRight, self._socketBottom]) + socketWidth = self._socketTop.boundingRect().width() + half = self._margin + (self._size - socketWidth) / 2 + padding = (self._margin - socketWidth) / 2 + self._socketTop.setPos(half, padding) + self._socketRight.setPos(self._size + self._margin + padding, half) + self._socketBottom.setPos(half, self._size + self._margin + padding) + self._socketLeft.setPos(padding, half) + + self._setSocketsVisible(False) + + @overrides + def boundingRect(self) -> QRectF: + return QRectF(0, 0, self._size + self._margin * 2, self._size + self._margin * 2) + + def rightSocket(self) -> SocketItem: + return self._socketRight + + @overrides + def paint(self, painter: QPainter, option: 'QStyleOptionGraphicsItem', widget: Optional[QWidget] = ...) -> None: + if self.isSelected(): + painter.setPen(QPen(Qt.GlobalColor.gray, 2, Qt.PenStyle.DashLine)) + painter.drawRoundedRect(self._margin, self._margin, self._size, self._size, 2, 2) + + avatar = avatars.avatar(self._character) + avatar.paint(painter, self._margin, self._margin, self._size, self._size) + + +class TextLineEditorPopup(MenuWidget): + + def __init__(self, text: str, rect: QRect, parent=None): + super().__init__(parent) + transparent(self) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self._lineEdit = AutoAdjustableLineEdit(defaultWidth=rect.width()) + self._lineEdit.setText(text) + self.addWidget(self._lineEdit) + + self._lineEdit.editingFinished.connect(self.hide) + + @overrides + def showEvent(self, QShowEvent): + self._lineEdit.setFocus() + + def text(self) -> str: + return self._lineEdit.text() + + +class AdditionMode(Enum): + NONE = 0 + EVENT = 1 + CHARACTER = 2 + + +class EventsMindMapScene(QGraphicsScene): + itemAdded = pyqtSignal() + editEvent = pyqtSignal(EventItem) + + def __init__(self, novel: Novel, parent=None): + super().__init__(parent) + self._novel = novel + self._linkMode: bool = False + self._additionMode: AdditionMode = AdditionMode.NONE + self._placeholder: Optional[PlaceholderItem] = None + self._connectorPlaceholder: Optional[ConnectorItem] = None + + if novel.characters: + characterItem = CharacterItem(novel.characters[0], CharacterNode(50, 50)) + characterItem.setPos(50, 50) + + self.addItem(characterItem) + eventItem = EventItem(Node(400, 100)) + self.addItem(eventItem) + + def linkMode(self) -> bool: + return self._linkMode + + def linkSource(self) -> Optional[SocketItem]: + if self._connectorPlaceholder is not None: + return self._connectorPlaceholder.source() + + def startLink(self, source: SocketItem): + self._linkMode = True + self._placeholder = PlaceholderItem() + self._placeholder.setVisible(False) + self._placeholder.setEnabled(False) + self.addItem(self._placeholder) + self._connectorPlaceholder = ConnectorItem(source, self._placeholder) + self.addItem(self._connectorPlaceholder) + + self._placeholder.setPos(source.scenePos()) + self._connectorPlaceholder.rearrange() + + def endLink(self): + self._linkMode = False + self.removeItem(self._connectorPlaceholder) + self.removeItem(self._placeholder) + self._connectorPlaceholder = None + self._placeholder = None + + def link(self, target: SocketItem): + connector = ConnectorItem(self._connectorPlaceholder.source(), target) + self._connectorPlaceholder.source().addConnector(connector) + target.addConnector(connector) + self.addItem(connector) + self.endLink() + + def editEventText(self, item: EventItem): + self.editEvent.emit(item) + + def addNewItem(self, pos: QPointF, itemType: ItemType): + if itemType == ItemType.Event: + item = QGraphicsTextItem('Type') + else: + return + + item.setPos(pos) + connector = ConnectorItem(self._connectorPlaceholder.source(), item) + self.addItem(item) + self.addItem(connector) + + self.endLink() + + def startAdditionMode(self, mode: AdditionMode): + self._additionMode = mode + + def endAdditionMode(self): + self._additionMode = AdditionMode.NONE + + @overrides + def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent') -> None: + if self.linkMode(): + self._placeholder.setPos(event.scenePos()) + self._connectorPlaceholder.rearrange() + super().mouseMoveEvent(event) + + @overrides + def keyPressEvent(self, event: QKeyEvent) -> None: + if event.key() == Qt.Key.Key_Escape: + if self.linkMode(): + self.endLink() + else: + self.clearSelection() + elif not event.modifiers() and len(self.selectedItems()) == 1: + item = self.selectedItems()[0] + if isinstance(item, EventItem): + self.editEvent.emit(item) + + @overrides + def mouseReleaseEvent(self, event: 'QGraphicsSceneMouseEvent') -> None: + if self.linkMode(): + if event.button() & Qt.MouseButton.RightButton: + self.endLink() + elif self._additionMode == AdditionMode.EVENT: + item = EventItem(self.toEventNode(event)) + self.addItem(item) + self.itemAdded.emit() + elif self._additionMode == AdditionMode.CHARACTER: + self.itemAdded.emit() + + super().mouseReleaseEvent(event) + + @staticmethod + def toEventNode(event: 'QGraphicsSceneMouseEvent') -> Node: + node = Node(event.scenePos().x(), event.scenePos().y()) + node.x = node.x - EventItem.Margin - EventItem.Padding + node.y = node.y - EventItem.Margin - EventItem.Padding + return node + + +class EventsMindMapView(BaseGraphicsView): + + def __init__(self, novel: Novel, parent=None): + super().__init__(parent) + self._novel = novel + self._scene = EventsMindMapScene(self._novel) + self.setScene(self._scene) + # self.setBackgroundBrush(QColor(RELAXED_WHITE_COLOR)) + self.setBackgroundBrush(QColor('#e9ecef')) + + # self.scale(0.6, 0.6) + + self._scene.itemAdded.connect(self._endAddition) + self._scene.editEvent.connect(self._editEvent) + + self._controlsNavBar = frame(self) + self._controlsNavBar.setProperty('relaxed-white-bg', True) + self._controlsNavBar.setProperty('rounded', True) + sp(self._controlsNavBar).h_max() + shadow(self._controlsNavBar) + + self._btnAddEvent = tool_btn( + IconRegistry.from_name('mdi.calendar-plus'), 'Add new event', True, + icon_resize=False, properties=['transparent-rounded-bg-on-hover', 'events-sidebar'], + parent=self._controlsNavBar) + self._btnAddCharacter = tool_btn( + IconRegistry.character_icon('#040406'), 'Add new character', True, + icon_resize=False, properties=['transparent-rounded-bg-on-hover', 'events-sidebar'], + parent=self._controlsNavBar) + self._btnGroup = ExclusiveOptionalButtonGroup() + self._btnGroup.addButton(self._btnAddEvent) + self._btnGroup.addButton(self._btnAddCharacter) + for btn in self._btnGroup.buttons(): + btn.installEventFilter(TooltipPositionEventFilter(btn)) + incr_icon(btn, 2) + self._btnGroup.buttonClicked.connect(self._startAddition) + vbox(self._controlsNavBar, 5, 6) + self._controlsNavBar.layout().addWidget(self._btnAddEvent) + self._controlsNavBar.layout().addWidget(self._btnAddCharacter) + + self._wdgZoomBar = frame(self) + self._wdgZoomBar.setProperty('relaxed-white-bg', True) + self._wdgZoomBar.setProperty('rounded', True) + shadow(self._wdgZoomBar) + hbox(self._wdgZoomBar, 2, spacing=6) + margins(self._wdgZoomBar, left=10, right=10) + + self._btnZoomIn = tool_btn(IconRegistry.plus_circle_icon('lightgrey'), 'Zoom in', transparent_=True, + parent=self._wdgZoomBar) + self._btnZoomOut = tool_btn(IconRegistry.minus_icon('lightgrey'), 'Zoom out', transparent_=True, + parent=self._wdgZoomBar) + self._btnZoomIn.clicked.connect(lambda: self.scale(1.1, 1.1)) + self._btnZoomOut.clicked.connect(lambda: self.scale(0.9, 0.9)) + + self._wdgZoomBar.layout().addWidget(self._btnZoomOut) + self._wdgZoomBar.layout().addWidget(self._btnZoomIn) + self.__arrangeSideBars() + + @overrides + def resizeEvent(self, event: QResizeEvent) -> None: + super(EventsMindMapView, self).resizeEvent(event) + self.__arrangeSideBars() + + # def _displayNewNodeMenu(self, placeholder: PlaceholderItem): + # menu = MenuWidget(self) + # menu.addAction( + # action('Event', + # slot=lambda: self._scene.addNewItem(placeholder.sceneBoundingRect().center(), ItemType.Event))) + # + # view_pos = self.mapFromScene(placeholder.sceneBoundingRect().center()) + # menu.exec(self.mapToGlobal(view_pos)) + + def _editEvent(self, item: EventItem): + def setText(text: str): + item.setText(text) + + popup = TextLineEditorPopup(item.text(), item.textRect(), parent=self) + view_pos = self.mapFromScene(item.textSceneRect().topLeft()) + popup.exec(self.mapToGlobal(view_pos)) + + popup.aboutToHide.connect(lambda: setText(popup.text())) + + def _startAddition(self): + if self._btnAddEvent.isChecked(): + self._scene.startAdditionMode(AdditionMode.EVENT) + elif self._btnAddCharacter.isChecked(): + self._scene.startAdditionMode(AdditionMode.CHARACTER) + + def _endAddition(self): + btn = self._btnGroup.checkedButton() + if btn: + btn.setChecked(False) + + self._scene.endAdditionMode() + + def __arrangeSideBars(self): + self._wdgZoomBar.setGeometry(10, self.height() - self._wdgZoomBar.sizeHint().height() - 10, + self._wdgZoomBar.sizeHint().width(), + self._wdgZoomBar.sizeHint().height()) + self._controlsNavBar.setGeometry(10, 100, self._controlsNavBar.sizeHint().width(), + self._controlsNavBar.sizeHint().height()) diff --git a/src/main/python/plotlyst/view/widget/graphics.py b/src/main/python/plotlyst/view/widget/graphics.py index a6edfdeda..5279808bc 100644 --- a/src/main/python/plotlyst/view/widget/graphics.py +++ b/src/main/python/plotlyst/view/widget/graphics.py @@ -17,12 +17,87 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from typing import Any, Optional -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QPainter, QWheelEvent, QMouseEvent -from PyQt6.QtWidgets import QGraphicsView +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QPainter, QWheelEvent, QMouseEvent, QPen, QPainterPath, QColor +from PyQt6.QtWidgets import QGraphicsView, QAbstractGraphicsShapeItem, QGraphicsItem, QGraphicsPathItem from overrides import overrides +from src.main.python.plotlyst.core.domain import Node + + +class ConnectorItem(QGraphicsPathItem): + + def __init__(self, source: QAbstractGraphicsShapeItem, target: QAbstractGraphicsShapeItem, + pen: Optional[QPen] = None): + super(ConnectorItem, self).__init__() + self._source = source + self._target = target + if pen: + self.setPen(pen) + else: + self.setPen(QPen(QColor(Qt.GlobalColor.darkBlue), 2)) + + # self.setPos(self._source.sceneBoundingRect().center()) + self.rearrange() + + def rearrange(self): + self.setPos(self._source.sceneBoundingRect().center()) + + path = QPainterPath() + target_x = self._target.scenePos().x() - self.pos().x() + target_y = self._target.scenePos().y() + path.quadTo(0, target_y / 2, target_x, target_y - self._source.scenePos().y()) + # if self._target.scenePos().y() < 0: + # elif self._target.scenePos().y() > 0: + # path.quadTo(0, target_y / 2, target_x, target_y) + # else: + # path.lineTo(target_x, target_y) + + self.setPath(path) + + def source(self) -> QAbstractGraphicsShapeItem: + return self._source + + def target(self) -> QAbstractGraphicsShapeItem: + return self._target + + +class NodeItem(QAbstractGraphicsShapeItem): + def __init__(self, node: Node, parent=None): + super().__init__(parent) + self._node = node + + self.setFlag( + QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | + QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) + self.setAcceptHoverEvents(True) + + self._posChangedTimer = QTimer() + self._posChangedTimer.setInterval(1000) + self._posChangedTimer.timeout.connect(self._posChangedOnTimeout) + + @overrides + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any: + if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + self._posChangedTimer.start(1000) + self._onPosChanged() + elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedChange: + self._onSelection(value) + return super(NodeItem, self).itemChange(change, value) + + def _onPosChanged(self): + pass + + def _onSelection(self, selected: bool): + pass + + def _posChangedOnTimeout(self): + self._posChangedTimer.stop() + self._node.x = self.scenePos().x() + self._node.y = self.scenePos().y() + class BaseGraphicsView(QGraphicsView): def __init__(self, parent=None):