Source code for RodTracker.ui.rodnumberwidget
# Copyright (c) 2023 Adrian Niemann Dmitry Puzyrev
#
# This file is part of RodTracker.
# RodTracker 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.
#
# RodTracker 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 RodTracker. If not, see <http://www.gnu.org/licenses/>.
"""**TBD**"""
from enum import Enum
from typing import List
import numpy as np
from PyQt5 import QtCore, QtGui, QtWidgets
[docs]
class RodStyle(str, Enum):
"""Styles for rod numbers."""
GENERAL = "background-color: transparent;" \
"color: rgb({},{},{}); " \
"font: {}px; font-weight: bold;"
SELECTED = "background-color: transparent;" \
"color: white; " \
"font: {}px; font-weight: bold;"
CONFLICT = "background-color: transparent;" \
"color: red; " \
"font: {}px; font-weight: bold;"
CHANGED = "background-color: transparent;" \
"color: green; " \
"font: {}px; font-weight: bold;"
[docs]
class RodState(Enum):
"""States of a rod."""
NORMAL = 0
SELECTED = 1
EDITING = 2
CHANGED = 3
CONFLICT = 4
[docs]
class RodStateError(ValueError):
"""Custom error that is raised when an unknown :class:`RodState` is
encountered."""
def __init__(self):
self.message = "The assigned RodState is invalid. Please assign a " \
"known RodState."
super().__init__(self.message)
[docs]
class RodNumberWidget(QtWidgets.QLineEdit):
"""A custom ``QLineEdit`` to display rod numbers and have associated rods.
Parameters
----------
color : str
The color of the rod that this widget represents.
parent : QWidget, optional
The widgets parent widget. Default is ``None``.
text : str, optional
The text displayed by the widget. Default is ``""``.
pos : QPoint, optional
The position of the widget (relative to its parent widget).
Default is ``QPoint(0, 0)``.
.. admonition:: Signals
- :attr:`activated`
- :attr:`dropped`
- :attr:`id_changed`
- :attr:`request_delete`
.. admonition:: Slots
- :meth:`update_settings`
Attributes
----------
rod_points : List[int]
The starting and ending points of the rod in **UNSCALED** form:\n
``[x1, y1, x2, y2]``
rod_center : np.array[float]
Center of the rod in **SCALED** form, i.e. adjusted to zoom levels and
offsets.
color : str
The color of the rod being represented.
"""
activated = QtCore.pyqtSignal(int, name="activated")
"""pyqtSignal(int) : Notifies, that this rod has been activated."""
dropped = QtCore.pyqtSignal(QtCore.QPoint, name="dropped")
"""pyqtSignal(QPoint) : Notifies, that his widget has been dropped after
dragging it to a new location."""
id_changed = QtCore.pyqtSignal(QtWidgets.QLineEdit, int,
name="id_changed")
"""pyqtSignal(QLineEdit, int) : Notifies, that this rod has changed its ID
(number)."""
request_delete = QtCore.pyqtSignal(QtWidgets.QLineEdit,
name="request_delete")
"""pyqtSignal(QLineEdit) : Requests the deletion of this rod by the
managing object."""
rod_state: RodState
seen: bool = False
settings_signal: QtCore.pyqtBoundSignal = None
"""pyqtBoundSignal : Signal to connect to for receiving updates of
settings.
Must be set before the creation of an instance to be used by that
instance.\n
By default ``None``."""
_boundary_offset: int = 5
_number_size: int = 12
_number_color: List[int] = [0, 0, 0]
_rod_thickness: int = 3
_rod_color: List[int] = [0, 255, 255]
def __init__(self, color, parent=None, text="", pos=QtCore.QPoint(0, 0)):
# General setup
super().__init__()
self.__mousePressPos = None
self.__mouseMovePos = None
# Include given parameters
self.setParent(parent)
self.setText(text)
self.initial_text = text
self.initial_pos = pos
self.move(pos)
self._rod_id = None
self._rod_state = RodState.NORMAL
self.rod_points = [0, 0, 0, 0]
self.rod_center = np.array([0, 0])
self.color = color
# Set initial visual appearance & function
self.setInputMask("99")
self.setMouseTracking(False)
self.setFrame(False)
self.setReadOnly(True)
self.setStyleSheet(RodStyle.GENERAL.format(*self._number_color,
self._number_size))
tmp_font = self.font()
tmp_font.setPixelSize(self._number_size)
tmp_font.setBold(True)
content_size = QtGui.QFontMetrics(tmp_font).boundingRect("99")
content_size.setWidth(content_size.width() + self._boundary_offset)
self.setGeometry(content_size)
# Connect to settings update signal
if self.settings_signal is not None:
self.settings_signal.connect(self.update_settings)
@property
def rod_id(self):
"""Property that represents the rod's ID (number).
Returns
-------
int
"""
return self._rod_id
@rod_id.setter
def rod_id(self, new_id: int):
if new_id > 99:
raise ValueError("Only values <100 allowed.")
self._rod_id = new_id
self.initial_text = str(new_id)
self.setText(str(new_id))
@property
def rod_state(self):
"""Holds the state of the rod.
State of the rod also determines the visual appearance.
Returns
-------
RodState
"""
return self._rod_state
@rod_state.setter
def rod_state(self, new_state: RodState):
new_style = ""
if new_state == RodState.NORMAL:
new_style = RodStyle.GENERAL.format(*self._number_color,
self._number_size)
elif new_state == RodState.SELECTED:
new_style = RodStyle.SELECTED.format(self._number_size)
elif new_state == RodState.EDITING:
new_style = RodStyle.SELECTED.format(self._number_size)
elif new_state == RodState.CHANGED:
new_style = RodStyle.CHANGED.format(self._number_size)
elif new_state == RodState.CONFLICT:
new_style = RodStyle.CONFLICT.format(self._number_size)
else:
raise RodStateError()
self._rod_state = new_state
self.setStyleSheet(new_style)
@property
def pen(self):
"""Holds the ``QPen`` with the visual settings defined by the current
state of this rod.
Returns
-------
QPen
Raises
------
RodStateError
"""
pen = QtGui.QPen()
if self.seen:
pen.setStyle(QtCore.Qt.SolidLine)
else:
pen.setStyle(QtCore.Qt.DotLine)
pen.setWidth(self._rod_thickness)
if self.rod_state == RodState.NORMAL:
pen.setColor(QtGui.QColor(*self._rod_color))
elif (self.rod_state == RodState.SELECTED or
self.rod_state == RodState.EDITING):
new_color = QtGui.QColor(QtCore.Qt.white)
new_color.setAlphaF(0.5)
pen.setColor(new_color)
elif self.rod_state == RodState.CHANGED:
pen.setColor(QtCore.Qt.green)
elif self.rod_state == RodState.CONFLICT:
pen.setColor(QtCore.Qt.red)
else:
raise RodStateError()
return pen
# Controlling "editing" behaviour
[docs]
def mouseDoubleClickEvent(self, e: QtGui.QMouseEvent) -> None:
""" Reimplements ``QLineEdit.mouseDoubleClickEvent(e)``.
Handles the selection of a rod number for editing.
Parameters
----------
e : QMouseEvent
Returns
-------
None
"""
self.setReadOnly(False)
self.selectAll()
[docs]
def keyPressEvent(self, e: QtGui.QKeyEvent) -> None:
""" Reimplements ``QLineEdit.keyPressEvent(e)``.
Handles the confirmation and exiting during rod number editing using
keyboard keys.
Parameters
----------
e : QMouseEvent
Returns
-------
None
.. hint::
**Emits:**
- :attr:`request_delete`
- :attr:`id_changed`
"""
if e.key() == QtCore.Qt.Key_Return or e.key() == QtCore.Qt.Key_Enter:
# Confirm & end editing (keep changes)
self.end(False)
self.setReadOnly(True)
user_text = self.text()
if user_text == "":
self.request_delete.emit(self)
return
self.initial_text = self.text()
previous_id = self.rod_id
self.rod_id = int(self.text())
self.id_changed.emit(self, previous_id)
elif e.key() == QtCore.Qt.Key_Escape:
# Abort editing (keep initial value)
self.end(False)
self.setReadOnly(True)
self.setText(self.initial_text)
else:
# Normal editing
super().keyPressEvent(e)
# Controlling "movement" behaviour
[docs]
def mouseMoveEvent(self, e: QtGui.QMouseEvent) -> None:
""" Reimplements ``QLineEdit.mouseMoveEvent(e)``.
Handles the position updating during drag&drop of this widget by the
user.
Parameters
----------
e : QMouseEvent
Returns
-------
None
"""
if self.isReadOnly():
if self.__mouseMovePos is None or self.__mousePressPos is None:
self.__mouseMovePos = e.globalPos()
self.__mousePressPos = e.globalPos()
return
curr_pos = self.mapToGlobal(self.pos())
global_pos = e.globalPos()
diff = global_pos - self.__mouseMovePos
new_pos = self.mapFromGlobal(curr_pos + diff)
self.move(new_pos)
self.__mouseMovePos = global_pos
self.parentWidget().repaint()
return
[docs]
def mousePressEvent(self, event):
""" Reimplements ``QLineEdit.mousePressEvent(event)``.
Handles the selection of a rod for corrections and drag&drop of this
widget by the user.
Parameters
----------
event : QMouseEvent
Returns
-------
None
.. hint::
**Emits:**
- :attr:`activated`
"""
# Propagate regular event (otherwise blocks functions relying on it)
QtWidgets.QLineEdit.mousePressEvent(self, event)
if self.isReadOnly():
self.__mousePressPos = None
self.__mouseMovePos = None
if event.button() == QtCore.Qt.LeftButton:
self.__mousePressPos = event.globalPos()
self.__mouseMovePos = event.globalPos()
self.activated.emit(self.rod_id)
[docs]
def mouseReleaseEvent(self, event) -> None:
""" Reimplements ``QLineEdit.mouseReleaseEvent(event)``.
Handles ending of drag&drop of this widget by the user.
Parameters
----------
event : QMouseEvent
Returns
-------
None
.. hint::
**Emits:**
- :attr:`dropped`
"""
if self.__mousePressPos is not None:
moved = event.globalPos() - self.__mousePressPos
if moved.manhattanLength() > 3:
# Mouse just moved minimally (not registered as "dragging")
event.ignore()
return
self.dropped.emit(event.globalPos())
return
# Actions triggered on other rods
[docs]
def deactivate_rod(self) -> None:
"""Handles the deactivation of this rod.
Returns
-------
None
"""
if self.styleSheet() != RodStyle.CONFLICT.format(self._number_size):
self.rod_state = RodState.NORMAL
self.setReadOnly(True)
[docs]
def copy(self):
"""Copies this instance of a :class:`RodNumberWidget`.
Returns
-------
RodNumberWidget
"""
copied = RodNumberWidget(self.color, self.parent(), self.text(),
self.pos())
copied.rod_state = self.rod_state
copied.rod_points = self.rod_points
copied.rod_id = self.rod_id
copied.seen = self.seen
copied.setVisible(False)
return copied
[docs]
@QtCore.pyqtSlot(int)
def resolution_adjust(self, font_size, bound_offset=5):
"""Sets the new font size and adapts the widgets size to it.
Currently not used."""
current_font = self.font()
current_font.setPointSizeF(font_size)
self.setFont(current_font)
self._boundary_offset = bound_offset
content_size = self.fontMetrics().boundingRect("99")
content_size.setWidth(content_size.width() + self._boundary_offset)
self.setGeometry(content_size)
[docs]
@QtCore.pyqtSlot(dict)
def update_settings(self, settings: dict):
"""Catches updates of the settings from a :class:`.Settings` class.
Checks for the keys relevant to itself and updates the corresponding
attributes. Redraws itself with the new settings in place, if
settings were changed.
Parameters
----------
settings : dict
Returns
-------
None
"""
settings_changed = False
if "number_size" in settings:
settings_changed = True
self._number_size = settings["number_size"]
if "number_color" in settings:
settings_changed = True
self._number_color = settings["number_color"]
if "boundary_offset" in settings:
settings_changed = True
self._boundary_offset = settings["boundary_offset"]
if "rod_thickness" in settings:
settings_changed = True
self._rod_thickness = settings["rod_thickness"]
if "rod_color" in settings:
settings_changed = True
self._rod_color = settings["rod_color"]
if settings_changed:
if self.rod_state == RodState.NORMAL:
self.setStyleSheet(RodStyle.GENERAL.format(
*self._number_color, self._number_size))
elif self.rod_state == RodState.SELECTED:
self.setStyleSheet(RodStyle.SELECTED.format(self._number_size))
elif self.rod_state == RodState.EDITING:
self.setStyleSheet(RodStyle.SELECTED.format(self._number_size))
elif self.rod_state == RodState.CHANGED:
self.setStyleSheet(RodStyle.CHANGED.format(self._number_size))
elif self.rod_state == RodState.CONFLICT:
self.setStyleSheet(RodStyle.CONFLICT.format(self._number_size))
content_size = self.fontMetrics().boundingRect("99")
content_size.setWidth(content_size.width() + self._boundary_offset)
self.setGeometry(content_size)
[docs]
@classmethod
def update_defaults(cls, settings: dict) -> None:
"""Catches updates of the settings from a :class:`.Settings` class.
Checks for the keys relevant to itself and updates the corresponding
class attributes. Updates those attributes to already have the
correct values when a new :class:`RodNumberWidget` is created.
Parameters
----------
settings : dict
Returns
-------
None
"""
if "number_size" in settings:
cls._number_size = settings["number_size"]
if "number_color" in settings:
cls._number_color = settings["number_color"]
if "boundary_offset" in settings:
cls._boundary_offset = settings["boundary_offset"]
if "rod_thickness" in settings:
cls._rod_thickness = settings["rod_thickness"]
if "rod_color" in settings:
cls._rod_color = settings["rod_color"]