# 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**"""
import os
import logging
import pathlib
import sys
import subprocess
from abc import abstractmethod
from enum import Enum, auto
from typing import Optional, Iterable, Union, List
import numpy as np
from PyQt5.QtWidgets import QListWidgetItem
from PyQt5 import QtCore
from RodTracker import LOG_FILE
import RodTracker.ui.rodnumberwidget as rn
_logger = logging.getLogger(__name__)
[docs]def exception_logger(e_type, e_value, e_tb):
"""Handler for logging uncaught exceptions during the program flow."""
_logger.exception("Uncaught exception:", exc_info=(e_type, e_value, e_tb))
[docs]def qt_error_handler(mode: QtCore.QtMsgType,
context: QtCore.QMessageLogContext, msg: str):
"""Handler for logging uncaught Qt exceptions during the program flow."""
context_info = (f"category: {context.category}\n"
f"function: {context.function}, line: {context.line}\n"
f"file: {context.file}\n")
if mode == QtCore.QtInfoMsg:
_logger.info(context_info + f"{msg}")
elif mode == QtCore.QtWarningMsg:
_logger.warning(context_info + f"{msg}")
elif mode == QtCore.QtCriticalMsg:
_logger.critical(context_info + f"{msg}")
elif mode == QtCore.QtFatalMsg:
_logger.error(context_info + f"{msg}")
else:
_logger.debug(context_info + f"{msg}")
QtCore.qInstallMessageHandler(qt_error_handler)
[docs]def open_logs():
"""Opens the log file."""
if sys.platform == "win32":
os.startfile(LOG_FILE)
else:
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.run([opener, LOG_FILE])
[docs]class FileActions(Enum):
"""Helper class holding all valid kinds of :class:`FileActions`.
Attributes
----------
SAVE : str
String representing the base of a saving to file action.
LOAD_IMAGES : str
String representing the base of a loaded images action.
OPEN_IMAGE : str
.. deprecated:: 0.1.0
Should not be used anymore, because it clutters the displayed log
of performed actions.
MODIFY : str
.. deprecated:: 0.1.0
Should not used be anymore, because all changes are made in RAM.
Use :attr:`SAVE` instead.
LOAD_RODS : str
String representing the base of a loaded rod position data action.
"""
SAVE = "Saved changes"
LOAD_IMAGES = "image file(s) loaded from"
OPEN_IMAGE = "Opened image"
MODIFY = "Modified file"
LOAD_RODS = "Loaded rod file(s) from"
[docs]class NumberChangeActions(Enum):
"""Helper class holding valid kinds of rod number changes.
Attributes
----------
ALL : int
Indicates a switch of rod numbers in all cameras from the current frame
to the last frame of the dataset.
ALL_ONE_CAM : int
Indicates a switch of rod numbers in the currently displayed camera
from the current frame to the last frame of the dataset.
ONE_BOTH_CAMS : int
Indicates a switch of rod numbers in all cameras for the current frame
only.
CURRENT : int
Indicates a switch of rod numbers in the current camera only and the
current frame only.
"""
ALL = auto()
ALL_ONE_CAM = auto()
ONE_BOTH_CAMS = auto()
CURRENT = auto()
[docs]class NotInvertableError(Exception):
"""Raised when a not invertable action is attempted to be inverted."""
pass
[docs]class Action(QListWidgetItem):
"""Base class for all Actions that are loggable by an
:class:`ActionLogger`."""
action: str
_parent_id: str = None
_frame: int = None
revert: bool = False
@property
def inverted(self):
"""Returns a 'plain' inverted version of the action without any
coupled actions."""
return self.invert()
@property
def parent_id(self) -> str:
"""The ID of the object that is responsible for (reverting) this
action."""
return self._parent_id
@parent_id.setter
def parent_id(self, new_id: str):
self._parent_id = new_id
self.setText(str(self))
@property
def frame(self) -> int:
"""Property holding the frame on which this :class:`Action` was
performed."""
return self._frame
@frame.setter
def frame(self, frame_id: int) -> None:
self._frame = frame_id
self.setText(str(self))
[docs] @abstractmethod
def __str__(self):
"""Returns a string representation of the action."""
[docs] @abstractmethod
def undo(self, rods: Optional[Iterable[rn.RodNumberWidget]]):
"""Triggers events to revert this action."""
[docs] def to_save(self):
"""Gives information for saving this action, None, if it's not
savable."""
return None
[docs] def invert(self):
"""Generates an inverted version of the :class:`Action` (for redoing),
None if the :class:`Action` is not invertible."""
return None
[docs]class FileAction(Action):
"""Class to represent a loggable action that was performed on a file.
Parameters
----------
path : str
Path to the file that this action describes.
action : FileActions
file_num : int, optional
Number of the image file that was loaded. It will be displayed to
the user, if it was set. (Default is None)
cam_id : str, optional
The objects ID on which behalf this action was done. This is
necessary for displaying it to the user. (Default is None)
parent_id : str, optional
The ID of the object that is responsible for (reverting) this
action. (Default is None)
*args : iterable
Positional arguments for the ``QListWidgetItem`` superclass.
**kwargs : dict
Keyword arguments for the ``QListWidgetItem`` superclass.
Attributes
----------
action : FileActions
Description of what kind of action was performed.
file : str
Path to the file that this action describes.
file_num : Union[int, None]
Number of the image file that was loaded. It will be displayed to
the user, if it was set.
cam_id : str
The objects ID on which behalf this action was done. This is
necessary for displaying it to the user.
"""
action: FileActions
def __init__(self, path: pathlib.Path, action: FileActions, file_num=None,
cam_id=None, parent_id: str = None, *args, **kwargs):
self._parent_id = parent_id
self.file = path
self.action = action
self.file_num = None
self.cam_id = cam_id
if action is FileActions.LOAD_IMAGES:
self.file_num = file_num
elif action is FileActions.LOAD_RODS:
pass
elif action is FileActions.MODIFY:
pass
super().__init__(str(self), *args, **kwargs)
def __str__(self):
to_str = f"{self.action.value}: {self.file}"
if self.file_num is not None:
to_str = str(self.file_num) + " " + to_str
if self.cam_id is not None:
to_str = f"({self.cam_id}) " + to_str
if self._parent_id is not None:
to_str = f"({self._parent_id}) " + to_str
return to_str
@property
def parent_id(self):
return self._parent_id
@parent_id.setter
def parent_id(self, new_id: str):
self._parent_id = new_id
self.setText(str(self))
[docs] def undo(self, rods=None):
if self.action is FileActions.MODIFY:
# TODO: evaluate whether it should be implemented
pass
else:
# This action cannot be undone
return
[docs]class ChangedRodNumberAction(Action):
"""Class to represent a change of the rod number as a loggable action.
Parameters
----------
rod : RodNumberWidget
A copy of the rod whose number is changed.
new_id : int
The new rod number of the changed rod.
coupled_action : Action, optional
The instance of an :class:`Action` that is performed at the same time
with this and must be reverted as well, if this :class:`Action` is
reverted. For example when the numbers of two rods are exchanged.
(Default is None)
*args : iterable
Positional arguments for the ``QListWidgetItem`` superclass.
**kwargs : dict
Keyword arguments for the ``QListWidgetItem`` superclass.
Attributes
----------
rod : RodNumberWidget
A copy of the rod whose number is changed.
new_id : int
The new rod number of the changed rod.
action : str
Description of what kind of action was performed.
(Default is "Changed rod")
coupled_action : Union[Action, None]
The instance of an :class:`Action` that is performed at the same time
with this and must be reverted as well, if this :class:`Action` is
reverted.
"""
def __init__(self, old_rod: rn.RodNumberWidget, new_id: int,
coupled_action: Action = None, *args,
**kwargs):
self.rod = old_rod
self.new_id = new_id
self.action = "Changed rod"
self.coupled_action = coupled_action
super().__init__(str(self), *args, **kwargs)
@property
def inverted(self):
rod = self.rod.copy()
rod.rod_id = self.new_id
inverted = ChangedRodNumberAction(rod, self.rod.rod_id)
inverted.parent_id = self.parent_id
inverted.frame = self.frame
return inverted
def __str__(self):
to_str = f") {self.action} #{self.rod.rod_id} ---> #{self.new_id}"
if self.rod is not None:
to_str = f"{self.rod.color}" + to_str
if self.frame is not None:
to_str = f"{self.frame}, " + to_str
if self._parent_id is not None:
to_str = f"{self._parent_id}, " + to_str
to_str = "(" + to_str
return to_str
[docs] def undo(self, rods: List[rn.RodNumberWidget]) -> List[rn.RodNumberWidget]:
"""Triggers events to revert this action.
Parameters
----------
rods : List[RodNumberWidget]
A list of :class:`RodNumberWidget` in which should be the
originally changed rod(s).
Returns
-------
List[RodNumberWidget]
"""
if rods is None:
raise Exception("Unable to undo action. No rods supplied.")
for rod in rods:
if rod.rod_id == self.new_id:
rod.rod_id = self.rod.rod_id
rod.setText(str(rod.rod_id))
elif rod.rod_id == self.rod.rod_id and type(self.coupled_action)\
is ChangedRodNumberAction:
rod.rod_id = self.new_id
rod.setText(str(rod.rod_id))
return rods
[docs] def to_save(self):
"""Generates a data representation of this action for saving.
Returns
-------
dict
Available fields: ("rod_id", "cam_id", "frame", "color",
"position")
"""
out = {
"position": self.rod.rod_points,
"cam_id": self.parent_id,
"frame": self.frame,
"color": self.rod.color,
"seen": self.rod.seen
}
if self.revert:
# If the action was reverted
out["rod_id"] = self.rod.rod_id
else:
# If the action was performed
out["rod_id"] = self.new_id
return out
[docs] def invert(self):
"""Generates an inverted version of the :class:`ChangedRodNumberAction`
(for redoing).
Returns
-------
ChangedRodNumberAction
"""
rod = self.rod.copy()
rod.rod_id = self.new_id
inverted = ChangedRodNumberAction(rod, self.rod.rod_id)
inverted.parent_id = self.parent_id
inverted.frame = self.frame
if self.coupled_action is not None:
inverted.coupled_action = self.coupled_action.inverted
if inverted.coupled_action is not None:
inverted.coupled_action.coupled_action = inverted
return inverted
[docs]class DeleteRodAction(Action):
"""Class to represent the deletion of a rod as a loggable action.
Parameters
----------
old_rod : RodNumberWidget
A copy of the rod that is deleted.
coupled_action : Union[Action, ChangedRodNumberAction], optional
The instance of an :class:`Action` that is performed at the same time
with this and must be reverted as well, if this :class:`Action` is
reverted.
(Default is None)
*args : iterable
Positional arguments for the ``QListWidgetItem`` superclass.
**kwargs : dict
Keyword arguments for the ``QListWidgetItem`` superclass.
Attributes
----------
rod : RodNumberWidget
A copy of the rod whose position was changed, prior to the change.
action : str
Description of what kind of action was performed.
(Default is "Deleted rod")
coupled_action : Union[Action, ChangeRodNumberAction, None]
The instance of an :class:`Action` that is performed at the same time
with this and must be reverted as well, if this :class:`Action` is
reverted.
"""
def __init__(self, old_rod: rn.RodNumberWidget,
coupled_action: Union[Action, ChangedRodNumberAction] = None,
*args, **kwargs):
self.rod = old_rod
self.action = "Deleted rod"
self.coupled_action = coupled_action
super().__init__(str(self), *args, **kwargs)
@property
def inverted(self):
rod = self.rod.copy()
inverted = CreateRodAction(rod)
inverted.parent_id = self.parent_id
inverted.frame = self.frame
return inverted
def __str__(self):
to_str = f") {self.action} #{self.rod.rod_id}"
if self.rod is not None:
to_str = f"{self.rod.color}" + to_str
if self.frame is not None:
to_str = f"{self.frame}, " + to_str
if self._parent_id is not None:
to_str = f"{self._parent_id}, " + to_str
to_str = "(" + to_str
return to_str
[docs] def undo(self, rods: List[rn.RodNumberWidget] = None):
"""Triggers events to revert this action.
Parameters
----------
rods : List[RodNumberWidget]
A list of :class:`RodNumberWidget` in which should be the
originally changed rod(s).
Returns
-------
List[RodNumberWidget]
"""
if self.coupled_action:
self.rod.rod_id = self.coupled_action.new_id
self.rod.setText(str(self.rod.rod_id))
self.rod.rod_state = rn.RodState.NORMAL
self.rod.setVisible(True)
return self.rod
[docs] def to_save(self):
"""Generates a data representation of this action for saving.
Returns
-------
dict
Available fields: ("rod_id", "cam_id", "frame", "color",
"position")
"""
out = {
"rod_id": self.rod.rod_id,
"cam_id": self.parent_id,
"frame": self.frame,
"color": self.rod.color
}
if self.revert:
# If the action was reverted
out["position"] = self.rod.rod_points
out["seen"] = self.rod.seen
else:
# If the action was performed
out["position"] = 4 * [np.nan]
out["seen"] = not self.rod.seen
return out
[docs] def invert(self):
"""Generates an inverted version of the :class:`DeleteRodAction` (for
redoing).
Returns
-------
ChangeRodPositionAction
"""
rod = self.rod.copy()
inverted = CreateRodAction(rod)
inverted.parent_id = self.parent_id
inverted.frame = self.frame
if self.coupled_action is not None:
inverted.coupled_action = self.coupled_action.inverted
if inverted.coupled_action is not None:
inverted.coupled_action.coupled_action = inverted
return inverted
[docs]class ChangeRodPositionAction(Action):
"""Class to represent the change of a rod's position as a loggable action.
Parameters
----------
old_rod : RodNumberWidget
A copy of the rod whose position was changed, prior to the change.
new_postion : List[int]
The newly set starting and ending points of the rod, i.e. [x1, y1,
x2, y2].
*args : iterable
Positional arguments for the ``QListWidgetItem`` superclass.
**kwargs : dict
Keyword arguments for the ``QListWidgetItem`` superclass.
Attributes
----------
rod : RodNumberWidget
A copy of the rod whose position was changed, prior to the change.
new_pos : List[int]
The newly set starting and ending points of the rod.
action : str
Default is "Rod position updated".
"""
def __init__(self, old_rod: rn.RodNumberWidget, new_position: List[int],
*args, **kwargs):
self.rod = old_rod
self.new_pos = new_position
self.action = "Rod position updated"
super().__init__(str(self), *args, **kwargs)
def __str__(self):
initial_pos = "[("
end_pos = "[("
for coord in range(4):
initial_pos += f"{self.rod.rod_points[coord]:.2f}"
end_pos += f"{self.new_pos[coord]:.2f}"
if (coord % 2) == 0:
initial_pos += ", "
end_pos += ", "
elif coord == 1:
initial_pos += "), ("
end_pos += "), ("
initial_pos += ")]"
end_pos += ")]"
to_str = f") #{self.rod.rod_id} {self.action}: {initial_pos} ---" \
f"> {end_pos}"
if self.rod is not None:
to_str = f"{self.rod.color}" + to_str
if self.frame is not None:
to_str = f"{self.frame}, " + to_str
if self._parent_id is not None:
to_str = f"{self._parent_id}, " + to_str
to_str = "(" + to_str
return to_str
[docs] def undo(self, rods: List[rn.RodNumberWidget] = None) \
-> List[rn.RodNumberWidget]:
"""Triggers events to revert this action.
Parameters
----------
rods : List[RodNumberWidget]
A list of :class:`RodNumberWidget` in which should be the
originally changed rod(s).
Returns
-------
List[RodNumberWidget]
Raises
------
Exception
"""
if rods is None:
raise Exception("Unable to undo action. No rods supplied.")
for rod in rods:
if rod.rod_id == self.rod.rod_id:
rod.rod_points = self.rod.rod_points
return rods
[docs] def to_save(self):
"""Generates a data representation of this action for saving.
Returns
-------
dict
Available fields: ("rod_id", "cam_id", "frame", "color",
"position")
"""
out = {
"rod_id": self.rod.rod_id,
"cam_id": self.parent_id,
"frame": self.frame,
"color": self.rod.color,
"seen": self.rod.seen
}
if self.revert:
# If the action was reverted
out["position"] = self.rod.rod_points
else:
# If the action was performed
out["position"] = self.new_pos
return out
[docs] def invert(self):
"""Generates an inverted version of the
:class:`ChangeRodPositionAction` (for redoing).
Returns
-------
ChangeRodPositionAction
"""
rod = self.rod.copy()
rod.rod_points = self.new_pos
inverted = ChangeRodPositionAction(rod, self.rod.rod_points)
inverted.parent_id = self.parent_id
inverted.frame = self.frame
return inverted
[docs]class CreateRodAction(Action):
"""Class to represent the creation of a new rod as a loggable action.
Parameters
----------
new_rod : RodNumberWidget
A copy of the rod which was created.
*args : iterable
Positional arguments for the ``QListWidgetItem`` superclass.
**kwargs : dict
Keyword arguments for the ``QListWidgetItem`` superclass.
Attributes
----------
rod : RodNumberWidget
A copy of the rod which was created.
action : str
Default is "Created new rod".
"""
def __init__(self, new_rod: rn.RodNumberWidget,
coupled_action: Union[Action, ChangedRodNumberAction] = None,
*args, **kwargs):
self.rod = new_rod
self.action = "Created new rod"
self.coupled_action = coupled_action
super().__init__(str(self), *args, **kwargs)
@property
def inverted(self):
inverted = DeleteRodAction(self.rod.copy())
inverted.parent_id = self.parent_id
inverted.frame = self.frame
return inverted
def __str__(self):
to_str = ") " + self.action + f" #{self.rod.rod_id}"
if self.rod is not None:
to_str = f"{self.rod.color}" + to_str
if self.frame is not None:
to_str = f"{self.frame}, " + to_str
if self._parent_id is not None:
to_str = f"{self._parent_id}, " + to_str
to_str = "(" + to_str
return to_str
[docs] def undo(self, rods: List[rn.RodNumberWidget] = None) -> \
List[rn.RodNumberWidget]:
"""Triggers events to revert this action.
Parameters
----------
rods : List[RodNumberWidget]
A list of :class:`RodNumberWidget` in which should be the created
rod.
Returns
-------
List[RodNumberWidget]
Raises
------
Exception
"""
if rods is None:
raise Exception("Unable to revert this action. No rods supplied.")
for rod in rods:
if rod.rod_id == self.rod.rod_id:
rods.remove(rod)
rod.deleteLater()
return rods
return rods
[docs] def to_save(self):
"""Generates a data representation of this action for saving.
Returns
-------
dict
Available fields: ("rod_id", "cam_id", "frame", "color",
"position")
"""
out = {
"rod_id": self.rod.rod_id,
"cam_id": self.parent_id,
"frame": self.frame,
"color": self.rod.color
}
if self.revert:
# If the action was reverted
out["position"] = [0, 0, 0, 0]
out["seen"] = False
else:
# If the action was performed
out["position"] = self.rod.rod_points
out["seen"] = True
return out
[docs] def invert(self):
"""Generates an inverted version of the :class:`CreateRodAction` (for
redoing).
Returns
-------
DeleteRodAction
"""
inverted = DeleteRodAction(self.rod.copy())
inverted.parent_id = self.parent_id
inverted.frame = self.frame
if self.coupled_action is not None:
inverted.coupled_action = self.coupled_action.inverted
if inverted.coupled_action is not None:
inverted.coupled_action.coupled_action = inverted
return inverted
[docs]class PermanentRemoveAction(Action):
"""Action to describe permanent deletion of a rod from a dataset.
Parameters
----------
rod_quantity : int
Number of rods (rows) have been deleted.
*args : iterable
Positional arguments for the ``QListWidgetItem`` superclass.
**kwargs : dict
Keyword arguments for the ``QListWidgetItem`` superclass.
"""
def __init__(self, rod_quantity: int, *args, **kwargs):
self.quantity = rod_quantity
self.action = "Permanently deleted {:d} unused rods"
super().__init__(str(self), *args, **kwargs)
def __str__(self):
to_str = self.action.format(self.quantity)
return to_str
[docs] def undo(self, rods: Optional[Iterable[rn.RodNumberWidget]]):
# TODO: implement
pass
[docs]class PruneLength(Action):
"""Class to represent the pruning of a rods length as a loggable action.
Parameters
----------
old_rod : RodNumberWidget
A copy of the rod whose position was changed, prior to the change.
new_postion : List[int]
The newly set starting and ending points of the rod, i.e. [x1, y1,
x2, y2].
*args : iterable
Positional arguments for the ``QListWidgetItem`` superclass.
**kwargs : dict
Keyword arguments for the ``QListWidgetItem`` superclass.
Attributes
----------
rod : RodNumberWidget
A copy of the rod whose position was changed, prior to the change.
new_pos : [int]
The newly set starting and ending points of the rod.
action : str
Default is "Rod length pruned: ".
"""
def __init__(self,
old_rods: Union[rn.RodNumberWidget, List[rn.RodNumberWidget]],
new_positions: List[List[int]], adjustment: float,
*args, **kwargs):
self.rods = old_rods
self.new_pos = new_positions
self.adjustment = adjustment
super().__init__(str(self), *args, **kwargs)
def __str__(self):
to_str = ") "
if len(self.rods) > 1:
to_str += f"All rod lengths adjusted by: {self.adjustment}"
else:
to_str += (f"#{self.rods[0].rod_id} length adjusted "
f"by: {self.adjustment}")
if self.rods is not None:
to_str = f"{self.rods[0].color}" + to_str
if self.frame is not None:
to_str = f"{self.frame}, " + to_str
if self._parent_id is not None:
to_str = f"{self._parent_id}, " + to_str
to_str = "(" + to_str
return to_str
[docs] def undo(self, rods: List[rn.RodNumberWidget] = None) -> \
List[rn.RodNumberWidget]:
"""Triggers events to revert this action.
Parameters
----------
rods : List[RodNumberWidget]
A list of :class:`.RodNumberWidget` in which should be the
originally changed rod(s).
Returns
-------
List[RodNumberWidget]
Raises
------
Exception
"""
if rods is None:
raise Exception("Unable to undo action. No rods supplied.")
for rod in rods:
for rod_s in self.rods:
if rod.rod_id == rod_s.rod_id:
rod.rod_points = self.rod.rod_points
break
return rods
[docs] def to_save(self):
"""Generates a data representation of this action for saving.
Returns
-------
dict
Available fields: ("rod_id", "cam_id", "frame", "color",
"position")
"""
out = {
"rod_id": [rod.rod_id for rod in self.rods],
"cam_id": [self.parent_id] * len(self.rods),
"frame": [self.frame] * len(self.rods),
"color": [rod.color for rod in self.rods],
"seen": [rod.seen for rod in self.rods]
}
if self.revert:
# If the action was reverted
out["position"] = [rod.rod_points for rod in self.rods]
else:
# If the action was performed
out["position"] = [pos for pos in self.new_pos]
return out
[docs] def invert(self):
"""Generates an inverted version of this action(for redoing).
Returns
-------
PruneLength
"""
inverted_rods = []
inverted_pos = []
for rod, pos in zip(self.rods, self.new_pos):
inv_rod = rod.copy()
inv_rod.rod_points = pos
inverted_rods.append(inv_rod)
inverted_pos = rod.rod_points
inverted = ChangeRodPositionAction(inverted_rods, inverted_pos)
inverted.parent_id = self.parent_id
inverted.frame = self.frame
return inverted
[docs]class NumberExchange(Action):
color: str = None
def __init__(self, mode: NumberChangeActions, previous_id: int,
new_id: int, color: str, frame: int, cam_id: str = None):
self.mode = mode
self.previous_id = previous_id
self.new_id = new_id
self.cam_id = cam_id
self.color = color
super().__init__(str(self))
self.frame = frame
[docs] def undo(self, rods: List[rn.RodNumberWidget]):
# TODO
pass
# return rods
def __str__(self):
to_str = f") Changed rod #{self.previous_id} ---> #{self.new_id} "\
f"of color {self.color}"
if self.mode == NumberChangeActions.ALL:
to_str += f" in frames >= {self.frame} and cameras."
elif self.mode == NumberChangeActions.ALL_ONE_CAM:
to_str += f" in frames >= {self.frame} of {self.cam_id}."
elif self.mode == NumberChangeActions.ONE_BOTH_CAMS:
to_str += f" in frame {self.frame} of all cameras."
elif self.mode == NumberChangeActions.CURRENT:
raise NotImplementedError()
if self.color is not None:
to_str = f"{self.color}" + to_str
if self.frame is not None:
to_str = f"{self.frame}, " + to_str
if self._parent_id is not None:
to_str = f"{self._parent_id}, " + to_str
to_str = "(" + to_str
return to_str
[docs] def to_save(self):
"""The operation is already 'saved' as it directly modifies the main
dataframe."""
return None
[docs] def invert(self):
"""Generates an inverted version of this action(for redoing).
Returns
-------
NumberExchange
"""
return NumberExchange(self.mode, self.new_id, self.previous_id,
self.color, self.frame, self.cam_id)
[docs]class ActionLogger(QtCore.QObject):
"""Logs actions performed on its associated GUI object.
Keeps track of actions performed on/by a GUI object that is associated
with it. It provides a list of the performed actions to a
:class:`.LoggerWidget` for display in the GUI. It is also used to trigger
reverting of these actions. Do NOT create instances of this class
directly but let an instance of the :class:`.LoggerWidget` class do that,
if the logged actions shall be displayed in the GUI.
Parameters
----------
*args :
Positional arguments for the ``QObject`` superclass.
**kwargs :
Keyword arguments for the ``QObject`` superclass.
.. admonition:: Signals
- :attr:`undo_action`
- :attr:`undone_action`
- :attr:`added_action`
- :attr:`notify_unsaved`
- :attr:`request_saving`
- :attr:`data_changed`
.. admonition:: Slots
- :meth:`undo_last`
- :meth:`actions_saved`
- :meth:`redo_last`
Attributes
----------
parent_id : str
ID of the GUI object from which actions are logged. It must be human
readable as it is used for labelling the actions displayed in the GUI.
logged_actions : List[Action]
A list of all actions performed/logged with this instance
(saved and unsaved).
unsaved_changes : List[Action]
A list of all actions performed/logged with this instance that are
savable but currently unsaved.
repeatable_changes : List[Action]
An ordered list of all currently redoable/repeatable actions that were
logged with this instance.
frame : int
Frame number that is currently relevant to the object this logger is
associated with.
Default is None.
"""
__pyqtSignals__ = ("undoAction(Action)",)
# Create custom signals
undo_action = QtCore.pyqtSignal(Action, name="undoAction")
"""pyqtSignal(Action) : Requests the reverting of the `Action` that is
given as the payload.
"""
undone_action = QtCore.pyqtSignal(Action, name="undone_action")
"""pyqtSignal(Action) : Notifies that the `Action` in the payload has been
reverted.
"""
added_action = QtCore.pyqtSignal(Action, name="added_action")
"""pyqtSignal(Action) : Notifies that this object logged the `Action` from
the payload.
"""
notify_unsaved = QtCore.pyqtSignal((bool, str), name="notify_unsaved")
"""pyqtSignal(bool, str) : Notifies, if this objects attribute
`unsaved_changes` changes from empty to being filled with one or more items
(True) or from filled to being empty (False). The `parent_id` is added to
the payload.
"""
request_saving = QtCore.pyqtSignal(bool, name="request_saving")
"""pyqtSignal(bool) : Requests the saving of any unsaved changes.
| True -> permanent saving
| False -> temporary saving
"""
data_changed = QtCore.pyqtSignal(Action, name="data_changed")
"""pyqtSignal(Action) : Notifies, if this object logged/undid/redid
something that changed the displayed data.
"""
unsaved_changes: List[Action]
parent_id: str
frame: int = None
def __init__(self, parent_id, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parent_id = parent_id
self.logged_actions = []
self.unsaved_changes = []
self.repeatable_changes = []
[docs] def add_action(self, last_action: Action) -> None:
"""Registers the actions performed by its parent and propagates them
for visual display in the GUI.
Parameters
----------
last_action : Action
.. hint::
**Emits**
- :attr:`added_action`
- :attr:`data_changed`
- :attr:`notify_unsaved`
"""
last_action.parent_id = self.parent_id
if last_action.frame is None:
last_action.frame = self.frame
self.logged_actions.append(last_action)
if self.repeatable_changes:
self.repeatable_changes = []
if type(last_action) is not FileAction:
if not self.unsaved_changes:
self.notify_unsaved.emit(True, self.parent_id)
self.unsaved_changes.append(last_action)
elif type(last_action) is FileAction:
if last_action.action == FileActions.SAVE:
self.unsaved_changes = []
self.notify_unsaved.emit(False, self.parent_id)
self.added_action.emit(last_action)
self.data_changed.emit(last_action)
[docs] @QtCore.pyqtSlot(str)
def undo_last(self, parent_id: str) -> None:
"""De-registers the last unsaved action recorded and triggers its
undo process.
Parameters
----------
parent_id : str
.. hint::
**Emits**
- :attr:`data_changed`
- :attr:`notify_unsaved`
- :attr:`undo_action`
"""
if parent_id != self.parent_id:
return
if not self.logged_actions:
# Nothing logged yet
return
undo_item = self.logged_actions.pop()
inv_undo_item = undo_item.invert()
self.repeatable_changes.append(inv_undo_item)
if undo_item not in self.unsaved_changes:
if not self.unsaved_changes:
self.notify_unsaved.emit(True, self.parent_id)
self.unsaved_changes.append(inv_undo_item)
else:
# Remove & Delete action
self.unsaved_changes.pop()
if not self.unsaved_changes:
# No more unsaved changes present
self.notify_unsaved.emit(False, self.parent_id)
undo_item.revert = True
self.undo_action.emit(undo_item)
self.data_changed.emit(undo_item)
del undo_item
[docs] def register_undone(self, undone_action: Action):
"""Lets the logger know that an action was undone without using its
undo method(s).
Parameters
----------
undone_action : Action
.. hint::
**Emits**
- :attr:`data_changed`
- :attr:`notify_unsaved`
- :attr:`undone_action`
"""
if undone_action in self.logged_actions:
undone_action.revert = True
self.data_changed.emit(undone_action)
try:
self.unsaved_changes.remove(undone_action)
except ValueError:
# The unsaved_changes were deleted (is intended when the
# changes were changed)
pass
self.logged_actions.remove(undone_action)
self.undone_action.emit(undone_action)
if not self.unsaved_changes:
# No more unsaved changes present
self.notify_unsaved.emit(False, self.parent_id)
[docs] def discard_changes(self):
"""Discards and reverts all unsaved changes made.
.. hint::
**Emits**
- :attr:`data_changed` **(potentially repeatedly)**
- :attr:`notify_unsaved`
- :attr:`undo_action` **(potentially repeatedly)**
- :attr:`undone_action` **(potentially repeatedly)**
"""
for item in self.unsaved_changes:
item.revert = True
self.data_changed.emit(item)
self.undo_action.emit(item)
self.logged_actions.remove(item)
self.undone_action.emit(item)
del item
# Save changes only in the temp location
self.unsaved_changes = []
self.notify_unsaved.emit(False, self.parent_id)
[docs] @QtCore.pyqtSlot()
def actions_saved(self):
"""All unsaved actions were saved.
.. hint::
**Emits**
- :attr:`notify_unsaved`
"""
if self.unsaved_changes:
self.unsaved_changes = []
self.notify_unsaved.emit(False, self.parent_id)
[docs] @QtCore.pyqtSlot(str)
def redo_last(self, parent_id: str) -> None:
"""De-registers the last undone action recorded and triggers its
undo-(actually redo-)process.
Parameters
----------
parent_id : str
.. hint::
**Emits**
- :attr:`added_action`
- :attr:`data_changed`
- :attr:`notify_unsaved`
- :attr:`undo_action`
- :attr:`unsaved_changes`
"""
if parent_id != self.parent_id:
return
if not self.repeatable_changes:
# Nothing repeatable yet
return
rep_item = self.repeatable_changes.pop()
inv_rep_item = rep_item.invert()
try:
if rep_item is self.unsaved_changes[-1]:
self.unsaved_changes.pop()
if not self.unsaved_changes:
# No more unsaved changes present
self.notify_unsaved.emit(False, self.parent_id)
except IndexError:
try:
if inv_rep_item.coupled_action is not None:
self.unsaved_changes.append(inv_rep_item.coupled_action)
except AttributeError:
pass
self.unsaved_changes.append(inv_rep_item)
self.notify_unsaved.emit(True, self.parent_id)
# Insert the coupled action before its parent to keep the correct
# order for undoing
try:
if inv_rep_item.coupled_action is not None:
self.logged_actions.append(inv_rep_item.coupled_action)
rep_item.coupled_action.revert = True
self.data_changed.emit(rep_item.coupled_action)
self.added_action.emit(inv_rep_item.coupled_action)
except AttributeError:
pass
self.logged_actions.append(inv_rep_item)
rep_item.revert = True
self.data_changed.emit(rep_item)
self.undo_action.emit(rep_item)
self.added_action.emit(inv_rep_item)
del rep_item