Ticket #3436: undo.py

File undo.py, 16.5 KB (added by Eric Pettersen, 5 years ago)

has deleted_check code

Line 
1# vim: set expandtab ts=4 sw=4:
2
3# === UCSF ChimeraX Copyright ===
4# Copyright 2017 Regents of the University of California.
5# All rights reserved. This software provided pursuant to a
6# license agreement containing restrictions on its disclosure,
7# duplication and use. For details see:
8# http://www.rbvi.ucsf.edu/chimerax/docs/licensing.html
9# This notice must be embedded in or attached to all copies,
10# including partial copies, of the software or any revisions
11# or derivations thereof.
12# === UCSF ChimeraX Copyright ===
13
14"""This module defines classes for maintaining stacks of "undo"
15and "redo" callbacks. Actions can register "undo" and "redo"
16functions which may be invoked via GUI, command or programmatically.
17"""
18
19import abc
20from .state import StateManager
21
22
23class Undo(StateManager):
24 """A per-session undo manager for tracking undo/redo callbacks.
25
26 'Undo' managers are per-session singletons that track
27 undo/redo callbacks in two stacks: the undo and redo stacks.
28 Actions can register objects that conform to the
29 'UndoAction'. When registered, an UndoAction instance
30 is pushed on to the undo stack and the redo stack is cleared.
31
32 When an "undo" is requested, an UndoAction is popped
33 off the undo stack, and its undo callback is invoked
34 If the undo callback throws an error or the UndoAction
35 'can_redo' attribute is false, the redo stack is cleared,
36 because we cannot establish the "original" state for the
37 next redo; otherwise, the UndoAction is pushed onto the
38 redo stack.
39
40 When a "redo" is requested, an UndoAction is popped off the
41 redo stack, its redo callback is invoked, and the UndoAction
42 is pushed on to the undo stack.
43
44 Maximum stack depths are supported. If zero, there is no
45 limit. Otherwise, if a stack grows deeper than its
46 allowed maximum, the bottom of stack is discarded.
47
48 Attributes
49 ----------
50 max_depth : int
51 Maximum depth for both the undo and redo stacks.
52 Default is 10. Setting to 0 removes limit.
53 redo_stack : list
54 List of UndoAction instances
55 undo_stack : list
56 List of UndoAction instances
57 """
58 # Most of this code is modeled after tools.Tools
59
60 def __init__(self, session, first=False, max_depth=10):
61 """Initialize per-session state manager for undo/redo actions.
62
63 Parameters
64 ----------
65 session : instance of chimerax.core.session.Session
66 Session for which this state manager was created.
67 """
68 import weakref
69 self._session = weakref.ref(session)
70 self.max_depth = max_depth
71 self.undo_stack = []
72 self.redo_stack = []
73 self._register_stack = []
74
75 @property
76 def session(self):
77 """Returns the session this undo state manager is in.
78 """
79 return self._session()
80
81 def register_push(self, handler):
82 """Push handler onto undo registration stack.
83
84 Parameters
85 ----------
86 handler : instance of UndoHandler
87 Handler that processes registration requests
88
89 Returns
90 -------
91 The registered handler.
92 """
93 self._register_stack.insert(0, handler)
94 return handler
95
96 def register_pop(self):
97 """Pop last pushed handler from undo registration stack.
98
99 Returns
100 -------
101 The popped handler.
102 """
103 handler = self._register_stack.pop(0)
104 return handler
105
106 def aggregate(self, name):
107 return UndoAggregateHandler(self, name)
108
109 def block(self):
110 return UndoBlockHandler(self, None)
111
112 def register(self, action):
113 """Register undo/redo actions with state manager.
114
115 Parameters
116 ----------
117 action : instance of UndoAction
118 Action that can change session between "before"
119 and "after" states.
120
121 Returns
122 -------
123 The registered action.
124 """
125 if len(self._register_stack):
126 return self._register_stack[0].register(action)
127 self._push(self.undo_stack, action)
128 self.redo_stack.clear()
129 self._update_ui()
130 return action
131
132 def deregister(self, action, delete_history=True):
133 """Deregisters undo/redo actions from state manager.
134 If the action is on the undo stack, all prior undo
135 actions are deleted if 'delete_history' is True
136 (default). Similarly, if the action is on the redo
137 stack all subsequent redo actions are deleted if
138 'delete_history' is True. The 'delete_history'
139 default is True because the deregistering action is
140 the one to establish the "current" state for the
141 next undo/redo action, so removing the action would
142 likely prevent the next undo/redo action from working
143 properly.
144
145 Parameters
146 ----------
147 action : instance of UndoAction
148 A previously registered UndoAction instance.
149 """
150 self._remove(self.undo_stack, action, delete_history)
151 self._remove(self.redo_stack, action, delete_history)
152 self._update_ui()
153
154 def clear(self):
155 """Clear both undo and redo stacks.
156 """
157 self.undo_stack.clear()
158 self.redo_stack.clear()
159 self._update_ui()
160
161 def top_undo_name(self):
162 """Return name for top undo action, or None if stack is empty.
163 """
164 return self._name(self.undo_stack)
165
166 def top_redo_name(self):
167 """Return name for top redo action, or None if stack is empty.
168 """
169 return self._name(self.redo_stack)
170
171 def undo(self, silent=True):
172 """Execute top undo action. Normally, if no undo action is
173 available, nothing happens. If "silent" is False, an IndexError
174 is raised for accessing invalid stack location.
175 """
176 try:
177 inst = self._pop(self.undo_stack)
178 except IndexError:
179 if not silent:
180 raise
181 else:
182 return
183 from .errors import UserError
184 try:
185 inst.undo()
186 except UserError:
187 raise
188 except Exception as e:
189 self.session.logger.report_exception("undo failed: %s" % str(e))
190 self.redo_stack.clear()
191 else:
192 if inst.can_redo:
193 self._push(self.redo_stack, inst)
194 else:
195 self.redo_stack.clear()
196 self._update_ui()
197
198 def redo(self, silent=True):
199 """Execute top redo action. Normally, if no redo action is
200 available, nothing happens. If "silent" is False, an IndexError
201 is raised for accessing invalid stack location.
202 """
203 try:
204 inst = self._pop(self.redo_stack)
205 except IndexError:
206 if not silent:
207 raise
208 else:
209 return
210 from .errors import UserError
211 try:
212 inst.redo()
213 except UserError:
214 raise
215 except Exception as e:
216 self.session.logger.report_exception("redo failed: %s" % str(e))
217 else:
218 self._push(self.undo_stack, inst)
219 self._update_ui()
220
221 def set_depth(self, depth):
222 """Set the maximum depth for the undo and redo stacks.
223
224 Parameter
225 ---------
226 depth : int
227 Maximum depth for stacks. Values <= 0 means unlimited.
228 """
229 if depth < 0:
230 depth = 0
231 self.max_depth = depth
232 self._trim(self.undo_stack)
233 self._trim(self.redo_stack)
234
235 # State methods
236
237 def take_snapshot(self, session, flags):
238 return {"version":1, "max_depth":self.max_depth}
239
240 @classmethod
241 def restore_snapshot(cls, session, data):
242 return cls(session, max_depth=data["max_depth"])
243
244 def reset_state(self, session):
245 """Reset state to data-less state"""
246 self.clear()
247
248 # Internal methods
249
250 def _trim(self, stack):
251 if self.max_depth > 0:
252 while len(stack) > self.max_depth:
253 stack.pop(0)
254
255 def _push(self, stack, inst):
256 stack.append(inst)
257 self._trim(stack)
258
259 def _pop(self, stack):
260 return stack.pop()
261
262 def _remove(self, stack, action, delete_history):
263 try:
264 n = stack.index(action)
265 except ValueError:
266 pass
267 else:
268 if delete_history:
269 del stack[:n+1]
270 else:
271 del stack[n]
272
273 def _name(self, stack):
274 try:
275 return stack[-1].name
276 except IndexError:
277 return None
278
279 def _update_ui(self):
280 session = self._session()
281 if session is None:
282 return
283 try:
284 f = session.ui.update_undo
285 except AttributeError:
286 pass
287 else:
288 f(self)
289
290
291class UndoAction:
292 """An instance holding the name for a pair of undo/redo callbacks.
293
294 Attributes
295 ----------
296 name : str
297 Name for the pair of undo/redo callbacks that changes
298 session between start and end states.
299 can_redo : boolean
300 Whether this instance supports redoing an action after
301 undoing it.
302 """
303
304 def __init__(self, name, can_redo=True):
305 self.name = name
306 self.can_redo = can_redo
307
308 def undo(self):
309 """Undo an action.
310 """
311 raise NotImplementedError("undo")
312
313 def redo(self):
314 """Redo an action.
315 """
316 raise NotImplementedError("redo")
317
318
319class UndoState(UndoAction):
320 """An instance that stores tuples of (owner,
321 attribute name, old values, new values) and uses the
322 information to undo/redo actions. 'owner' may be
323 a simple instance or an ordered container such as
324 a list or an 'atomic.molarray.Collection' instance.
325
326 Attributes
327 ----------
328 name : str
329 can_redo : boolean
330 Inherited from UndoAction.
331 state : list
332 List of (owner, attribute, old, new, options) tuples that
333 have been added to the action.
334 """
335
336 _valid_options = ["A", "M", "MA", "MK", "S"]
337
338 def __init__(self, name, can_redo=True):
339 super().__init__(name, can_redo)
340 self.state = []
341
342 def add(self, owner, attribute, old_value, new_value, option="A", *,
343 deleted_check=lambda obj: hasattr(obj, 'deleted') and obj.deleted):
344 """Add another tuple of (owner, attribute, old_value, new_value,
345 option) to the undo action state.
346
347 Parameters
348 ----------
349 owner : instance
350 An instance or a container of instances. If owner
351 is a container, then undo/redo callbacks will check
352 to make sure that old_value has the same number of
353 elements.
354 attribute : string
355 Name of attribute whose value changes with undo/redo.
356 old_value : object
357 Value for attribute after undo.
358 If owner is a container, then old_value should be
359 a container of values with the same number of elements.
360 Otherwise, any value is acceptable.
361 new_value : object
362 Value for attribute after redo.
363 Even if owner is a container, new_value may be a
364 simple value, in which case all elements in the
365 owner container will receive the same attribute value.
366 option : string
367 Option specifying how the attribute and values are
368 used for updating state. If option is "A" (default),
369 the attribute is changed to the value using setattr.
370 If option is "M", the attribute is assumed to be callable
371 with a single argument of the value. If option is "MA",
372 the attribute is called with the values as its argument
373 list, i.e., attribute(*value). If option is "MK", the
374 attribute iscalled with the values as keywords, i.e.,
375 attribute(**value). If option is "S" then owner is a sequence
376 and old and new values are sequences of the same length
377 and setattr is used to set each element of the owner sequence
378 to the corresponding element of the value sequence.
379 """
380 if option not in self._valid_options:
381 raise ValueError("invalid UndoState option: %s" % option)
382 self.state.append((owner, attribute, old_value, new_value, option, deleted_check))
383
384 def undo(self):
385 """Undo action (set owner attributes to old values).
386 """
387 self._consistency_check()
388 for owner, attribute, old_value, new_value, option, deleted_check in reversed(self.state):
389 self._update_owner(owner, attribute, old_value, option, deleted_check)
390
391 def redo(self):
392 """Redo action (set owner attributes to new values).
393 """
394 self._consistency_check()
395 for owner, attribute, old_value, new_value, option, deleted_check in self.state:
396 self._update_owner(owner, attribute, new_value, option, deleted_check)
397
398 def _consistency_check(self):
399 for owner, attribute, old_value, new_value, option, deleted_check in self.state:
400 try:
401 owner_length = len(owner)
402 except TypeError:
403 # Not a container, so move on
404 continue
405 else:
406 # Is a container, old_value must be the same length
407 try:
408 value_length = len(old_value)
409 except TypeError:
410 value_length = 1
411 if value_length != owner_length:
412 from .errors import UserError
413 raise UserError("Undo failed, probably because "
414 "structures have been modified.")
415
416 def _update_owner(self, owner, attribute, value, option, deleted_check):
417 if option != "S":
418 if deleted_check(owner):
419 return
420 if option == "A":
421 setattr(owner, attribute, value)
422 elif option == "M":
423 getattr(owner, attribute)(value)
424 elif option == "MK":
425 getattr(owner, attribute)(**value)
426 elif option == "MA":
427 getattr(owner, attribute)(*value)
428 elif option == "S":
429 for e,v in zip(owner, value):
430 if deleted_check(e):
431 continue
432 setattr(e, attribute, v)
433
434
435class UndoHandler(metaclass=abc.ABCMeta):
436 """An instance that intercepts undo registration
437 requests. For example, multiple undo actions may
438 be aggregated into a single undo action; or
439 undo actions may be blocked and replaced with
440 a more efficient undo mechanism.
441 """
442
443 def __init__(self, mgr, name):
444 """Initialize undo handler.
445
446 Parameters
447 ----------
448 mgr : instance of Undo
449 Undo manager for which this undo handler was created.
450 name : str
451 Name of undo action to register
452 """
453 self.name = name
454 self.mgr = mgr
455
456 def __enter__(self):
457 self.mgr.register_push(self)
458 return self
459
460 def __exit__(self, exc_type, exc_value, exc_traceback):
461 self.mgr.register_pop()
462 if not exc_type:
463 self.finish()
464
465 @abc.abstractmethod
466 def register(self, action):
467 """Register undo/redo actions.
468
469 Parameters
470 ----------
471 action : instance of UndoAction
472 Action that can change session between "before"
473 and "after" states.
474
475 Returns
476 -------
477 The registered action.
478 """
479 pass
480
481 @abc.abstractmethod
482 def finish(self):
483 """Finish processing intercepted registration requests."""
484 pass
485
486
487class UndoAggregateHandler(UndoHandler):
488 """An instance that intercepts undo registration
489 requests and aggregates them into a single undo action.
490 """
491
492 def __init__(self, mgr, name):
493 super().__init__(mgr, name)
494 self.actions = []
495
496 def register(self, action):
497 self.actions.append(action)
498
499 def finish(self):
500 a = UndoAggregateAction(self.name, self.actions)
501 self.mgr.register(a)
502
503
504class UndoAggregateAction(UndoAction):
505 """An instance that executes a list of UndoAction
506 instances as a group."""
507
508 def __init__(self, name, actions):
509 can_redo = all([a.can_redo for a in actions])
510 super().__init__(name, can_redo=can_redo)
511 self.actions = actions
512
513 def undo(self):
514 for a in reversed(self.actions):
515 a.undo()
516
517 def redo(self):
518 for a in self.actions:
519 a.redo()
520
521
522class UndoBlockHandler(UndoHandler):
523 """An instance that intercepts undo registration
524 requests and discards them.
525 """
526
527 def register(self, action):
528 pass
529
530 def finish(self):
531 pass