diff --git a/docs/source/submodules.rst b/docs/source/submodules.rst index b9e698805..4418f9aa6 100644 --- a/docs/source/submodules.rst +++ b/docs/source/submodules.rst @@ -8,3 +8,4 @@ Submodules Data View Fields Timers + Undo diff --git a/docs/source/undo.rst b/docs/source/undo.rst new file mode 100644 index 000000000..4de550ca4 --- /dev/null +++ b/docs/source/undo.rst @@ -0,0 +1,72 @@ +Undo Framework +============== + +The Undo Framework is a component of the Enthought Tool Suite that provides +developers with an API that implements the standard pattern for do/undo/redo +commands. + +The framework is completely configurable. Alternate implementations of all +major components can be provided if necessary. + + +Framework Concepts +------------------ + +The following are the concepts supported by the framework. + +- Command + + A command is an application defined operation that can be done (i.e. + executed), undone (i.e. reverted) and redone (i.e. repeated). + + A command operates on some data and maintains sufficient state to allow it to + revert or repeat a change to the data. + + Commands may be merged so that potentially long sequences of similar + commands (e.g. to add a character to some text) can be collapsed into a + single command (e.g. to add a word to some text). + +- Macro + + A macro is a sequence of commands that is treated as a single command when + being undone or redone. + +- Command Stack + + A command is done by pushing it onto a command stack. The last command can + be undone and redone by calling appropriate command stack methods. It is + also possible to move the stack's position to any point and the command stack + will ensure that commands are undone or redone as required. + + A command stack maintains a *clean* state which is updated as commands are + done and undone. It may be explicitly set, for example when the data being + manipulated by the commands is saved to disk. + + PyFace actions are provided as wrappers around command stack methods + to implement common menu items. + +- Undo Manager + + An undo manager is responsible for one or more command stacks and maintains + a reference to the currently active stack. It provides convenience undo and + redo methods that operate on the currently active stack. + + An undo manager ensures that each command execution is allocated a unique + sequence number, irrespective of which command stack it is pushed to. Using + this it is possible to synchronise multiple command stacks and restore them + to a particular point in time. + + An undo manager will generate an event whenever the clean state of the active + stack changes. This can be used to maintain some sort of GUI status + indicator to tell the user that their data has been modified since it was + last saved. + +Typically an application will have one undo manager and one undo stack for +each data type that can be edited. However this is not a requirement: how the +command stack's in particular are organised and linked (with the user +manager's sequence number) can need careful thought so as not to confuse the +user - particularly in a plugin based application that may have many editors. + +To support this typical usage the PyFace ``Workbench`` class has an +``undo_manager`` trait and the PyFace ``Editor`` class has a ``command_stack`` +trait. Both are lazy loaded so can be completely ignored if they are not used. diff --git a/examples/undo/commands.py b/examples/undo/commands.py new file mode 100644 index 000000000..3a341cd48 --- /dev/null +++ b/examples/undo/commands.py @@ -0,0 +1,213 @@ +# (C) Copyright 2007-2020 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! + +# ----------------------------------------------------------------------------- +# Copyright (c) 2007, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ----------------------------------------------------------------------------- + + +# Enthought library imports. +from traits.api import Instance, Int, Str +from pyface.undo.api import AbstractCommand + +# Local imports. +from model import Label + + +class LabelIncrementSizeCommand(AbstractCommand): + """ The LabelIncrementSizeCommand class is a command that increases the + size of a label's text. This command will merge multiple increments + togther. + """ + + #### 'ICommand' interface ################################################# + + # The data being operated on. + data = Instance(Label) + + # The name of the command. + name = Str("&Increment size") + + #### Private interface #################################################### + + _incremented_by = Int() + + ########################################################################### + # 'ICommand' interface. + ########################################################################### + + def do(self): + self.data.increment_size(1) + self._incremented_by = 1 + + def merge(self, other): + # We can merge if the other command is the same type (or a sub-type). + if isinstance(other, type(self)): + self._incremented_by += 1 + merged = True + else: + merged = False + + return merged + + def redo(self): + self.data.increment_size(self._incremented_by) + + def undo(self): + self.data.decrement_size(self._incremented_by) + + +class LabelDecrementSizeCommand(AbstractCommand): + """ The LabelDecrementSizeCommand class is a command that decreases the + size of a label's text. This command will merge multiple decrements + togther. + """ + + #### 'ICommand' interface ################################################# + + # The data being operated on. + data = Instance(Label) + + # The name of the command. + name = Str("&Decrement size") + + #### Private interface #################################################### + + _decremented_by = Int() + + ########################################################################### + # 'ICommand' interface. + ########################################################################### + + def do(self): + self.data.decrement_size(1) + self._decremented_by = 1 + + def merge(self, other): + # We can merge if the other command is the same type (or a sub-type). + if isinstance(other, type(self)): + self._decremented_by += 1 + merged = True + else: + merged = False + + return merged + + def redo(self): + self.data.decrement_size(self._decremented_by) + + def undo(self): + self.data.increment_size(self._decremented_by) + + +class LabelNormalFontCommand(AbstractCommand): + """ The LabelNormalFontCommand class is a command that sets a normal font + for a label's text. + """ + + #### 'ICommand' interface ################################################# + + # The data being operated on. + data = Instance(Label) + + # The name of the command. + name = Str("&Normal font") + + ########################################################################### + # 'ICommand' interface. + ########################################################################### + + def do(self): + # Save the old value. + self._saved = self.data.style + + # Calling redo() is a convenient way to update the model now that the + # old value is saved. + self.redo() + + def redo(self): + self.data.style = 'normal' + + def undo(self): + self.data.style = self._saved + + +class LabelBoldFontCommand(AbstractCommand): + """ The LabelNormalFontCommand class is a command that sets a bold font for + a label's text. + """ + + #### 'ICommand' interface ############################################# + + # The data being operated on. + data = Instance(Label) + + # The name of the command. + name = Str("&Bold font") + + ########################################################################### + # 'ICommand' interface. + ########################################################################### + + def do(self): + # Save the old value. + self._saved = self.data.style + + # Calling redo() is a convenient way to update the model now that the + # old value is saved. + self.redo() + + def redo(self): + self.data.style = 'bold' + + def undo(self): + self.data.style = self._saved + + +class LabelItalicFontCommand(AbstractCommand): + """ The LabelNormalFontCommand class is a command that sets an italic font + for a label's text. + """ + + #### 'ICommand' interface ################################################# + + # The data being operated on. + data = Instance(Label) + + # The name of the command. + name = Str("&Italic font") + + ########################################################################### + # 'ICommand' interface. + ########################################################################### + + def do(self): + # Save the old value. + self._saved = self.data.style + + # Calling redo() is a convenient way to update the model now that the + # old value is saved. + self.redo() + + def redo(self): + self.data.style = 'italic' + + def undo(self): + self.data.style = self._saved diff --git a/examples/undo/example.py b/examples/undo/example.py new file mode 100644 index 000000000..a050dc037 --- /dev/null +++ b/examples/undo/example.py @@ -0,0 +1,96 @@ +# (C) Copyright 2008-2020 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! + +# ----------------------------------------------------------------------------- +# Copyright (c) 2008, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ----------------------------------------------------------------------------- + + +# Standard library imports. +import logging + +# Enthought library imports. +from pyface.api import GUI, YES +from pyface.workbench.api import Workbench +from pyface.undo.api import UndoManager + +# Local imports. +from example_undo_window import ExampleUndoWindow +from model import Label + + +# Log to stderr. +logging.getLogger().addHandler(logging.StreamHandler()) +logging.getLogger().setLevel(logging.DEBUG) + + +class ExampleUndo(Workbench): + """ The ExampleUndo class is a workbench that creates ExampleUndoWindow + windows. + """ + + #### 'Workbench' interface ################################################ + + # The factory (in this case simply a class) that is used to create + # workbench windows. + window_factory = ExampleUndoWindow + + ########################################################################### + # Private interface. + ########################################################################### + + def _exiting_changed(self, event): + """ Called when the workbench is exiting. """ + + if self.active_window.confirm('Ok to exit?') != YES: + event.veto = True + + return + + +def main(argv): + """ A simple example of using the the undo framework in a workbench. """ + + # Create the GUI. + gui = GUI() + + # Create the workbench. + workbench = ExampleUndo(state_location=gui.state_location) + + window = workbench.create_window(position=(300, 300), size=(400, 300)) + window.open() + + # Create some objects to edit. + label = Label(text="Label") + label2 = Label(text="Label2") + + # Edit the objects. + window.edit(label) + window.edit(label2) + + # Start the GUI event loop. + gui.start_event_loop() + + return + + +if __name__ == '__main__': + import sys + main(sys.argv) diff --git a/examples/undo/example_editor_manager.py b/examples/undo/example_editor_manager.py new file mode 100644 index 000000000..8af9e0718 --- /dev/null +++ b/examples/undo/example_editor_manager.py @@ -0,0 +1,166 @@ +# (C) Copyright 2007-2020 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! + +# ----------------------------------------------------------------------------- +# Copyright (c) 2007, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ----------------------------------------------------------------------------- + + +# Enthought library imports. +from traits.etsconfig.api import ETSConfig +from pyface.workbench.api import Editor, EditorManager + + +class _wxLabelEditor(Editor): + """ _wxLabelEditor is the wx implementation of a label editor. """ + + def create_control(self, parent): + import wx + + w = wx.TextCtrl(parent, style=wx.TE_RICH2) + style = w.GetDefaultStyle() + style.SetAlignment(wx.TEXT_ALIGNMENT_CENTER) + w.SetDefaultStyle(style) + + self._set_text(w) + self._set_size_and_style(w) + + self.obj.on_trait_change(self._update_text, 'text') + self.obj.on_trait_change(self._update_size, 'size') + self.obj.on_trait_change(self._update_style, 'style') + + return w + + def _name_default(self): + return self.obj.text + + def _update_text(self): + self._set_text(self.control) + + def _set_text(self, w): + w.SetValue("") + w.WriteText( + "%s(%d points, %s)" % ( + self.obj.text, + self.obj.size, + self.obj.style + ) + ) + + def _update_size(self): + self._set_size_and_style(self.control) + + def _update_style(self): + self._set_size_and_style(self.control) + + def _set_size_and_style(self, w): + import wx + if self.obj.style == 'normal': + style, weight = wx.NORMAL, wx.NORMAL + elif self.obj.style == 'italic': + style, weight = wx.ITALIC, wx.NORMAL + elif self.obj.style == 'bold': + style, weight = wx.NORMAL, wx.BOLD + else: + raise NotImplementedError( + "style '%s' not supported" % self.obj.style + ) + + f = wx.Font(self.obj.size, wx.ROMAN, style, weight, False) + style = wx.TextAttr("BLACK", wx.NullColour, f) + w.SetDefaultStyle(style) + self._set_text(w) + + +class _PyQt4LabelEditor(Editor): + """ _PyQt4LabelEditor is the PyQt implementation of a label editor. """ + + def create_control(self, parent): + + from pyface.qt import QtCore, QtGui + + w = QtGui.QLabel(parent) + w.setAlignment(QtCore.Qt.AlignCenter) + + self._set_text(w) + self._set_size(w) + self._set_style(w) + + self.obj.on_trait_change(self._update_text, 'text') + self.obj.on_trait_change(self._update_size, 'size') + self.obj.on_trait_change(self._update_style, 'style') + + return w + + def _name_default(self): + return self.obj.text + + def _update_text(self): + self._set_text(self.control) + + def _set_text(self, w): + w.setText( + "%s\n(%d points, %s)" % ( + self.obj.text, + self.obj.size, + self.obj.style + ) + ) + + def _update_size(self): + self._set_size(self.control) + + def _set_size(self, w): + f = w.font() + f.setPointSize(self.obj.size) + w.setFont(f) + + self._set_text(w) + + def _update_style(self): + self._set_style(self.control) + + def _set_style(self, w): + f = w.font() + f.setBold(self.obj.style == 'bold') + f.setItalic(self.obj.style == 'italic') + w.setFont(f) + + self._set_text(w) + + +class ExampleEditorManager(EditorManager): + """ The ExampleEditorManager class creates the example editors. """ + + def create_editor(self, window, obj, kind): + + # Create the toolkit specific editor. + tk_name = ETSConfig.toolkit + + if tk_name == 'wx': + ed = _wxLabelEditor(window=window, obj=obj) + elif tk_name == 'qt4' or tk_name == 'qt': + ed = _PyQt4LabelEditor(window=window, obj=obj) + else: + raise NotImplementedError( + "unsupported toolkit: %s" % tk_name + ) + + return ed diff --git a/examples/undo/example_undo_window.py b/examples/undo/example_undo_window.py new file mode 100644 index 000000000..1abb315a7 --- /dev/null +++ b/examples/undo/example_undo_window.py @@ -0,0 +1,150 @@ +# (C) Copyright 2008-2020 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! + +# ----------------------------------------------------------------------------- +# Copyright (c) 2008, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ----------------------------------------------------------------------------- + + +# Enthought library imports. +from pyface.action.api import Action, Group, MenuManager +from pyface.workbench.api import WorkbenchWindow +from pyface.workbench.action.api import MenuBarManager, ToolBarManager +from traits.api import Instance +from pyface.undo.action.api import CommandAction, RedoAction, UndoAction + +# Local imports. +from example_editor_manager import ExampleEditorManager +from commands import LabelIncrementSizeCommand, LabelDecrementSizeCommand, \ + LabelNormalFontCommand, LabelBoldFontCommand, LabelItalicFontCommand + + +class ExampleUndoWindow(WorkbenchWindow): + """ The ExampleUndoWindow class is a workbench window that contains example + editors that demonstrate the use of the undo framework. + """ + + #### Private interface #################################################### + + # The action that exits the application. + _exit_action = Instance(Action) + + # The File menu. + _file_menu = Instance(MenuManager) + + # The Label menu. + _label_menu = Instance(MenuManager) + + # The Undo menu. + _undo_menu = Instance(MenuManager) + + ########################################################################### + # Private interface. + ########################################################################### + + #### Trait initialisers ################################################### + + def __file_menu_default(self): + """ Trait initialiser. """ + + return MenuManager(self._exit_action, name="&File") + + def __undo_menu_default(self): + """ Trait initialiser. """ + undo_manager = self.workbench.undo_manager + + undo_action = UndoAction(undo_manager=undo_manager) + redo_action = RedoAction(undo_manager=undo_manager) + + return MenuManager(undo_action, redo_action, name="&Undo") + + def __label_menu_default(self): + """ Trait initialiser. """ + + size_group = Group(CommandAction(command=LabelIncrementSizeCommand), + CommandAction(command=LabelDecrementSizeCommand)) + + normal = CommandAction(id='normal', command=LabelNormalFontCommand, + style='radio', checked=True) + bold = CommandAction(id='bold', command=LabelBoldFontCommand, + style='radio') + italic = CommandAction(id='italic', command=LabelItalicFontCommand, + style='radio') + + style_group = Group(normal, bold, italic, id='style') + + return MenuManager(size_group, style_group, name="&Label") + + def __exit_action_default(self): + """ Trait initialiser. """ + + return Action(name="E&xit", on_perform=self.workbench.exit) + + def _editor_manager_default(self): + """ Trait initialiser. """ + + return ExampleEditorManager() + + def _menu_bar_manager_default(self): + """ Trait initialiser. """ + + return MenuBarManager( + self._file_menu, + self._label_menu, + self._undo_menu, + window=self + ) + + def _tool_bar_manager_default(self): + """ Trait initialiser. """ + + return ToolBarManager(self._exit_action, show_tool_names=False) + + def _active_editor_changed(self, old, new): + """ Trait handler. """ + + # Tell the undo manager about the new command stack. + if old is not None: + old.command_stack.undo_manager.active_stack = None + + if new is not None: + new.command_stack.undo_manager.active_stack = new.command_stack + + # Walk the label editor menu. + for grp in self._label_menu.groups: + for itm in grp.items: + action = itm.action + + # Enable the action and set the command stack and data if there + # is a new editor. + if new is not None: + action.enabled = True + action.command_stack = new.command_stack + action.data = new.obj + + # FIXME v3: We should just be able to check the menu option + # corresponding to the style trait - but that doesn't seem + # to uncheck the other options in the group. Even then the + # first switch to another editor doesn't update the menus + # (though subsequent ones do). + if grp.id == 'style': + action.checked = (action.data.style == action.id) + else: + action.enabled = False diff --git a/examples/undo/model.py b/examples/undo/model.py new file mode 100644 index 000000000..65b2d7f12 --- /dev/null +++ b/examples/undo/model.py @@ -0,0 +1,56 @@ +# (C) Copyright 2008-2020 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! + +# ----------------------------------------------------------------------------- +# Copyright (c) 2008, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ----------------------------------------------------------------------------- + + +# Enthought library imports. +from traits.api import Enum, HasTraits, Int, Str + + +class Label(HasTraits): + """The Label class implements the data model for a label.""" + + #### 'Label' interface #################################################### + + # The name. + name = Str() + + # The size in points. + size = Int(18) + + # The style. + style = Enum('normal', 'bold', 'italic') + + ########################################################################### + # 'Label' interface. + ########################################################################### + + def increment_size(self, by): + """Increment the current font size.""" + + self.size += by + + def decrement_size(self, by): + """Decrement the current font size.""" + + self.size -= by diff --git a/pyface/undo/__init__.py b/pyface/undo/__init__.py new file mode 100644 index 000000000..23776f87d --- /dev/null +++ b/pyface/undo/__init__.py @@ -0,0 +1,11 @@ +# (C) Copyright 2005-2020 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! +""" Supports undoing and scripting application commands. +""" diff --git a/pyface/undo/abstract_command.py b/pyface/undo/abstract_command.py new file mode 100644 index 000000000..8e18da045 --- /dev/null +++ b/pyface/undo/abstract_command.py @@ -0,0 +1,86 @@ +# (C) Copyright 2007-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2007, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +# Enthought library imports. +from traits.api import Any, HasTraits, Str, provides + +# Local imports. +from .i_command import ICommand + + +@provides(ICommand) +class AbstractCommand(HasTraits): + """The AbstractCommand class is an abstract base class that implements the + ICommand interface. + """ + + #### 'ICommand' interface ################################################# + + #: This is the data on which the command operates. + data = Any() + + #: This is the name of the command as it will appear in any GUI element. It + #: may include '&' which will be automatically removed whenever it is + #: inappropriate. + name = Str() + + ########################################################################### + # 'ICommand' interface. + ########################################################################### + + def do(self): + """This is called by the command stack to do the command and to return + any value. The command must save any state necessary for the 'redo()' + and 'undo()' methods to work. The class's __init__() must also ensure + that deep copies of any arguments are made if appropriate. It is + guaranteed that this will only ever be called once and that it will be + called before any call to 'redo()' or 'undo()'. + """ + + raise NotImplementedError + + def merge(self, other): + """This is called by the command stack to try and merge another + command with this one. True is returned if the commands were merged. + 'other' is the command that is about to be executed. If the commands + are merged then 'other' will discarded and not placed on the command + stack. A subsequent undo or redo of this modified command must have + the same effect as the two original commands. + """ + + # By default merges never happen. + return False + + def redo(self): + """This is called by the command stack to redo the command. Any + returned value will replace the value that the command stack references + from the original call to 'do()' or previous call to 'redo()'. + """ + + raise NotImplementedError + + def undo(self): + """ This is called by the command stack to undo the command. """ + + raise NotImplementedError diff --git a/pyface/undo/action/__init__.py b/pyface/undo/action/__init__.py new file mode 100644 index 000000000..6b94a8b23 --- /dev/null +++ b/pyface/undo/action/__init__.py @@ -0,0 +1,9 @@ +# (C) Copyright 2005-2020 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! diff --git a/pyface/undo/action/abstract_command_stack_action.py b/pyface/undo/action/abstract_command_stack_action.py new file mode 100644 index 000000000..cefbd57fe --- /dev/null +++ b/pyface/undo/action/abstract_command_stack_action.py @@ -0,0 +1,93 @@ +# (C) Copyright 2008-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2008, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +# Enthought library imports. +from pyface.action.api import Action +from traits.api import Instance + +# Local library imports +from ..i_undo_manager import IUndoManager + + +class AbstractCommandStackAction(Action): + """The abstract base class for all actions that operate on a command + stack. + """ + + #### 'AbstractCommandStackAction' interface ############################### + + #: The undo manager. + undo_manager = Instance(IUndoManager) + + ########################################################################### + # 'object' interface. + ########################################################################### + + def __init__(self, **traits): + """ Initialise the instance. """ + + super(AbstractCommandStackAction, self).__init__(**traits) + + self.undo_manager.on_trait_event( + self._on_stack_updated, "stack_updated" + ) + + # Update the action to initialise it. + self._update_action() + + ########################################################################### + # 'Action' interface. + ########################################################################### + + def destroy(self): + """Called when the action is no longer required. + + By default this method does nothing, but this would be a great place to + unhook trait listeners etc. + + """ + + self.undo_manager.on_trait_event( + self._on_stack_updated, "stack_updated", remove=True + ) + + ########################################################################### + # Protected interface. + ########################################################################### + + def _update_action(self): + """ Update the state of the action. """ + + raise NotImplementedError + + ########################################################################### + # Private interface. + ########################################################################### + + def _on_stack_updated(self, stack): + """ Handle changes to the state of a command stack. """ + + # Ignore unless it is the active stack. + if stack is self.undo_manager.active_stack: + self._update_action() diff --git a/pyface/undo/action/api.py b/pyface/undo/action/api.py new file mode 100644 index 000000000..80e76269b --- /dev/null +++ b/pyface/undo/action/api.py @@ -0,0 +1,27 @@ +# (C) Copyright 2007-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2007, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +from .command_action import CommandAction +from .redo_action import RedoAction +from .undo_action import UndoAction diff --git a/pyface/undo/action/command_action.py b/pyface/undo/action/command_action.py new file mode 100644 index 000000000..17eb6d19e --- /dev/null +++ b/pyface/undo/action/command_action.py @@ -0,0 +1,68 @@ +# (C) Copyright 2007-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2007, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +# Enthought library imports. +from pyface.action.api import Action +from traits.api import Any, Callable, Instance +from ..i_command_stack import ICommandStack + + +class CommandAction(Action): + """The CommandAction class is an Action class that wraps undo/redo + commands. It is only useful for commands that do not take any arguments or + return any result. + """ + + #### 'CommandAction' interface ############################################ + + #: The command to create when the action is performed. + command = Callable() + + #: The command stack onto which the command will be pushed when the action + #: is performed. + command_stack = Instance(ICommandStack) + + #: This is the data on which the command operates. + data = Any() + + ########################################################################### + # 'Action' interface. + ########################################################################### + + def perform(self, event): + """This is reimplemented to push a new command instance onto the + command stack. + """ + + self.command_stack.push(self.command(data=self.data)) + + def _name_default(self): + """ This gets the action name from the command. """ + + if self.command: + name = self.command().name + else: + name = "" + + return name diff --git a/pyface/undo/action/redo_action.py b/pyface/undo/action/redo_action.py new file mode 100644 index 000000000..3d5018141 --- /dev/null +++ b/pyface/undo/action/redo_action.py @@ -0,0 +1,59 @@ +# (C) Copyright 2007-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2007, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +# Local imports. +from .abstract_command_stack_action import AbstractCommandStackAction + + +class RedoAction(AbstractCommandStackAction): + """An action that redos the last command undone of the active command + stack. + """ + + ########################################################################### + # 'Action' interface. + ########################################################################### + + def perform(self, event): + """ Perform the action. """ + + self.undo_manager.redo() + + ########################################################################### + # 'AbstractUndoAction' interface. + ########################################################################### + + def _update_action(self): + """ Update the state of the action. """ + + name = self.undo_manager.redo_name + + if name: + name = "&Redo " + name + self.enabled = True + else: + name = "&Redo" + self.enabled = False + + self.name = name diff --git a/pyface/undo/action/tests/__init__.py b/pyface/undo/action/tests/__init__.py new file mode 100644 index 000000000..6b94a8b23 --- /dev/null +++ b/pyface/undo/action/tests/__init__.py @@ -0,0 +1,9 @@ +# (C) Copyright 2005-2020 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! diff --git a/pyface/undo/action/tests/test_actions.py b/pyface/undo/action/tests/test_actions.py new file mode 100644 index 000000000..687794b1a --- /dev/null +++ b/pyface/undo/action/tests/test_actions.py @@ -0,0 +1,53 @@ +# (C) Copyright 2005-2020 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! + +import unittest + +from traits.testing.api import UnittestTools + +from pyface.undo.api import CommandStack, UndoManager +from pyface.undo.tests.testing_commands import SimpleCommand, UnnamedCommand + +from pyface.undo.action.api import RedoAction, UndoAction + + +class TestRedoAction(unittest.TestCase): + + def setUp(self): + self.stack = CommandStack() + self.undo_manager = UndoManager() + self.stack.undo_manager = self.undo_manager + self.undo_manager.active_stack = self.stack + + self.command = SimpleCommand() + + def test_update(self): + redo_action = RedoAction(command=self.command, undo_manager=self.undo_manager) + self.stack.push(self.command) + self.undo_manager.undo() + self.assertTrue(redo_action.enabled) + self.assertEqual(redo_action.name, "&Redo Increment by 1") + + +class TestUndoAction(unittest.TestCase): + + def setUp(self): + self.stack = CommandStack() + self.undo_manager = UndoManager() + self.stack.undo_manager = self.undo_manager + self.undo_manager.active_stack = self.stack + + self.command = SimpleCommand() + + def test_update(self): + undo_action = UndoAction(command=self.command, undo_manager=self.undo_manager) + self.stack.push(self.command) + self.assertTrue(undo_action.enabled) + self.assertEqual(undo_action.name, "&Undo Increment by 1") diff --git a/pyface/undo/action/undo_action.py b/pyface/undo/action/undo_action.py new file mode 100644 index 000000000..d76670703 --- /dev/null +++ b/pyface/undo/action/undo_action.py @@ -0,0 +1,57 @@ +# (C) Copyright 2007-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2007, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +# Local imports. +from .abstract_command_stack_action import AbstractCommandStackAction + + +class UndoAction(AbstractCommandStackAction): + """ An action that undos the last command of the active command stack. """ + + ########################################################################### + # 'Action' interface. + ########################################################################### + + def perform(self, event): + """ Perform the action. """ + + self.undo_manager.undo() + + ########################################################################### + # 'AbstractUndoAction' interface. + ########################################################################### + + def _update_action(self): + """ Update the state of the action. """ + + name = self.undo_manager.undo_name + + if name: + name = "&Undo " + name + self.enabled = True + else: + name = "&Undo" + self.enabled = False + + self.name = name diff --git a/pyface/undo/api.py b/pyface/undo/api.py new file mode 100644 index 000000000..ce6fb87f4 --- /dev/null +++ b/pyface/undo/api.py @@ -0,0 +1,20 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) 2008, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +from .abstract_command import AbstractCommand +from .command_stack import CommandStack +from .i_command import ICommand +from .i_command_stack import ICommandStack +from .i_undo_manager import IUndoManager +from .undo_manager import UndoManager diff --git a/pyface/undo/command_stack.py b/pyface/undo/command_stack.py new file mode 100644 index 000000000..78abd2612 --- /dev/null +++ b/pyface/undo/command_stack.py @@ -0,0 +1,329 @@ +# (C) Copyright 2008-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2008, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +# Enthought library imports. +from traits.api import ( + Bool, + HasTraits, + Instance, + Int, + List, + Property, + Str, + provides, +) + +# Local imports. +from .abstract_command import AbstractCommand +from .i_command import ICommand +from .i_command_stack import ICommandStack +from .i_undo_manager import IUndoManager + + +class _StackEntry(HasTraits): + """ The _StackEntry class is a single entry on a command stack. """ + + #### '_StackEntry' interface ############################################## + + #: Set if the entry corresponds to a clean point on the stack. + clean = Bool(False) + + #: The command instance. + command = Instance(ICommand) + + #: The sequence number of the entry. + sequence_nr = Int() + + +class _MacroCommand(AbstractCommand): + """ The _MacroCommand class is an internal command that handles macros. """ + + #### '_MacroCommand' interface ############################################ + + #: The commands that make up this macro. + macro_commands = List(Instance(ICommand)) + + ########################################################################### + # 'ICommand' interface. + ########################################################################### + + def do(self): + """ Invoke the command. """ + + # This is a dummy. + return None + + def merge(self, other): + """ Try and merge a command. """ + + if len(self.macro_commands) == 0: + merged = False + else: + merged = self.macro_commands[-1].merge(other) + + return merged + + def redo(self): + """ Redo the sub-commands. """ + + for cmd in self.macro_commands: + cmd.redo() + + # Macros cannot return values. + return None + + def undo(self): + """ Undo the sub-commands. """ + + for cmd in self.macro_commands: + cmd.undo() + + +@provides(ICommandStack) +class CommandStack(HasTraits): + """The CommandStack class is the default implementation of the + ICommandStack interface. + """ + + #### 'ICommandStack' interface ############################################ + + #: This is the clean state of the stack. Its value changes as commands are + #: undone and redone. It can also be explicity set to mark the current + #: stack position as being clean (when the data is saved to disk for + #: example). + clean = Property(Bool) + + #: This is the name of the command that can be redone. It will be empty if + #: there is no command that can be redone. It is maintained by the undo + #: stack. + redo_name = Property(Str) + + #: This is the undo manager that manages this stack. + undo_manager = Instance(IUndoManager) + + #: This is the name of the command that can be undone. It will be empty if + #: there is no command that can be undone. It is maintained by the undo + #: stack. + undo_name = Property(Str) + + #### Private interface #################################################### + + # The current index into the stack (ie. the last command that was done). + _index = Int(-1) + + # The current macro stack. + _macro_stack = List(Instance(_MacroCommand)) + + # The stack itself. + _stack = List(Instance(_StackEntry)) + + ########################################################################### + # 'ICommandStack' interface. + ########################################################################### + + def begin_macro(self, name): + """This begins a macro by creating an empty command with the given + 'name'. All subsequent calls to 'push()' create commands that will be + children of the empty command until the next call to 'end_macro()'. + Macros may be nested. The stack is disabled (ie. nothing can be undone + or redone) while a macro is being created (ie. while there is an + outstanding 'end_macro()' call). + """ + + command = _MacroCommand(name=name) + self.push(command) + self._macro_stack.append(command) + + def clear(self): + """This clears the stack, without undoing or redoing any commands, and + leaves the stack in a clean state. It is typically used when all + changes to the data have been abandoned. + """ + + self._index = -1 + self._stack = [] + self._macro_stack = [] + + self.undo_manager.stack_updated = self + + def end_macro(self): + """ This ends a macro. """ + + try: + self._macro_stack.pop() + except IndexError: + pass + + def push(self, command): + """This executes a command and saves it on the command stack so that + it can be subsequently undone and redone. 'command' is an instance + that implements the ICommand interface. Its 'do()' method is called + to execute the command. If any value is returned by 'do()' then it is + returned by 'push()'. + """ + + # See if the command can be merged with the previous one. + if len(self._macro_stack) == 0: + if self._index >= 0: + merged = self._stack[self._index].command.merge(command) + else: + merged = False + else: + merged = self._macro_stack[-1].merge(command) + + # Increment the global sequence number. + if not merged: + self.undo_manager.sequence_nr += 1 + + # Execute the command. + result = command.do() + + # Do nothing more if the command was merged. + if merged: + return result + + # Only update the command stack if there is no current macro. + if len(self._macro_stack) == 0: + # Remove everything on the stack after the last command that was + # done. + self._index += 1 + del self._stack[self._index:] + + # Create a new stack entry and add it to the stack. + entry = _StackEntry( + command=command, sequence_nr=self.undo_manager.sequence_nr + ) + + self._stack.append(entry) + self.undo_manager.stack_updated = self + else: + # Add the command to the parent macro command. + self._macro_stack[-1].macro_commands.append(command) + + return result + + def redo(self, sequence_nr=0): + """If 'sequence_nr' is 0 then the last command that was undone is + redone and any result returned. Otherwise commands are redone up to + and including the given 'sequence_nr' and any result of the last of + these is returned. + """ + + # Make sure a redo is valid in the current context. + if self.redo_name == "": + return None + + if sequence_nr == 0: + result = self._redo_one() + else: + result = None + + while self._index + 1 < len(self._stack): + if self._stack[self._index + 1].sequence_nr > sequence_nr: + break + + result = self._redo_one() + + self.undo_manager.stack_updated = self + + return result + + def undo(self, sequence_nr=0): + """If 'sequence_nr' is 0 then the last command is undone. Otherwise + commands are undone up to and including the given 'sequence_nr'. + """ + + # Make sure an undo is valid in the current context. + if self.undo_name == "": + return + + if sequence_nr == 0: + self._undo_one() + else: + while self._index >= 0: + if self._stack[self._index].sequence_nr <= sequence_nr: + break + + self._undo_one() + + self.undo_manager.stack_updated = self + + ########################################################################### + # Private interface. + ########################################################################### + + def _redo_one(self): + """ Redo the command at the current index and return the result. """ + + self._index += 1 + entry = self._stack[self._index] + + return entry.command.redo() + + def _undo_one(self): + """ Undo the command at the current index. """ + + entry = self._stack[self._index] + self._index -= 1 + + entry.command.undo() + + def _get_clean(self): + """ Get the clean state of the stack. """ + + if self._index >= 0: + clean = self._stack[self._index].clean + else: + clean = True + + return clean + + def _set_clean(self, clean): + """ Set the clean state of the stack. """ + + if self._index >= 0: + self._stack[self._index].clean = clean + + def _get_redo_name(self): + """ Get the name of the redo command, if any. """ + + redo_name = "" + + if len(self._macro_stack) == 0 and self._index + 1 < len(self._stack): + redo_name = self._stack[self._index + 1].command.name.replace( + "&", "" + ) + + return redo_name + + def _get_undo_name(self): + """ Get the name of the undo command, if any. """ + + undo_name = "" + + if len(self._macro_stack) == 0 and self._index >= 0: + command = self._stack[self._index].command + undo_name = command.name.replace("&", "") + + return undo_name diff --git a/pyface/undo/i_command.py b/pyface/undo/i_command.py new file mode 100644 index 000000000..3dbe9c684 --- /dev/null +++ b/pyface/undo/i_command.py @@ -0,0 +1,75 @@ +# (C) Copyright 2007-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2007, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + + +# Enthought library imports. +from traits.api import Any, Interface, Str + + +class ICommand(Interface): + """The command interface. The state of the data can be changed by passing + an instance that implements this interface to the 'push()' method of a + command stack along with any arguments. + """ + + #### 'ICommand' interface ################################################# + + #: This is the data on which the command operates. + data = Any() + + #: This is the name of the command as it will appear in any GUI element. It + #: may include '&' which will be automatically removed whenever it is + #: inappropriate. + name = Str() + + ########################################################################### + # 'ICommand' interface. + ########################################################################### + + def do(self): + """This is called by the command stack to do the command and to return + any value. The command must save any state necessary for the 'redo()' + and 'undo()' methods to work. The class's __init__() must also ensure + that deep copies of any arguments are made if appropriate. It is + guaranteed that this will only ever be called once and that it will be + called before any call to 'redo()' or 'undo()'. + """ + + def merge(self, other): + """This is called by the command stack to try and merge another + command with this one. True is returned if the commands were merged. + 'other' is the command that is about to be executed. If the commands + are merged then 'other' will discarded and not placed on the command + stack. A subsequent undo or redo of this modified command must have + the same effect as the two original commands. + """ + + def redo(self): + """This is called by the command stack to redo the command. Any + returned value will replace the value that the command stack references + from the original call to 'do()' or previous call to 'redo()'. + """ + + def undo(self): + """ This is called by the command stack to undo the command. """ diff --git a/pyface/undo/i_command_stack.py b/pyface/undo/i_command_stack.py new file mode 100644 index 000000000..d5aa3ed17 --- /dev/null +++ b/pyface/undo/i_command_stack.py @@ -0,0 +1,101 @@ +# (C) Copyright 2007-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2007, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +# Enthought library imports. +from traits.api import Bool, Instance, Interface, Str + +# Local imports. +from .i_undo_manager import IUndoManager + + +class ICommandStack(Interface): + """The command stack interface. A command stack is responsible for + managing the changes to a data model and recording those changes so that + they can be undone or redone. + """ + + #### 'ICommandStack' interface ############################################ + + #: This is the clean state of the stack. Its value changes as commands are + #: undone and redone. It can also be explicity set to mark the current + #: stack position as being clean (when the data is saved to disk for + #: example). + clean = Bool() + + #: This is the name of the command that can be redone. It will be empty if + #: there is no command that can be redone. It is maintained by the undo + #: stack. + redo_name = Str() + + #: This is the undo manager that manages this stack. + undo_manager = Instance(IUndoManager) + + #: This is the name of the command that can be undone. It will be empty if + #: there is no command that can be undone. It is maintained by the undo + #: stack. + undo_name = Str() + + ########################################################################### + # 'ICommandStack' interface. + ########################################################################### + + def begin_macro(self, name): + """This begins a macro by creating an empty command with the given + 'name'. The commands passed to all subsequent calls to 'push()' will + be contained in the macro until the next call to 'end_macro()'. Macros + may be nested. The stack is disabled (ie. nothing can be undone or + redone) while a macro is being created (ie. while there is an + outstanding 'end_macro()' call). + """ + + def clear(self): + """This clears the stack, without undoing or redoing any commands, and + leaves the stack in a clean state. It is typically used when all + changes to the data have been abandoned. + """ + + def end_macro(self): + """ This ends a macro. """ + + def push(self, command): + """This executes a command and saves it on the command stack so that + it can be subsequently undone and redone. 'command' is an instance + that implements the ICommand interface. Its 'do()' method is called + to execute the command. If any value is returned by 'do()' then it is + returned by 'push()'. The command stack will keep a reference to the + result so that it can recognise it as an argument to a subsequent + command (which allows a script to properly save a result needed later). + """ + + def redo(self, sequence_nr=0): + """If 'sequence_nr' is 0 then the last command that was undone is + redone and any result returned. Otherwise commands are redone up to + and including the given 'sequence_nr' and any result of the last of + these is returned. + """ + + def undo(self, sequence_nr=0): + """If 'sequence_nr' is 0 then the last command is undone. Otherwise + commands are undone up to and including the given 'sequence_nr'. + """ diff --git a/pyface/undo/i_undo_manager.py b/pyface/undo/i_undo_manager.py new file mode 100644 index 000000000..8f7208890 --- /dev/null +++ b/pyface/undo/i_undo_manager.py @@ -0,0 +1,75 @@ +# (C) Copyright 2007-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2008, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +# Enthought library imports. +from traits.api import Bool, Event, Instance, Int, Interface, Str + + +class IUndoManager(Interface): + """The undo manager interface. An undo manager is responsible for one or + more command stacks. Typically an application would have a single undo + manager. + """ + + #### 'IUndoManager' interface ############################################# + + #: This is the currently active command stack and may be None. Typically it + #: is set when some sort of editor becomes active. + #: IUndoManager and ICommandStack depend on one another, hence we can't + #: directly import ICommandStack and use it here. + active_stack = Instance("pyface.undo.api.ICommandStack") + + #: This reflects the clean state of the currently active command stack. It + #: is intended to support a "document modified" indicator in the GUI. It is + #: maintained by the undo manager. + active_stack_clean = Bool() + + #: This is the name of the command that can be redone. It will be empty if + #: there is no command that can be redone. It is maintained by the undo + #: manager. + redo_name = Str() + + #: This is the sequence number of the next command to be performed. It is + #: incremented immediately before a command is invoked (by its 'do()' + #: method). + sequence_nr = Int() + + #: This event is fired when the index of a command stack changes. Note that + #: it may not be the active stack. + stack_updated = Event(Instance("pyface.undo.api.ICommandStack")) + + #: This is the name of the command that can be undone. It will be empty if + #: there is no command that can be undone. It is maintained by the undo + #: manager. + undo_name = Str() + + ########################################################################### + # 'IUndoManager' interface. + ########################################################################### + + def redo(self): + """ Redo the last undone command of the active command stack. """ + + def undo(self): + """ Undo the last command of the active command stack. """ diff --git a/pyface/undo/tests/__init__.py b/pyface/undo/tests/__init__.py new file mode 100644 index 000000000..6b94a8b23 --- /dev/null +++ b/pyface/undo/tests/__init__.py @@ -0,0 +1,9 @@ +# (C) Copyright 2005-2020 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! diff --git a/pyface/undo/tests/test_command_stack.py b/pyface/undo/tests/test_command_stack.py new file mode 100644 index 000000000..9e52ede94 --- /dev/null +++ b/pyface/undo/tests/test_command_stack.py @@ -0,0 +1,184 @@ +# (C) Copyright 2005-2020 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! + +from contextlib import contextmanager +import unittest + +from pyface.undo.api import CommandStack, UndoManager +from pyface.undo.tests.testing_commands import SimpleCommand, UnnamedCommand + + +class TestCommandStack(unittest.TestCase): + def setUp(self): + self.stack = CommandStack() + undo_manager = UndoManager() + self.stack.undo_manager = undo_manager + + self.command = SimpleCommand() + + # Command pushing tests --------------------------------------------------- + + def test_empty_command_stack(self): + with self.assert_n_commands_pushed(self.stack, 0): + pass + + def test_1_command_pushed(self): + with self.assert_n_commands_pushed(self.stack, 1): + self.stack.push(self.command) + + def test_n_command_pushed(self): + n = 4 + with self.assert_n_commands_pushed(self.stack, n): + for i in range(n): + self.stack.push(self.command) + + # Undo/Redo tests --------------------------------------------------------- + + def test_undo_1_command(self): + with self.assert_n_commands_pushed_and_undone(self.stack, 1): + self.stack.push(self.command) + self.assertEqual(self.stack.undo_name, self.command.name) + self.stack.undo() + + def test_undo_n_command(self): + n = 4 + with self.assert_n_commands_pushed_and_undone(self.stack, n): + for i in range(n): + self.stack.push(self.command) + + for i in range(n): + self.stack.undo() + + def test_undo_redo_sequence_nr(self): + n = 4 + for i in range(n): + self.stack.push(self.command) + self.assertEqual(self.stack._index, 3) + # undo back to the 1st command in the stack + self.stack.undo(1) + self.assertEqual(self.stack._index, 0) + # redo back to the 3rd command in the stack + self.stack.redo(3) + self.assertEqual(self.stack._index, 2) + + def test_undo_unnamed_command(self): + unnamed_command = UnnamedCommand() + with self.assert_n_commands_pushed(self.stack, 1): + self.stack.push(unnamed_command) + + # But the command cannot be undone because it has no name + self.assertEqual(self.stack.undo_name, "") + # This is a no-op + self.stack.undo() + + def test_undo_redo_1_command(self): + with self.assert_n_commands_pushed(self.stack, 1): + self.stack.push(self.command) + self.stack.undo() + self.stack.redo() + + # Macro tests ------------------------------------------------------------- + + def test_define_macro(self): + with self.assert_n_commands_pushed(self.stack, 1): + add_macro(self.stack, num_commands=2) + + def test_undo_macro(self): + with self.assert_n_commands_pushed_and_undone(self.stack, 1): + # The 2 pushes are viewed as 1 command + add_macro(self.stack, num_commands=2) + self.stack.undo() + + # Cleanliness tests ------------------------------------------------------- + + def test_empty_stack_is_clean(self): + self.assertTrue(self.stack.clean) + + def test_non_empty_stack_is_dirty(self): + self.stack.push(self.command) + self.assertFalse(self.stack.clean) + + def test_make_clean(self): + # This makes it dirty by default + self.stack.push(self.command) + # Make the current tip of the stack clean + self.stack.clean = True + self.assertTrue(self.stack.clean) + + def test_make_dirty(self): + # Start from a clean state: + self.stack.push(self.command) + self.stack.clean = True + + self.stack.clean = False + self.assertFalse(self.stack.clean) + + def test_save_push_undo_is_clean(self): + self.stack.push(self.command) + + self.stack.clean = True + self.stack.push(self.command) + self.stack.undo() + self.assertTrue(self.stack.clean) + + def test_save_push_save_undo_is_clean(self): + self.stack.push(self.command) + + self.stack.clean = True + self.stack.push(self.command) + self.stack.clean = True + self.stack.undo() + self.assertTrue(self.stack.clean) + + def test_push_undo_save_redo_is_dirty(self): + self.stack.push(self.command) + self.stack.undo() + self.stack.clean = True + self.stack.redo() + self.assertFalse(self.stack.clean) + + def test_clear(self): + n = 5 + for _ in range(n): + self.stack.push(self.command) + self.stack.clear() + self.assertEqual(self.stack._stack, []) + self.assertTrue(self.stack.clean) + + # Assertion helpers ------------------------------------------------------- + + @contextmanager + def assert_n_commands_pushed(self, stack, n): + current_length = len(stack._stack) + yield + # N commands have been pushed... + self.assertEqual(len(stack._stack), current_length + n) + # ... and the state is at the tip of the stack... + self.assertEqual(stack._index, current_length + n - 1) + + @contextmanager + def assert_n_commands_pushed_and_undone(self, stack, n): + current_length = len(stack._stack) + yield + # N commands have been pushed and then reverted. The stack still + # contains the commands... + self.assertEqual(len(stack._stack), n) + # ... but we are back to the initial (clean) state + self.assertEqual(stack._index, current_length - 1) + + +def add_macro(stack, num_commands=2): + command = SimpleCommand() + stack.begin_macro("Increment n times") + try: + for i in range(num_commands): + stack.push(command) + finally: + stack.end_macro() diff --git a/pyface/undo/tests/test_undo_manager.py b/pyface/undo/tests/test_undo_manager.py new file mode 100644 index 000000000..15cd467ad --- /dev/null +++ b/pyface/undo/tests/test_undo_manager.py @@ -0,0 +1,73 @@ +# (C) Copyright 2005-2020 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! + +import unittest + +from traits.testing.api import UnittestTools + +from pyface.undo.api import CommandStack, UndoManager +from pyface.undo.tests.testing_commands import SimpleCommand, UnnamedCommand + + +class TestUndoManager(unittest.TestCase, UnittestTools): + + def setUp(self): + self.stack_a = CommandStack() + self.stack_b = CommandStack() + self.undo_manager = UndoManager() + self.stack_a.undo_manager = self.undo_manager + self.stack_b.undo_manager = self.undo_manager + + self.undo_manager.active_stack = self.stack_a + + self.command = SimpleCommand() + + # Command pushing tests --------------------------------------------------- + + def test_undo(self): + self.assertEqual(self.stack_a._index, -1) + self.stack_a.push(self.command) + self.assertEqual(self.stack_a._index, 0) + with self.assertTraitChanges( + self.undo_manager, 'stack_updated', count=1): + self.undo_manager.undo() + self.assertEqual(self.stack_a._index, -1) + + def test_redo(self): + self.assertEqual(self.stack_a._index, -1) + self.stack_a.push(self.command) + self.undo_manager.undo() + self.assertEqual(self.stack_a._index, -1) + with self.assertTraitChanges( + self.undo_manager, 'stack_updated', count=1): + self.undo_manager.redo() + self.assertEqual(self.stack_a._index, 0) + + def test_change_active_stack(self): + for _ in range(5): + self.stack_a.push(self.command) + self.assertEqual(self.stack_a._index, 4) + self.undo_manager.active_stack = self.stack_b + for _ in range(5): + self.stack_b.push(self.command) + self.assertEqual(self.stack_b._index, 4) + for _ in range(3): + self.undo_manager.undo() + self.undo_manager.redo() + + self.assertEqual(self.stack_a._index, 4) + self.assertEqual(self.stack_b._index, 2) + + def test_active_stack_clean(self): + self.assertTrue(self.undo_manager.active_stack_clean) + self.stack_a.push(self.command) + self.assertFalse(self.undo_manager.active_stack_clean) + self.undo_manager.active_stack = None + self.assertTrue(self.undo_manager.active_stack_clean) diff --git a/pyface/undo/tests/testing_commands.py b/pyface/undo/tests/testing_commands.py new file mode 100644 index 000000000..f1f9b2c81 --- /dev/null +++ b/pyface/undo/tests/testing_commands.py @@ -0,0 +1,33 @@ +# (C) Copyright 2005-2020 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! + +from traits.api import Int +from pyface.undo.api import AbstractCommand + + +class SimpleCommand(AbstractCommand): + """ Simplest command possible operating on an integer. """ + + name = "Increment by 1" + + data = Int() + + def do(self): + self.redo() + + def redo(self): + self.data += 1 + + def undo(self): + self.data -= 1 + + +class UnnamedCommand(SimpleCommand): + name = "" diff --git a/pyface/undo/undo_manager.py b/pyface/undo/undo_manager.py new file mode 100644 index 000000000..b38aa07ee --- /dev/null +++ b/pyface/undo/undo_manager.py @@ -0,0 +1,132 @@ +# (C) Copyright 2008-2020 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! + +# ------------------------------------------------------------------------------ +# Copyright (c) 2008, Riverbank Computing Limited +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/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! +# +# Author: Riverbank Computing Limited +# Description: +# ------------------------------------------------------------------------------ + +# Enthought library imports. +from traits.api import ( + Bool, + Event, + HasTraits, + Instance, + Int, + Property, + Str, + provides, +) + +# Local imports. +from .i_undo_manager import IUndoManager + + +@provides(IUndoManager) +class UndoManager(HasTraits): + """The UndoManager class is the default implementation of the + IUndoManager interface. + """ + + #### 'IUndoManager' interface ############################################# + + #: This is the currently active command stack and may be None. Typically it + #: is set when some sort of editor becomes active. + active_stack = Instance("pyface.undo.api.ICommandStack") + + #: This reflects the clean state of the currently active command stack. It + #: is intended to support a "document modified" indicator in the GUI. It is + #: maintained by the undo manager. + active_stack_clean = Property(Bool) + + #: This is the name of the command that can be redone. It will be empty if + #: there is no command that can be redone. It is maintained by the undo + #: manager. + redo_name = Property(Str) + + #: This is the sequence number of the next command to be performed. It is + #: incremented immediately before a command is invoked (by its 'do()' + #: method). + sequence_nr = Int() + + #: This event is fired when the index of a command stack changes. The value + #: of the event is the stack that has changed. Note that it may not be the + #: active stack. + stack_updated = Event() + + #: This is the name of the command that can be undone. It will be empty if + #: there is no command that can be undone. It is maintained by the undo + #: manager. + undo_name = Property(Str) + + ########################################################################### + # 'IUndoManager' interface. + ########################################################################### + + def redo(self): + """ Redo the last undone command of the active command stack. """ + + if self.active_stack is not None: + self.active_stack.redo() + + def undo(self): + """ Undo the last command of the active command stack. """ + + if self.active_stack is not None: + self.active_stack.undo() + + ########################################################################### + # Private interface. + ########################################################################### + + def _active_stack_changed(self, new): + """ Handle a different stack becoming active. """ + + # Pretend that the stack contents have changed. + self.stack_updated = new + + def _get_active_stack_clean(self): + """ Get the current clean state. """ + + if self.active_stack is None: + active_stack_clean = True + else: + active_stack_clean = self.active_stack.clean + + return active_stack_clean + + def _get_redo_name(self): + """ Get the current redo name. """ + + if self.active_stack is None: + redo_name = "" + else: + redo_name = self.active_stack.redo_name + + return redo_name + + def _get_undo_name(self): + """ Get the current undo name. """ + + if self.active_stack is None: + undo_name = "" + else: + undo_name = self.active_stack.undo_name + + return undo_name diff --git a/pyface/workbench/i_editor.py b/pyface/workbench/i_editor.py index 316c6103a..22b83baad 100755 --- a/pyface/workbench/i_editor.py +++ b/pyface/workbench/i_editor.py @@ -33,7 +33,7 @@ class IEditor(IWorkbenchPart): """ The interface of a workbench editor. """ # The optional command stack. - command_stack = Instance("apptools.undo.api.ICommandStack") + command_stack = Instance("pyface.undo.api.ICommandStack") # Is the object that the editor is editing 'dirty' i.e., has it been # modified but not saved? @@ -71,7 +71,7 @@ class MEditor(MWorkbenchPart): # 'IEditor' interface -------------------------------------------------# # The optional command stack. - command_stack = Instance("apptools.undo.api.ICommandStack") + command_stack = Instance("pyface.undo.api.ICommandStack") # Is the object that the editor is editing 'dirty' i.e., has it been # modified but not saved? @@ -140,7 +140,7 @@ def _command_stack_default(self): # We make sure the undo package is entirely optional. try: - from apptools.undo.api import CommandStack + from pyface.undo.api import CommandStack except ImportError: return None diff --git a/pyface/workbench/i_workbench.py b/pyface/workbench/i_workbench.py index 188abdeda..1f7e166dc 100644 --- a/pyface/workbench/i_workbench.py +++ b/pyface/workbench/i_workbench.py @@ -35,7 +35,7 @@ class IWorkbench(Interface): state_location = Str() # The optional undo manager. - undo_manager = Instance("apptools.undo.api.IUndoManager") + undo_manager = Instance("pyface.undo.api.IUndoManager") # The user defined perspectives manager. user_perspective_manager = Instance(UserPerspectiveManager) diff --git a/pyface/workbench/workbench.py b/pyface/workbench/workbench.py index 97489ccf5..9500f0647 100755 --- a/pyface/workbench/workbench.py +++ b/pyface/workbench/workbench.py @@ -58,7 +58,7 @@ class Workbench(HasTraits): state_location = Str() # The optional undo manager. - undo_manager = Instance("apptools.undo.api.IUndoManager") + undo_manager = Instance("pyface.undo.api.IUndoManager") # The user-defined perspectives manager. user_perspective_manager = Instance(UserPerspectiveManager) @@ -266,7 +266,7 @@ def _undo_manager_default(self): # We make sure the undo package is entirely optional. try: - from apptools.undo.api import UndoManager + from pyface.undo.api import UndoManager except ImportError: return None