Source code for schrodinger.application.msv.command

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()