diff --git a/src/main/python/plotlyst/core/domain.py b/src/main/python/plotlyst/core/domain.py index b448b472b..bee477cfe 100644 --- a/src/main/python/plotlyst/core/domain.py +++ b/src/main/python/plotlyst/core/domain.py @@ -2028,6 +2028,35 @@ def default_stages() -> List[SceneStage]: SceneStage('Proofread'), SceneStage('Final')] +class GraphicsItemType(Enum): + CHARACTER = 'character' + STICKER = 'sticker' + EVENT = 'event' + COMMENT = 'comment' + SETUP = 'setup' + NOTE = 'note' + IMAGE = 'image' + MAP_MARKER = 'map_marker' + ICON = 'icon' + MAP_AREA_SQUARE = 'map_area_square' + MAP_AREA_CIRCLE = 'map_area_circle' + MAP_AREA_CUSTOM = 'map_area_custom' + + def mimeType(self) -> str: + return f'application/node-{self.value}' + + +NODE_SUBTYPE_GOAL = 'goal' +NODE_SUBTYPE_CONFLICT = 'conflict' +NODE_SUBTYPE_DISTURBANCE = 'disturbance' +NODE_SUBTYPE_BACKSTORY = 'backstory' +NODE_SUBTYPE_INTERNAL_CONFLICT = 'internal_conflict' +NODE_SUBTYPE_QUESTION = 'question' +NODE_SUBTYPE_FORESHADOWING = 'foreshadowing' +NODE_SUBTYPE_TOOL = 'tool' +NODE_SUBTYPE_COST = 'cost' + + class VariableType(Enum): Text = 0 @@ -2148,17 +2177,27 @@ def __hash__(self): return hash(self.key) +@dataclass +class Point: + x: float + y: float + + @dataclass class WorldBuildingMarker: x: float y: float + type: GraphicsItemType = GraphicsItemType.MAP_MARKER name: str = '' description: str = '' color: str = '#ef233c' color_selected: str = '#A50C1E' icon: str = 'mdi.circle' size: int = field(default=0, metadata=config(exclude=exclude_if_empty)) + height: int = field(default=0, metadata=config(exclude=exclude_if_empty)) + width: int = field(default=0, metadata=config(exclude=exclude_if_empty)) ref: Optional[uuid.UUID] = field(default=None, metadata=config(exclude=exclude_if_empty)) + points: List[Point] = field(default_factory=list, metadata=config(exclude=exclude_if_empty)) @dataclass @@ -3406,32 +3445,6 @@ def default_tags() -> Dict[TagType, List[Tag]]: return tags -class GraphicsItemType(Enum): - CHARACTER = 'character' - STICKER = 'sticker' - EVENT = 'event' - COMMENT = 'comment' - SETUP = 'setup' - NOTE = 'note' - IMAGE = 'image' - MAP_MARKER = 'map_marker' - ICON = 'icon' - - def mimeType(self) -> str: - return f'application/node-{self.value}' - - -NODE_SUBTYPE_GOAL = 'goal' -NODE_SUBTYPE_CONFLICT = 'conflict' -NODE_SUBTYPE_DISTURBANCE = 'disturbance' -NODE_SUBTYPE_BACKSTORY = 'backstory' -NODE_SUBTYPE_INTERNAL_CONFLICT = 'internal_conflict' -NODE_SUBTYPE_QUESTION = 'question' -NODE_SUBTYPE_FORESHADOWING = 'foreshadowing' -NODE_SUBTYPE_TOOL = 'tool' -NODE_SUBTYPE_COST = 'cost' - - @dataclass class Node(CharacterBased): x: float diff --git a/src/main/python/plotlyst/view/common.py b/src/main/python/plotlyst/view/common.py index 856826093..7f0591dc3 100644 --- a/src/main/python/plotlyst/view/common.py +++ b/src/main/python/plotlyst/view/common.py @@ -96,6 +96,15 @@ def _text_color_with_rgb(r: int, g: int, b: int) -> str: return 'black' if hsp > 171.5 else WHITE_COLOR +def stronger_color(hex_color: str, factor: float = 1.5) -> str: + color = QColor(hex_color) + h, s, v, a = color.getHsv() + s = min(255, int(s * factor)) # Scale up the saturation but cap at 255 + v = max(0, int(v / factor)) # Darken the color + color.setHsv(h, s, v, a) + return color.name() + + def action(text: str, icon: Optional[QIcon] = None, slot=None, parent=None, checkable: bool = False, tooltip: str = '', incr_font_: Optional[int] = None) -> QAction: _action = QAction(text) diff --git a/src/main/python/plotlyst/view/widget/graphics/view.py b/src/main/python/plotlyst/view/widget/graphics/view.py index ff415b9df..e515be4d9 100644 --- a/src/main/python/plotlyst/view/widget/graphics/view.py +++ b/src/main/python/plotlyst/view/widget/graphics/view.py @@ -65,8 +65,6 @@ def mousePressEvent(self, event: QMouseEvent) -> None: self.setDragMode(QGraphicsView.DragMode.NoDrag) self._moveOriginX = event.pos().x() self._moveOriginY = event.pos().y() - else: - self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) super(BaseGraphicsView, self).mousePressEvent(event) @overrides diff --git a/src/main/python/plotlyst/view/widget/world/map.py b/src/main/python/plotlyst/view/widget/world/map.py index a01159b73..f3e94815b 100644 --- a/src/main/python/plotlyst/view/widget/world/map.py +++ b/src/main/python/plotlyst/view/widget/world/map.py @@ -17,15 +17,18 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import math from functools import partial from typing import Optional, Any import qtanim from PyQt6.QtCore import Qt, QPoint, QSize, QPointF, QRectF, pyqtSignal, QTimer, QObject -from PyQt6.QtGui import QColor, QPixmap, QShowEvent, QResizeEvent, QImage, QPainter, QKeyEvent, QIcon, QUndoStack +from PyQt6.QtGui import QColor, QPixmap, QShowEvent, QResizeEvent, QImage, QPainter, QKeyEvent, QIcon, QUndoStack, \ + QPainterPath, QPen, QMouseEvent from PyQt6.QtWidgets import QGraphicsScene, QGraphicsPixmapItem, QGraphicsItem, QAbstractGraphicsShapeItem, QWidget, \ QGraphicsSceneMouseEvent, QGraphicsOpacityEffect, QGraphicsDropShadowEffect, QFrame, QLineEdit, \ - QApplication, QGraphicsSceneDragDropEvent, QSlider + QApplication, QGraphicsSceneDragDropEvent, QSlider, QGraphicsRectItem, QGraphicsEllipseItem, QGraphicsPathItem, \ + QGraphicsView from overrides import overrides from qthandy import busy, vbox, sp, line, incr_font, flow, incr_icon, bold, vline, \ margins, decr_font, translucent @@ -34,12 +37,13 @@ from qtpy import sip from plotlyst.common import PLOTLYST_SECONDARY_COLOR, RELAXED_WHITE_COLOR, PLOTLYST_TERTIARY_COLOR, PLOTLYST_MAIN_COLOR -from plotlyst.core.domain import Novel, WorldBuildingMap, WorldBuildingMarker, GraphicsItemType, Location +from plotlyst.core.domain import Novel, WorldBuildingMap, WorldBuildingMarker, GraphicsItemType, Location, Point from plotlyst.resources import resource_registry from plotlyst.service.cache import entities_registry from plotlyst.service.image import load_image, upload_image, LoadedImage from plotlyst.service.persistence import RepositoryPersistenceManager -from plotlyst.view.common import tool_btn, action, shadow, TooltipPositionEventFilter, dominant_color, push_btn +from plotlyst.view.common import tool_btn, action, shadow, TooltipPositionEventFilter, dominant_color, push_btn, \ + ExclusiveOptionalButtonGroup from plotlyst.view.icons import IconRegistry from plotlyst.view.widget.graphics import BaseGraphicsView from plotlyst.view.widget.graphics.editor import ZoomBar, BaseItemToolbar, \ @@ -210,6 +214,10 @@ def setMarker(self, item: 'MarkerItem'): self._btnColor.setIcon(IconRegistry.from_name('fa5s.map-marker', color=marker.color)) self._sbSize.setValue(marker.size if marker.size else 50) + self._btnIcon.setEnabled(marker.type == GraphicsItemType.MAP_MARKER) + self._sbSize.setVisible(marker.type == GraphicsItemType.MAP_MARKER) + self._toolbar.updateGeometry() + self._item = item @busy @@ -237,37 +245,20 @@ def _sizeChanged(self, value: int): self._item.setSize(value) -class MarkerItem(QAbstractGraphicsShapeItem): - DEFAULT_MARKER_WIDTH: int = 50 - DEFAULT_MARKER_HEIGHT: int = 70 - - def __init__(self, marker: WorldBuildingMarker, parent=None): - super().__init__(parent) - self._marker = marker - self._width = marker.size if marker.size else self.DEFAULT_MARKER_WIDTH - self._height = int(self._width * (self.DEFAULT_MARKER_HEIGHT / self.DEFAULT_MARKER_WIDTH)) - self.__default_type_size = self._width // 2 +class BaseMapItem: + def __init__(self): + self.__default_type_size = 25 self._typeSize = self.__default_type_size - - self._posChangedTimer = QTimer() - self._posChangedTimer.setInterval(1000) - self._posChangedTimer.timeout.connect(self._posChangedOnTimeout) + self._marker = None self.setFlag( QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) self.setAcceptHoverEvents(True) - self._iconMarker = IconRegistry.from_name('fa5s.map-marker', self._marker.color) - self._iconMarkerSelected = IconRegistry.from_name('fa5s.map-marker', self._marker.color_selected) - if self._marker.icon: - self._iconType = IconRegistry.from_name(self._marker.icon, RELAXED_WHITE_COLOR) - else: - self._iconType = QIcon() - - self.setPos(self._marker.x, self._marker.y) - - self._checkRef() + self._posChangedTimer = QTimer() + self._posChangedTimer.setInterval(1000) + self._posChangedTimer.timeout.connect(self._posChangedOnTimeout) def marker(self) -> WorldBuildingMarker: return self._marker @@ -275,6 +266,12 @@ def marker(self) -> WorldBuildingMarker: def mapScene(self) -> 'WorldBuildingMapScene': return self.scene() + def activate(self): + self._checkRef() + + def highlight(self): + self.mapScene().highlightItem(self) + def setLocation(self, location: Location): self._marker.ref = location.id self.mapScene().markerChangedEvent(self) @@ -284,6 +281,91 @@ def setColor(self, color: str): self._marker.color_selected = marker_selected_colors[color] self.refresh() + def refresh(self): + self.update() + self.mapScene().markerChangedEvent(self) + + def _hoverEnter(self, event: 'QGraphicsSceneHoverEvent') -> None: + if not self.isSelected(): + effect = QGraphicsOpacityEffect() + effect.setOpacity(0.8) + self.setGraphicsEffect(effect) + self._typeSize = self.__default_type_size + 1 + self.update() + + if self._marker.ref: + if entities_registry.location(str(self._marker.ref)): + QTimer.singleShot(250, self._triggerPopup) + else: + self._marker.ref = None + self.mapScene().markerChangedEvent(self) + + def _hoverLeave(self, event: 'QGraphicsSceneHoverEvent') -> None: + if not self.isSelected(): + self.setGraphicsEffect(None) + self._checkRef() + self._typeSize = self.__default_type_size + self.update() + + if self._marker.ref: + self.scene().hidePopupEvent() + + def _onSelection(self, selected: bool): + if selected: + effect = QGraphicsDropShadowEffect() + effect.setBlurRadius(12) + effect.setOffset(0) + effect.setColor(QColor(RELAXED_WHITE_COLOR)) + self.setGraphicsEffect(effect) + + self._typeSize = self.__default_type_size + 2 + else: + self._typeSize = self.__default_type_size + self.setGraphicsEffect(None) + self._checkRef() + + def _checkRef(self): + if not self._marker.ref: + effect = QGraphicsOpacityEffect() + effect.setOpacity(0.5) + self.setGraphicsEffect(effect) + + def _posChangedOnTimeout(self): + self._posChangedTimer.stop() + self._marker.x = self.scenePos().x() + self._marker.y = self.scenePos().y() + scene = self.mapScene() + if scene: + scene.markerChangedEvent(self) + + def _triggerPopup(self): + if not self.isSelected() and self.isUnderMouse(): + self.mapScene().showPopupEvent(self) + + +class MarkerItem(QAbstractGraphicsShapeItem, BaseMapItem): + DEFAULT_MARKER_WIDTH: int = 50 + DEFAULT_MARKER_HEIGHT: int = 70 + + def __init__(self, marker: WorldBuildingMarker, parent=None): + super().__init__(parent) + self._marker = marker + self._width = marker.size if marker.size else self.DEFAULT_MARKER_WIDTH + self._height = int(self._width * (self.DEFAULT_MARKER_HEIGHT / self.DEFAULT_MARKER_WIDTH)) + self.__default_type_size = self._width // 2 + self._typeSize = self.__default_type_size + + self._iconMarker = IconRegistry.from_name('fa5s.map-marker', self._marker.color) + self._iconMarkerSelected = IconRegistry.from_name('fa5s.map-marker', self._marker.color_selected) + if self._marker.icon: + self._iconType = IconRegistry.from_name(self._marker.icon, RELAXED_WHITE_COLOR) + else: + self._iconType = QIcon() + + self.setPos(self._marker.x, self._marker.y) + + self._checkRef() + def setIcon(self, icon: str): self._marker.icon = icon self.refresh() @@ -306,8 +388,7 @@ def refresh(self): if self._marker.icon: self._iconType = IconRegistry.from_name(self._marker.icon, RELAXED_WHITE_COLOR) - self.update() - self.mapScene().markerChangedEvent(self) + super().refresh() @overrides def boundingRect(self) -> QRectF: @@ -341,179 +422,181 @@ def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> An @overrides def hoverEnterEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: - if not self.isSelected(): - effect = QGraphicsOpacityEffect() - effect.setOpacity(0.9) - self.setGraphicsEffect(effect) - self._typeSize = self.__default_type_size + 1 - self.update() + self._hoverEnter(event) - if self._marker.ref: - if entities_registry.location(str(self._marker.ref)): - QTimer.singleShot(250, self._triggerPopup) - else: - self._marker.ref = None - self.mapScene().markerChangedEvent(self) + @overrides + def hoverLeaveEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: + self._hoverLeave(event) + + +class BaseMapAreaItem(BaseMapItem): + def setMarker(self, marker: WorldBuildingMarker): + self._marker = marker + self.setPen(QPen(Qt.GlobalColor.black, 1)) + color = QColor(marker.color) + color.setAlpha(125) + self.setBrush(color) + + @overrides + def refresh(self): + color = QColor(self._marker.color) + color.setAlpha(125) + self.setBrush(color) + super().refresh() + + +class AreaSquareItem(QGraphicsRectItem, BaseMapAreaItem): + def __init__(self, marker: WorldBuildingMarker, rect: QRectF, parent=None): + super().__init__(rect, parent) + self.setMarker(marker) + + @overrides + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any: + if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + self._posChangedTimer.start() + scene = self.mapScene() + if scene: + scene.itemMovedEvent(self) + if change == QGraphicsItem.GraphicsItemChange.ItemSelectedChange: + self._onSelection(value) + return super().itemChange(change, value) + + @overrides + def hoverEnterEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: + self._hoverEnter(event) @overrides def hoverLeaveEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: - if not self.isSelected(): - self.setGraphicsEffect(None) - self._checkRef() - self._typeSize = self.__default_type_size - self.update() + self._hoverLeave(event) - if self._marker.ref: - self.scene().hidePopupEvent() + @overrides + def _posChangedOnTimeout(self): + self._posChangedTimer.stop() + self._marker.x = self.rect().x() + self.scenePos().x() + self._marker.y = self.rect().y() + self.scenePos().y() + scene = self.mapScene() + if scene: + scene.markerChangedEvent(self) - def activate(self): - self._checkRef() - def highlight(self): - self.mapScene().highlightItem(self) +class AreaCircleItem(QGraphicsEllipseItem, BaseMapAreaItem): + def __init__(self, marker: WorldBuildingMarker, rect: QRectF, parent=None): + super().__init__(rect, parent) + self.setMarker(marker) - def _checkRef(self): - if not self._marker.ref: - effect = QGraphicsOpacityEffect() - effect.setOpacity(0.5) - self.setGraphicsEffect(effect) + @overrides + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any: + if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + self._posChangedTimer.start() + scene = self.mapScene() + if scene: + scene.itemMovedEvent(self) + if change == QGraphicsItem.GraphicsItemChange.ItemSelectedChange: + self._onSelection(value) + return super().itemChange(change, value) + + @overrides + def hoverEnterEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: + self._hoverEnter(event) + @overrides + def hoverLeaveEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: + self._hoverLeave(event) + + @overrides def _posChangedOnTimeout(self): self._posChangedTimer.stop() - self._marker.x = self.scenePos().x() - self._marker.y = self.scenePos().y() + self._marker.x = self.rect().x() + self.scenePos().x() + self._marker.y = self.rect().y() + self.scenePos().y() scene = self.mapScene() if scene: scene.markerChangedEvent(self) - def _triggerPopup(self): - if not self.isSelected() and self.isUnderMouse(): - self.scene().showPopupEvent(self) - def _onSelection(self, selected: bool): - if selected: - effect = QGraphicsDropShadowEffect() - effect.setBlurRadius(12) - effect.setOffset(0) - effect.setColor(QColor(RELAXED_WHITE_COLOR)) - self.setGraphicsEffect(effect) +class AreaCustomPathItem(QGraphicsPathItem, BaseMapAreaItem): + def __init__(self, marker: WorldBuildingMarker, path: QPainterPath, parent=None): + super().__init__(path, parent) + self.setMarker(marker) + self._threshold = 5 + self._last_point = None - self._typeSize = self.__default_type_size + 2 + @overrides + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any: + if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: + self._posChangedTimer.start() + scene = self.mapScene() + if scene: + scene.itemMovedEvent(self) + if change == QGraphicsItem.GraphicsItemChange.ItemSelectedChange: + self._onSelection(value) + return super().itemChange(change, value) + + @overrides + def hoverEnterEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: + self._hoverEnter(event) + + @overrides + def hoverLeaveEvent(self, event: 'QGraphicsSceneHoverEvent') -> None: + self._hoverLeave(event) + + def addPoint(self, point: QPointF): + path = self.path() + if self._last_point is None: + path.moveTo(point) + self._last_point = point else: - self._typeSize = self.__default_type_size - self.setGraphicsEffect(None) - self._checkRef() + if self._distance(self._last_point, point) < self._threshold: + return + path.lineTo(point) + self._last_point = point + self._marker.points.append(Point(int(point.x()), int(point.y()))) + self.setPath(path) -# class EntityEditorWidget(QFrame): -# changed = pyqtSignal(MarkerItem) -# -# def __init__(self, parent=None): -# super().__init__(parent) -# self._marker: Optional[WorldBuildingMarker] = None -# self._item: Optional[MarkerItem] = None -# self.setFrameShape(QFrame.Shape.StyledPanel) -# self.setStyleSheet('''QFrame { -# background: #ede0d4; -# border-radius: 12px; -# }''') -# -# vbox(self, 5, 0) -# self._scrollarea, self.wdgCenter = scrolled(self) -# self._scrollarea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) -# self._scrollarea.setProperty('transparent', True) -# self.wdgCenter.setProperty('transparent', True) -# -# shadow(self) -# vbox(self.wdgCenter, 2, spacing=6) -# -# self.btnLinkMilieu = push_btn(IconRegistry.world_building_icon(), 'Link to milieu', transparent_=True) -# decr_font(self.btnLinkMilieu) -# -# self.lineTitle = QLineEdit() -# self.lineTitle.setProperty('transparent', True) -# self.lineTitle.setPlaceholderText('Name') -# self.lineTitle.setAlignment(Qt.AlignmentFlag.AlignCenter) -# incr_font(self.lineTitle) -# bold(self.lineTitle) -# self.lineTitle.textEdited.connect(self._nameChanged) -# -# self.textEdit = AutoAdjustableTextEdit(height=100) -# self.textEdit.setProperty('transparent', True) -# self.textEdit.setProperty('rounded', True) -# self.textEdit.setPlaceholderText('Edit synopsis') -# self.textEdit.setMaximumHeight(150) -# self.textEdit.textChanged.connect(self._synopsisChanged) -# -# self.wdgColorSelector = MarkerColorSelectorWidget() -# self.wdgColorSelector.colorSelected.connect(self._colorChanged) -# self.wdgIconSelector = MarkerIconSelectorWidget() -# self.wdgIconSelector.iconReset.connect(self._iconReset) -# self.wdgIconSelector.iconSelected.connect(self._iconChanged) -# -# self.wdgCenter.layout().addWidget(self.btnLinkMilieu, alignment=Qt.AlignmentFlag.AlignRight) -# self.wdgCenter.layout().addWidget(self.lineTitle) -# self.wdgCenter.layout().addWidget(line(color='lightgrey')) -# self._addHeader('Synopsis', self.textEdit) -# self._addHeader('Color', self.wdgColorSelector) -# self._addHeader('Icon', self.wdgIconSelector) -# self.wdgCenter.layout().addWidget(vspacer()) -# -# self.setFixedWidth(200) -# -# sp(self).v_max() -# -# def setMarker(self, item: MarkerItem): -# self._marker = None -# self._item = None -# self.textEdit.setText(item.marker().description) -# self.lineTitle.setText(item.marker().name) -# self._marker = item.marker() -# self._item = item -# -# def _nameChanged(self, text: str): -# if self._marker: -# self._marker.name = text -# self.changed.emit(self._item) -# -# def _synopsisChanged(self): -# if self._marker: -# self._marker.description = self.textEdit.toPlainText() -# self.changed.emit(self._item) -# -# def _colorChanged(self, color: str): -# if self._marker: -# self._marker.color = color -# self._marker.color_selected = marker_selected_colors[color] -# self._item.refresh() -# -# def _iconChanged(self, icon: str): -# if self._marker: -# self._marker.icon = icon -# self._item.refresh() -# -# def _iconReset(self): -# if self._marker: -# self._marker.icon = '' -# self._item.refresh() -# -# def _addHeader(self, text: str, wdg: QWidget) -> CollapseButton: -# btn = CollapseButton(Qt.Edge.RightEdge, Qt.Edge.BottomEdge) -# decr_icon(btn, 4) -# decr_font(btn) -# btn.setChecked(True) -# btn.setText(text) -# wrapped = wrap(wdg, margin_left=5) -# btn.toggled.connect(wrapped.setVisible) -# -# self.wdgCenter.layout().addWidget(btn, alignment=Qt.AlignmentFlag.AlignLeft) -# self.wdgCenter.layout().addWidget(wrapped) -# -# return btn + def finish(self, point: QPointF): + path = self.path() + path.lineTo(point) + self.setPath(path) + + self._last_point = None + + def _distance(self, p1: QPointF, p2: QPointF) -> float: + return math.hypot(p2.x() - p1.x(), p2.y() - p1.y()) + + @overrides + def _posChangedOnTimeout(self): + self._posChangedTimer.stop() + self._marker.x += self.scenePos().x() + self._marker.y += self.scenePos().y() + for point in self._marker.points: + point.x += self.scenePos().x() + point.y += self.scenePos().y() + scene = self.mapScene() + if scene: + scene.markerChangedEvent(self) + + +class AreaSelectorWidget(SecondarySelectorWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self._btnSquare = self.addItemTypeButton(GraphicsItemType.MAP_AREA_SQUARE, + IconRegistry.from_name('mdi.select'), + 'Select a square-shaped area', 0, 0) + self._btnCircle = self.addItemTypeButton(GraphicsItemType.MAP_AREA_CIRCLE, + IconRegistry.from_name('mdi.selection-ellipse'), + 'Select a circle-shaped area', 1, 0) + self._btnCustom = self.addItemTypeButton(GraphicsItemType.MAP_AREA_CUSTOM, + IconRegistry.from_name('mdi.draw'), + 'Draw and select a custom area', 2, 0) + + @overrides + def showEvent(self, event: QShowEvent) -> None: + self._btnCustom.setChecked(True) class WorldBuildingMapScene(QGraphicsScene): - showPopup = pyqtSignal(MarkerItem) + showPopup = pyqtSignal(BaseMapItem) hidePopup = pyqtSignal() cancelItemAddition = pyqtSignal() itemAdded = pyqtSignal() @@ -524,7 +607,9 @@ def __init__(self, novel: Novel, parent=None): self._novel = novel self._map: Optional[WorldBuildingMap] = None self._animParent = QObject() - self._additionMode: bool = False + self._additionDescriptor: Optional[GraphicsItemType] = None + self._area_start_point = None + self._current_area_item: Optional[BaseMapItem] = None self.repo = RepositoryPersistenceManager.instance() @@ -532,7 +617,12 @@ def map(self) -> Optional[WorldBuildingMap]: return self._map def isAdditionMode(self) -> bool: - return self._additionMode + return self._additionDescriptor is not None + + def isAreaAdditionMode(self) -> bool: + if self._additionDescriptor and self._additionDescriptor.name.startswith('MAP_AREA'): + return True + return False def showPopupEvent(self, item: MarkerItem): self.showPopup.emit(item) @@ -564,8 +654,11 @@ def dragMoveEvent(self, event: QGraphicsSceneDragDropEvent) -> None: @overrides def dropEvent(self, event: QGraphicsSceneDragDropEvent) -> None: - self._addMarker(event.scenePos()) - event.accept() + if event.mimeData().hasFormat(GraphicsItemType.MAP_MARKER.mimeType()): + self._addMarker(event.scenePos()) + event.accept() + else: + event.ignore() @busy def loadMap(self, map: WorldBuildingMap) -> Optional[QGraphicsPixmapItem]: @@ -582,20 +675,73 @@ def loadMap(self, map: WorldBuildingMap) -> Optional[QGraphicsPixmapItem]: self.addItem(item) for marker in self._map.markers: - markerItem = MarkerItem(marker) + if marker.type == GraphicsItemType.MAP_MARKER: + markerItem = MarkerItem(marker) + elif marker.type == GraphicsItemType.MAP_AREA_SQUARE: + rect = QRectF(marker.x, marker.y, marker.width, marker.height) + markerItem = AreaSquareItem(marker, rect) + elif marker.type == GraphicsItemType.MAP_AREA_CIRCLE: + rect = QRectF(marker.x, marker.y, marker.width, marker.height) + markerItem = AreaCircleItem(marker, rect) + elif marker.type == GraphicsItemType.MAP_AREA_CUSTOM: + path = QPainterPath(QPointF(marker.x, marker.y)) + for point in marker.points: + path.lineTo(point.x, point.y) + path.lineTo(marker.x, marker.y) + markerItem = AreaCustomPathItem(marker, path) + else: + continue self.addItem(markerItem) + markerItem.activate() return item else: self._map = None + @overrides + def mousePressEvent(self, event: 'QGraphicsSceneMouseEvent') -> None: + if self.isAreaAdditionMode() and event.button() == Qt.MouseButton.LeftButton: + self._addArea(event.scenePos()) + super().mousePressEvent(event) + + @overrides + def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent') -> None: + if self._current_area_item: + current_point = event.scenePos() + if self._additionDescriptor != GraphicsItemType.MAP_AREA_CUSTOM: + rect = QRectF(self._area_start_point, + current_point).normalized() # normalize to handle negative coordinates + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + size = min(rect.width(), rect.height()) + rect = QRectF(self._area_start_point, self._area_start_point + QPointF(size, size)).normalized() + self._current_area_item.setRect(rect) + self._current_area_item.marker().width = int(rect.width()) + self._current_area_item.marker().height = int(rect.height()) + else: + self._current_area_item.addPoint(event.scenePos()) + + super().mouseMoveEvent(event) + @overrides def mouseReleaseEvent(self, event: 'QGraphicsSceneMouseEvent') -> None: + if self._current_area_item: + if self._additionDescriptor == GraphicsItemType.MAP_AREA_CUSTOM: + self._current_area_item.finish(self._area_start_point) + self.repo.update_world(self._novel) + + self._current_area_item.activate() + + self._area_start_point = None + self._current_area_item = None if self.isAdditionMode() and event.button() & Qt.MouseButton.RightButton: self.cancelItemAddition.emit() self.endAdditionMode() elif self.isAdditionMode(): - self._addMarker(event.scenePos()) + if self._additionDescriptor == GraphicsItemType.MAP_MARKER: + self._addMarker(event.scenePos()) + elif self.isAreaAdditionMode(): + self.cancelItemAddition.emit() + self.endAdditionMode() super().mouseReleaseEvent(event) @@ -610,14 +756,15 @@ def markerChangedEvent(self, _: MarkerItem): def itemMovedEvent(self, _: MarkerItem): self.itemMoved.emit() - def startAdditionMode(self, _: GraphicsItemType): - self._additionMode = True + def startAdditionMode(self, itemType: GraphicsItemType): + self._additionDescriptor = itemType def endAdditionMode(self): - self._additionMode = False + self._additionDescriptor = None - def highlightItem(self, item: MarkerItem): - anim = qtanim.glow(item, duration=250, radius=50, loop=1, color=QColor(PLOTLYST_MAIN_COLOR), teardown=item.activate) + def highlightItem(self, item: BaseMapItem): + anim = qtanim.glow(item, duration=250, radius=50, loop=1, color=QColor(PLOTLYST_MAIN_COLOR), + teardown=item.activate) anim.setParent(self._animParent) def _addMarker(self, pos: QPointF): @@ -634,6 +781,20 @@ def _addMarker(self, pos: QPointF): self.itemAdded.emit() self.endAdditionMode() + def _addArea(self, pos: QPointF): + self._area_start_point = pos + marker = WorldBuildingMarker(self._area_start_point.x(), self._area_start_point.y(), + type=self._additionDescriptor) + self._map.markers.append(marker) + if self._additionDescriptor == GraphicsItemType.MAP_AREA_SQUARE: + self._current_area_item = AreaSquareItem(marker, QRectF(self._area_start_point, self._area_start_point)) + elif self._additionDescriptor == GraphicsItemType.MAP_AREA_CIRCLE: + self._current_area_item = AreaCircleItem(marker, QRectF(self._area_start_point, self._area_start_point)) + else: + path = QPainterPath(self._area_start_point) + self._current_area_item = AreaCustomPathItem(marker, path) + self.addItem(self._current_area_item) + def _removeItem(self, item: QGraphicsItem): def remove(): self._map.markers.remove(item.marker()) @@ -661,9 +822,18 @@ def __init__(self, novel: Novel, parent=None): vbox(self._controlsNavBar, 5, 6) self._controlsNavBar.setHidden(True) + self._btnGroup = ExclusiveOptionalButtonGroup() + self._btnAddMarker = self._newControlButton(IconRegistry.from_name('fa5s.map-marker'), 'Add new marker (or double-click on the map)', GraphicsItemType.MAP_MARKER) + self._btnAddArea = self._newControlButton(IconRegistry.from_name('mdi.select'), + 'Add a new area', + GraphicsItemType.MAP_AREA_CUSTOM) + + self._wdgSecondaryAreaSelector = AreaSelectorWidget(self) + self._wdgSecondaryAreaSelector.setVisible(False) + self._wdgSecondaryAreaSelector.selected.connect(self._startAddition) # self._wdgEditor = EntityEditorWidget(self) # self._wdgEditor.setHidden(True) @@ -726,6 +896,12 @@ def resizeEvent(self, event: QResizeEvent) -> None: super().resizeEvent(event) self._arrangeSideBars() + @overrides + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton and self._scene.isAreaAdditionMode(): + self.setDragMode(QGraphicsView.DragMode.NoDrag) + super().mousePressEvent(event) + @overrides def _scale(self, scale: float): super()._scale(scale) @@ -743,6 +919,12 @@ def _arrangeSideBars(self): self._controlsNavBar.setGeometry(10, 100, self._controlsNavBar.sizeHint().width(), self._controlsNavBar.sizeHint().height()) + secondary_x = self._controlsNavBar.pos().x() + self._controlsNavBar.sizeHint().width() + 5 + secondary_y = self._controlsNavBar.pos().y() + self._btnAddArea.pos().y() + self._wdgSecondaryAreaSelector.setGeometry(secondary_x, secondary_y, + self._wdgSecondaryAreaSelector.sizeHint().width(), + self._wdgSecondaryAreaSelector.sizeHint().height()) + # self._wdgEditor.setGeometry(self.width() - self._wdgEditor.width() - 20, # 20, # self._wdgEditor.width(), @@ -758,6 +940,7 @@ def _newControlButton(self, icon: QIcon, tooltip: str, itemType: GraphicsItemTyp btn.installEventFilter(TooltipPositionEventFilter(btn)) incr_icon(btn, 2) + self._btnGroup.addButton(btn) self._controlsNavBar.layout().addWidget(btn) btn.clicked.connect(partial(self._mainControlClicked, itemType)) @@ -771,16 +954,28 @@ def _mainControlClicked(self, itemType: GraphicsItemType, checked: bool): self._scene.endAdditionMode() def _startAddition(self, itemType: GraphicsItemType): + for btn in self._btnGroup.buttons(): + if not btn.isChecked(): + btn.setDisabled(True) + if not QApplication.overrideCursor(): QApplication.setOverrideCursor(Qt.CursorShape.PointingHandCursor) + if itemType.name.startswith('MAP_AREA'): + self._wdgSecondaryAreaSelector.setVisible(True) + else: + self._wdgSecondaryAreaSelector.setVisible(False) + self._scene.startAdditionMode(itemType) - self.setToolTip('Click to add a new marker') def _endAddition(self): - self._btnAddMarker.setChecked(False) + for btn in self._btnGroup.buttons(): + btn.setEnabled(True) + if btn.isChecked(): + btn.setChecked(False) + + self._wdgSecondaryAreaSelector.setHidden(True) QApplication.restoreOverrideCursor() - self.setToolTip('') def _loadMap(self, map: WorldBuildingMap): self._bgItem = self._scene.loadMap(map) @@ -823,7 +1018,7 @@ def _selectionChanged(self): else: self._markerEditor.setVisible(False) - def _showPopup(self, item: MarkerItem): + def _showPopup(self, item: BaseMapItem): location = entities_registry.location(str(item.marker().ref)) if location: self._popup.setText(location.name, location.summary)