1# ------------------------------------------------------------------------------
2#
3#  Copyright (c) 2005, Enthought, Inc.
4#  All rights reserved.
5#
6#  This software is provided without warranty under the terms of the BSD
7#  license included in LICENSE.txt and may be redistributed only
8#  under the conditions described in the aforementioned license.  The license
9#  is also available online at http://www.enthought.com/licenses/BSD.txt
10#
11#  Thanks for using Enthought open source!
12#
13#  Author: David C. Morrill
14#  Date:   10/07/2004
15#
16# ------------------------------------------------------------------------------
17
18""" Defines the manager for Undo and Redo history for Traits user interface
19    support.
20"""
21
22import collections.abc
23
24from traits.api import (
25    Event,
26    HasPrivateTraits,
27    HasStrictTraits,
28    HasTraits,
29    Instance,
30    Int,
31    List,
32    Property,
33    Str,
34    Trait,
35)
36
37
38NumericTypes = (int, float, complex)
39SimpleTypes = (str, bytes) + NumericTypes
40
41
42class AbstractUndoItem(HasPrivateTraits):
43    """ Abstract base class for undo items.
44    """
45
46    def undo(self):
47        """ Undoes the change.
48        """
49        raise NotImplementedError
50
51    def redo(self):
52        """ Re-does the change.
53        """
54        raise NotImplementedError
55
56    def merge_undo(self, undo_item):
57        """ Merges two undo items if possible.
58        """
59        return False
60
61
62class UndoItem(AbstractUndoItem):
63    """ A change to an object trait, which can be undone.
64    """
65
66    # -------------------------------------------------------------------------
67    #  Trait definitions:
68    # -------------------------------------------------------------------------
69
70    #: Object the change occurred on
71    object = Instance(HasTraits)
72
73    #: Name of the trait that changed
74    name = Str()
75
76    #: Old value of the changed trait
77    old_value = Property()
78
79    #: New value of the changed trait
80    new_value = Property()
81
82    def _get_old_value(self):
83        return self._old_value
84
85    def _set_old_value(self, value):
86        if isinstance(value, list):
87            value = value[:]
88        self._old_value = value
89
90    def _get_new_value(self):
91        return self._new_value
92
93    def _set_new_value(self, value):
94        if isinstance(value, list):
95            value = value[:]
96        self._new_value = value
97
98    def undo(self):
99        """ Undoes the change.
100        """
101        try:
102            setattr(self.object, self.name, self.old_value)
103        except Exception:
104            from traitsui.api import raise_to_debug
105
106            raise_to_debug()
107
108    def redo(self):
109        """ Re-does the change.
110        """
111        try:
112            setattr(self.object, self.name, self.new_value)
113        except Exception:
114            from traitsui.api import raise_to_debug
115
116            raise_to_debug()
117
118    def merge_undo(self, undo_item):
119        """ Merges two undo items if possible.
120        """
121        # Undo items are potentially mergeable only if they are of the same
122        # class and refer to the same object trait, so check that first:
123        if (
124            isinstance(undo_item, self.__class__)
125            and (self.object is undo_item.object)
126            and (self.name == undo_item.name)
127        ):
128            v1 = self.new_value
129            v2 = undo_item.new_value
130            t1 = type(v1)
131            if isinstance(v2, t1):
132
133                if isinstance(t1, str):
134                    # Merge two undo items if they have new values which are
135                    # strings which only differ by one character (corresponding
136                    # to a single character insertion, deletion or replacement
137                    # operation in a text editor):
138                    n1 = len(v1)
139                    n2 = len(v2)
140                    if abs(n1 - n2) > 1:
141                        return False
142                    n = min(n1, n2)
143                    i = 0
144                    while (i < n) and (v1[i] == v2[i]):
145                        i += 1
146                    if v1[i + (n2 <= n1):] == v2[i + (n2 >= n1):]:
147                        self.new_value = v2
148                        return True
149
150                elif isinstance(v1, collections.abc.Sequence):
151                    # Merge sequence types only if a single element has changed
152                    # from the 'original' value, and the element type is a
153                    # simple Python type:
154                    v1 = self.old_value
155                    if isinstance(v1, collections.abc.Sequence):
156                        # Note: wxColour says it's a sequence type, but it
157                        # doesn't support 'len', so we handle the exception
158                        # just in case other classes have similar behavior:
159                        try:
160                            if len(v1) == len(v2):
161                                diffs = 0
162                                for i, item in enumerate(v1):
163                                    titem = type(item)
164                                    item2 = v2[i]
165                                    if (
166                                        (titem not in SimpleTypes)
167                                        or (not isinstance(item2, titem))
168                                        or (item != item2)
169                                    ):
170                                        diffs += 1
171                                        if diffs >= 2:
172                                            return False
173                                if diffs == 0:
174                                    return False
175                                self.new_value = v2
176                                return True
177                        except Exception:
178                            pass
179
180                elif t1 in NumericTypes:
181                    # Always merge simple numeric trait changes:
182                    self.new_value = v2
183                    return True
184        return False
185
186    def __repr__(self):
187        """ Returns a "pretty print" form of the object.
188        """
189        n = self.name
190        cn = self.object.__class__.__name__
191        return "undo( %s.%s = %s )\nredo( %s.%s = %s )" % (
192            cn,
193            n,
194            self.old_value,
195            cn,
196            n,
197            self.new_value,
198        )
199
200
201class ListUndoItem(AbstractUndoItem):
202    """ A change to a list, which can be undone.
203    """
204
205    # -------------------------------------------------------------------------
206    #  Trait definitions:
207    # -------------------------------------------------------------------------
208
209    #: Object that the change occurred on
210    object = Instance(HasTraits)
211
212    #: Name of the trait that changed
213    name = Str()
214
215    #: Starting index
216    index = Int()
217
218    #: Items added to the list
219    added = List()
220
221    #: Items removed from the list
222    removed = List()
223
224    def undo(self):
225        """ Undoes the change.
226        """
227        try:
228            list = getattr(self.object, self.name)
229            list[self.index : (self.index + len(self.added))] = self.removed
230        except Exception:
231            from traitsui.api import raise_to_debug
232
233            raise_to_debug()
234
235    def redo(self):
236        """ Re-does the change.
237        """
238        try:
239            list = getattr(self.object, self.name)
240            list[self.index : (self.index + len(self.removed))] = self.added
241        except Exception:
242            from traitsui.api import raise_to_debug
243
244            raise_to_debug()
245
246    def merge_undo(self, undo_item):
247        """ Merges two undo items if possible.
248        """
249        # Discard undo items that are identical to us. This is to eliminate
250        # the same undo item being created by multiple listeners monitoring the
251        # same list for changes:
252        if (
253            isinstance(undo_item, self.__class__)
254            and (self.object is undo_item.object)
255            and (self.name == undo_item.name)
256            and (self.index == undo_item.index)
257        ):
258            added = undo_item.added
259            removed = undo_item.removed
260            if (len(self.added) == len(added)) and (
261                len(self.removed) == len(removed)
262            ):
263                for i, item in enumerate(self.added):
264                    if item is not added[i]:
265                        break
266                else:
267                    for i, item in enumerate(self.removed):
268                        if item is not removed[i]:
269                            break
270                    else:
271                        return True
272        return False
273
274    def __repr__(self):
275        """ Returns a 'pretty print' form of the object.
276        """
277        return "undo( %s.%s[%d:%d] = %s )" % (
278            self.object.__class__.__name__,
279            self.name,
280            self.index,
281            self.index + len(self.removed),
282            self.added,
283        )
284
285
286class UndoHistory(HasStrictTraits):
287    """ Manages a list of undoable changes.
288    """
289
290    # -------------------------------------------------------------------------
291    #  Trait definitions:
292    # -------------------------------------------------------------------------
293
294    #: List of accumulated undo changes
295    history = List()
296    #: The current position in the list
297    now = Int()
298    #: Fired when state changes to undoable
299    undoable = Event(False)
300    #: Fired when state changes to redoable
301    redoable = Event(False)
302    #: Can an action be undone?
303    can_undo = Property()
304    #: Can an action be redone?
305    can_redo = Property()
306
307    def add(self, undo_item, extend=False):
308        """ Adds an UndoItem to the history.
309        """
310        if extend:
311            self.extend(undo_item)
312            return
313
314        # Try to merge the new undo item with the previous item if allowed:
315        now = self.now
316        if now > 0:
317            previous = self.history[now - 1]
318            if (len(previous) == 1) and previous[0].merge_undo(undo_item):
319                self.history[now:] = []
320                return
321
322        old_len = len(self.history)
323        self.history[now:] = [[undo_item]]
324        self.now += 1
325        if self.now == 1:
326            self.undoable = True
327        if self.now <= old_len:
328            self.redoable = False
329
330    def extend(self, undo_item):
331        """ Extends the undo history.
332
333        If possible the method merges the new UndoItem with the last item in
334        the history; otherwise, it appends the new item.
335        """
336        if self.now > 0:
337            undo_list = self.history[self.now - 1]
338            if not undo_list[-1].merge_undo(undo_item):
339                undo_list.append(undo_item)
340
341    def undo(self):
342        """ Undoes an operation.
343        """
344        if self.can_undo:
345            self.now -= 1
346            items = self.history[self.now]
347            for i in range(len(items) - 1, -1, -1):
348                items[i].undo()
349            if self.now == 0:
350                self.undoable = False
351            if self.now == (len(self.history) - 1):
352                self.redoable = True
353
354    def redo(self):
355        """ Redoes an operation.
356        """
357        if self.can_redo:
358            self.now += 1
359            for item in self.history[self.now - 1]:
360                item.redo()
361            if self.now == 1:
362                self.undoable = True
363            if self.now == len(self.history):
364                self.redoable = False
365
366    def revert(self):
367        """ Reverts all changes made so far and clears the history.
368        """
369        history = self.history[: self.now]
370        self.clear()
371        for i in range(len(history) - 1, -1, -1):
372            items = history[i]
373            for j in range(len(items) - 1, -1, -1):
374                items[j].undo()
375
376    def clear(self):
377        """ Clears the undo history.
378        """
379        old_len = len(self.history)
380        old_now = self.now
381        self.now = 0
382        del self.history[:]
383        if old_now > 0:
384            self.undoable = False
385        if old_now < old_len:
386            self.redoable = False
387
388    def _get_can_undo(self):
389        """ Are there any undoable operations?
390        """
391        return self.now > 0
392
393    def _get_can_redo(self):
394        """ Are there any redoable operations?
395        """
396        return self.now < len(self.history)
397
398
399class UndoHistoryUndoItem(AbstractUndoItem):
400    """ An undo item for the undo history.
401    """
402
403    # -------------------------------------------------------------------------
404    #  Trait definitions:
405    # -------------------------------------------------------------------------
406
407    #: The undo history to undo or redo
408    history = Instance(UndoHistory)
409
410    def undo(self):
411        """ Undoes the change.
412        """
413        history = self.history
414        for i in range(history.now - 1, -1, -1):
415            items = history.history[i]
416            for j in range(len(items) - 1, -1, -1):
417                items[j].undo()
418
419    def redo(self):
420        """ Re-does the change.
421        """
422        history = self.history
423        for i in range(0, history.now):
424            for item in history.history[i]:
425                item.redo()
426