import enum
import inspect
import time
from contextlib import contextmanager
from decorator import decorator
from schrodinger.application.msv.utils import DEBUG_MODE
from schrodinger.Qt import QtGui
# NO_COMMAND can be returned from a do_command decorated method to indicate that
# no command should be added to the undo stack. See do_command for more
# information.
NO_COMMAND = object()
# Command IDs for commands that should be mergeable. Using this enum allows us
# to avoid id conflicts.
CommandType = enum.IntEnum("CommandType",
("MoveTab", "AddGaps", "SelectResidues"))
[docs]class UndoStack(QtGui.QUndoStack):
"""
Custom QUndoStack for MSV2.
:ivar in_macro: Whether currently inside a macro.
"""
[docs] def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.in_macro = False
[docs] def beginMacro(self, desc):
self.in_macro = True
super().beginMacro(desc)
[docs] def endMacro(self):
super().endMacro()
# TODO in_macro will be incorrect if macros are nested
self.in_macro = False
[docs]class Command(QtGui.QUndoCommand):
[docs] def __init__(self, redo, undo, description, command_id=-1, desc_merge=None):
"""
:param redo: A method that redoes some action
:type redo: callable
:param undo: A function that undoes the actions of `redo`
:type undo: callable
:param description: A short summary of what `redo` accomplishes
:type description: str
:param command_id: An id to assign the command.
:type command_id: int
:param desc_merge: A function that takes two command descriptions
and merges them.
:type desc_merge: callable
"""
super().__init__(description)
self._redo = [redo]
self._undo = [undo]
self.command_id = command_id
self.desc_merge = desc_merge
self.redo_return_value = None
[docs] def redo(self):
for func in self._redo:
self.redo_return_value = func()
[docs] def undo(self):
for func in self._undo:
func()
[docs] def id(self):
return self.command_id
[docs] def mergeWith(self, other_command):
# See Qt documentation for method documentation
self._redo.extend(other_command._redo)
self._undo = other_command._undo + self._undo
if self.desc_merge is not None:
new_desc = self.desc_merge(self.text(), other_command.text())
self.setText(new_desc)
return True
[docs]class TimeBasedCommand(Command):
"""
A class for undo commands that can be can be merged together if they have
the same command_id and occur within a certain time interval.
"""
[docs] def __init__(self,
redo,
undo,
description,
command_id=-1,
desc_merge=None,
merge_time_interval=1):
"""
:param redo: A method that redoes some action
:type redo: callable
:param undo: A function that undoes the actions of `redo`
:type undo: callable
:param description: A short summary of what `redo` accomplishes
:type description: str
:param command_id: An id to assign the command.
:type command_id: int
:param desc_merge: A function that takes two command descriptions
and merges them.
:type desc_merge: callable
:param merge_time_interval: The max time in seconds that two commands
with the same ID can occur in while still merging. Defaults to 1.
:type merge_time: float
"""
super().__init__(redo, undo, description, command_id, desc_merge)
self.merge_time_interval = merge_time_interval
self.timestamp = time.time()
[docs] def mergeWith(self, other_command):
# See Qt documentation for method documentation
if other_command.timestamp - self.timestamp > self.merge_time_interval:
return False
self.timestamp = other_command.timestamp
return super().mergeWith(other_command)
[docs]def do_command(meth=None,
*,
command_id=-1,
command_class=Command,
**dec_kwargs):
"""
Decorates a method returning parameters for a `Command`, constructs the
`Command`, and pushes it onto the command stack if there is one or calls
`redo` if there isn't. Also validates the `Command` parameters if DEBUG
mode is enabled.
The decorated method must return a tuple of at least three values:
- The redo method (`callable`)
- The undo method (`callable`)
- A description of the command (`str`)
The returned tuple may also include additional values, which will be passed
to the command class's `__init__` method (see `command_class` param below).
Alternatively, the decorated method may return `NO_COMMAND`, in which case
no command will be added to the undo stack. In this case, no changes should
be made to the alignment. This is useful if the arguments passed to the
method do not require any changes to the alignment.
When the decorated method is called, it will return the value returned by
the redo method.
The decorator itself takes two optional keyword-only arguments.
:param command_id: The command ID. If given, then any sequential commands
with the same command ID will be merged as they are pushed onto the undo
stack. If no command ID is given, then no command merging will occur.
(Note that a non-default `command_class` may alter this command merging
behavior.)
:type command_id: int
:param command_class: The command class to use. Must be `Command` or a
`Command` subclass.
:type command_class: Type[Command]
Any additional keyword arguments passed to the decorator will be passed to
the command class's `__init__` method.
"""
@decorator
def do_command_dec(meth, self, *args, **kwargs):
retval = meth(self, *args, **kwargs)
if retval is NO_COMMAND:
return None
redo, undo, desc, *command_args = retval
if DEBUG_MODE:
if (not callable(undo) or not callable(redo) or
not isinstance(desc, str)):
msg = ('%s must return a tuple with callable redo and undo '
'objects and a description string.' % meth.__name__)
raise ValueError(msg)
command = command_class(redo, undo, desc, command_id, *command_args,
**dec_kwargs)
undo_stack = getattr(self, 'undo_stack', None)
if undo_stack is None:
command.redo()
else:
undo_stack.push(command)
return command.redo_return_value
if meth is None:
return do_command_dec
else:
return do_command_dec(meth)
[docs]@contextmanager
def compress_command(stack, description):
"""
Enables compression of commands on undo stack, so they can be revoked
using a single undo call.
:type stack: `QUndoStack`
:param stack: Undo stack to compress commands on.
:type description: str
:param description: Description of the compressed command.
"""
try:
stack.beginMacro(description)
yield
finally:
stack.endMacro()
[docs]@decorator
def from_command_only(meth, *args, **kwargs):
"""
When running in DEBUG mode, enforces the requirement that the decorated
method only be called from a command object
"""
if DEBUG_MODE:
info = inspect.stack()[2][3]
if info not in ['redo', 'undo']:
msg = ("%s was called by %s but can only be called from a command" %
(meth.__name__, info))
raise RuntimeError(msg)
return meth(*args, **kwargs)
[docs]def revert_command(stack):
"""
Undo and discard the last command on the undo stack.
:param stack: The undo stack
:type stack: QtGui.QUndoStack
"""
cur_index = stack.index()
if cur_index < 1:
return
cmd = stack.command(cur_index - 1)
cmd.setObsolete(True)
cmd.undo() # TODO remove after QTBUG-67633 is fixed
stack.undo()