Source code for traitsui.undo

# (C) Copyright 2004-2023 Enthought, Inc., Austin, TX
# All rights reserved.
#
# This software is provided without warranty under the terms of the BSD
# license included in LICENSE.txt and may be redistributed only under
# the conditions described in the aforementioned license. The license
# is also available online at http://www.enthought.com/licenses/BSD.txt
#
# Thanks for using Enthought open source!

""" Defines the manager for Undo and Redo history for Traits user interface
    support.
"""

import collections.abc

from traits.api import (
    Bool,
    Event,
    HasPrivateTraits,
    HasStrictTraits,
    HasTraits,
    Instance,
    Int,
    List,
    Property,
    Str,
    Trait,
    cached_property,
    observe,
)
from pyface.undo.api import (
    AbstractCommand,
    CommandStack,
    ICommand,
    ICommandStack,
    IUndoManager,
    UndoManager,
)


NumericTypes = (int, float, complex)
SimpleTypes = (str, bytes) + NumericTypes


[docs]class AbstractUndoItem(AbstractCommand): """Abstract base class for undo items. This class is deprecated and will be removed in TraitsUI 8. Any custom subclasses of this class should either subclass from AbstractCommand, or provide the ICommand interface. """ #: A simple default name. name = "Edit"
[docs] def do(self): """Does nothing. All undo items log events after they have happened, so by default they do not do anything when added to the history. """ pass
[docs] def undo(self): """Undoes the change.""" raise NotImplementedError
[docs] def redo(self): """Re-does the change.""" raise NotImplementedError
[docs] def merge(self, other): """Merges two undo items if possible.""" return False
[docs]class UndoItem(AbstractUndoItem): """A change to an object trait, which can be undone.""" # ------------------------------------------------------------------------- # Trait definitions: # ------------------------------------------------------------------------- #: Object the change occurred on object = Instance(HasTraits) #: Name of the trait that changed name = Str() #: Old value of the changed trait old_value = Property() #: New value of the changed trait new_value = Property() def _get_old_value(self): return self._old_value def _set_old_value(self, value): if isinstance(value, list): value = value[:] self._old_value = value def _get_new_value(self): return self._new_value def _set_new_value(self, value): if isinstance(value, list): value = value[:] self._new_value = value
[docs] def undo(self): """Undoes the change.""" try: setattr(self.object, self.name, self.old_value) except Exception: from traitsui.api import raise_to_debug raise_to_debug()
[docs] def redo(self): """Re-does the change.""" try: setattr(self.object, self.name, self.new_value) except Exception: from traitsui.api import raise_to_debug raise_to_debug()
[docs] def merge(self, undo_item): """Merges two undo items if possible.""" # Undo items are potentially mergeable only if they are of the same # class and refer to the same object trait, so check that first: if ( isinstance(undo_item, self.__class__) and (self.object is undo_item.object) and (self.name == undo_item.name) ): v1 = self.new_value v2 = undo_item.new_value t1 = type(v1) if isinstance(v2, t1): if isinstance(v1, str): # Merge two undo items if they have new values which are # strings which only differ by one character (corresponding # to a single character insertion, deletion or replacement # operation in a text editor): n1 = len(v1) n2 = len(v2) if abs(n1 - n2) > 1: return False n = min(n1, n2) i = 0 while (i < n) and (v1[i] == v2[i]): i += 1 if v1[i + (n2 <= n1) :] == v2[i + (n2 >= n1) :]: self.new_value = v2 return True elif isinstance(v1, collections.abc.Sequence): # Merge sequence types only if a single element has changed # from the 'original' value, and the element type is a # simple Python type: v1 = self.old_value if isinstance(v1, collections.abc.Sequence): # Note: wxColour says it's a sequence type, but it # doesn't support 'len', so we handle the exception # just in case other classes have similar behavior: try: if len(v1) == len(v2): diffs = 0 for i, item in enumerate(v1): titem = type(item) item2 = v2[i] if ( (titem not in SimpleTypes) or (not isinstance(item2, titem)) or (item != item2) ): diffs += 1 if diffs >= 2: return False if diffs == 0: return False self.new_value = v2 return True except Exception: pass elif t1 in NumericTypes: # Always merge simple numeric trait changes: self.new_value = v2 return True return False
def __repr__(self): """Returns a "pretty print" form of the object.""" n = self.name cn = self.object.__class__.__name__ return "undo( %s.%s = %s )\nredo( %s.%s = %s )" % ( cn, n, self.old_value, cn, n, self.new_value, )
[docs]class ListUndoItem(AbstractUndoItem): """A change to a list, which can be undone.""" # ------------------------------------------------------------------------- # Trait definitions: # ------------------------------------------------------------------------- #: Object that the change occurred on object = Instance(HasTraits) #: Name of the trait that changed name = Str() #: Starting index index = Int() #: Items added to the list added = List() #: Items removed from the list removed = List()
[docs] def undo(self): """Undoes the change.""" try: list = getattr(self.object, self.name) list[self.index : (self.index + len(self.added))] = self.removed except Exception: from traitsui.api import raise_to_debug raise_to_debug()
[docs] def redo(self): """Re-does the change.""" try: list = getattr(self.object, self.name) list[self.index : (self.index + len(self.removed))] = self.added except Exception: from traitsui.api import raise_to_debug raise_to_debug()
[docs] def merge(self, undo_item): """Merges two undo items if possible.""" # Discard undo items that are identical to us. This is to eliminate # the same undo item being created by multiple listeners monitoring the # same list for changes: if ( isinstance(undo_item, self.__class__) and (self.object is undo_item.object) and (self.name == undo_item.name) and (self.index == undo_item.index) ): added = undo_item.added removed = undo_item.removed if (len(self.added) == len(added)) and ( len(self.removed) == len(removed) ): for i, item in enumerate(self.added): if item is not added[i]: break else: for i, item in enumerate(self.removed): if item is not removed[i]: break else: return True return False
def __repr__(self): """Returns a 'pretty print' form of the object.""" return "undo( %s.%s[%d:%d] = %s )" % ( self.object.__class__.__name__, self.name, self.index, self.index + len(self.removed), self.added, )
class _MultiUndoItem(AbstractCommand): """The _MultiUndoItem class is an internal command that unifies commands.""" name = "Edit" #: The commands that make up this undo item. commands = List(Instance(ICommand)) def push(self, command): """Append a command, merging if possible.""" if len(self.commands) > 0: merged = self.commands[-1].merge(command) if merged: return self.commands.append(command) def merge(self, other): """Try and merge a command.""" return False def redo(self): """Redo the sub-commands.""" for cmd in self.commands: cmd.redo() def undo(self): """Undo the sub-commands.""" for cmd in self.commands: cmd.undo()
[docs]class UndoHistory(HasStrictTraits): """Manages a list of undoable changes.""" # ------------------------------------------------------------------------- # Trait definitions: # ------------------------------------------------------------------------- #: The undo manager for the history. manager = Instance(IUndoManager, allow_none=False) #: The command stack for the history. stack = Instance(ICommandStack, allow_none=False) #: The current position in the list now = Property(Int, observe='stack._index') #: Fired when state changes to undoable undoable = Event(False) #: Fired when state changes to redoable redoable = Event(False) #: Can an action be undone? can_undo = Property(Bool, observe='_can_undo') #: Can an action be redone? can_redo = Property(Bool, observe='_can_redo') _can_undo = Bool() _can_redo = Bool()
[docs] def add(self, undo_item, extend=False): """Adds an UndoItem to the history.""" if extend: self.extend(undo_item) else: self.manager.active_stack = self.stack self.stack.push(undo_item)
[docs] def extend(self, undo_item): """Extends the undo history. If possible the method merges the new UndoItem with the last item in the history; otherwise, it appends the new item. """ self.manager.active_stack = self.stack # get the last command in the stack # XXX this is using CommandStack internals that it should not # XXX this should be re-architected to use the macro interface # XXX it possibly should be removed altogether entries = self.stack._stack if len(entries) > 0: command = entries[-1].command if not isinstance(command, _MultiUndoItem): command = _MultiUndoItem(commands=[command]) entries[-1].command = command else: command = _MultiUndoItem(commands=[]) command.push(undo_item)
[docs] def undo(self): """Undoes an operation.""" if self.can_undo: self.manager.undo()
[docs] def redo(self): """Redoes an operation.""" if self.can_redo: self.manager.redo()
[docs] def revert(self): """Reverts all changes made so far and clears the history.""" # undo everything self.manager.active_stack = self.stack self.stack.undo(sequence_nr=-1) self.clear()
[docs] def clear(self): """Clears the undo history.""" self.manager.active_stack = self.stack self.stack.clear()
@observe('manager.stack_updated') def _observe_stack_updated(self, event): """Update undo/redo state.""" self._can_undo = self.manager and self.manager.undo_name != "" self._can_redo = self.manager and self.manager.redo_name != "" @observe('_can_undo') def _observe_can_undo(self, event): self.undoable = event.new @observe('_can_redo') def _observe_can_redo(self, event): self.redoable = event.new @cached_property def _get_now(self): return self.stack._index + 1 def _get_can_undo(self): return self._can_undo def _get_can_redo(self): return self._can_redo def _manager_default(self): manager = UndoManager() return manager def _stack_default(self): stack = CommandStack(undo_manager=self.manager) return stack
[docs]class UndoHistoryUndoItem(AbstractUndoItem): """An undo item for the undo history.""" # ------------------------------------------------------------------------- # Trait definitions: # ------------------------------------------------------------------------- #: The undo history to undo or redo history = Instance(UndoHistory)
[docs] def undo(self): """Undoes the change.""" history = self.history for i in range(history.now - 1, -1, -1): items = history.history[i] for j in range(len(items) - 1, -1, -1): items[j].undo()
[docs] def redo(self): """Re-does the change.""" history = self.history for i in range(0, history.now): for item in history.history[i]: item.redo()