# Copyright 2005-2008 Nanorex, Inc. See LICENSE file for details. """ undo_manager.py - own and manage an UndoArchive, feeding it info about user-command events (such as when to make checkpoints and how to describe the diffs generated by user commands), and package the undo/redo ops it offers into a reasonable form for supporting a UI. @author: Bruce @version: $Id$ @copyright: 2005-2008 Nanorex, Inc. See LICENSE file for details. [060117 -- for current status see undo_archive.py module docstring] """ from utilities.debug import register_debug_menu_command_maker from utilities.debug import print_compact_traceback, print_compact_stack from utilities import debug_flags from platform_dependent.PlatformDependent import is_macintosh from foundation.undo_archive import AssyUndoArchive import foundation.undo_archive as undo_archive # for debug_undo2; # could move that to a debug flags module; not urgent from utilities.constants import noop from utilities.prefs_constants import undoAutomaticCheckpoints_prefs_key import foundation.env as env from utilities.Log import greenmsg, redmsg ##, orangemsg import time class UndoManager: """ [abstract class] [060117 addendum: this docstring is mostly obsolete or nim] Own and manage an undo-archive, in such a way as to provide undo/redo operations and a current-undo-point within the archive [addendum 060117: undo point might be in model objects or archive, not here, and since objects can't be in more than one archive or have more than one cur state (at present), this doesn't matter much] on top of state-holding objects which would otherwise not have these undo/redo ops (though they must have the ability to track changes and/or support scanning of their state). Assume that no other UndoManager or UndoArchive is tracking the same state-holding objects (i.e. we own them for undo-related purposes). #e future: Perhaps also delegate all command-method-calls to the state-holding objects... but for now, tolerate external code directly calling command methods on our state-holding objects, just receiving begin/end calls related to those commands and their subroutines or calling event-handlers, checkpoint calls from same, and undo/redo command callbacks. """ pass try: _last_autocp # don't change it when we reload this module except NameError: _last_autocp = True # used only for history messages class AssyUndoManager(UndoManager): """ An UndoManager specialized for handling the state held by an assy (an instance of class assembly). """ active = True #060223 changed this to True, since False mainly means it died, not that it's being initialized [060223] _undo_manager_initialized = False #060223 def __init__(self, assy, menus = ()): # called from assy.__init__ """ Do what can be done early in assy.__init__; caller must also (subsequently) call init1 and either _initial_checkpoint or (preferred) clear_undo_stack. @type assy: assembly.assembly @warning: callers concerned with performance should heed the warning in the docstring of clear_undo_stack about when to first call it. """ # assy owns the state whose changes we'll be managing... # [semiobs cmt:] should it have same undo-interface as eg chunks do?? self._current_main_menu_ops = {} self.assy = assy self.menus = menus return def init1(self): #e might be merged into end of __init__ """ Do what we might do in __init__ except that it might be too early during assy.__init__ then (see also _initial_checkpoint) """ assy = self.assy self.archive = AssyUndoArchive(assy) ## assy._u_archive = self.archive ####@@@@ still safe in 060117 stub code?? [guess 060223: not needed anymore ###@@@] # [obs??] this is how model objects in assy find something to report changes to (typically in their __init__ methods); # we do it here (not in caller) since its name and value are private to our API for model objects to report changes ## self.archive.subscribe_to_checkpoints( self.remake_UI_menuitems ) ## self.remake_UI_menuitems() # so it runs for initial checkpoint and disables menu items, etc if is_macintosh(): win = assy.w from PyQt4.Qt import Qt win.editRedoAction.setShortcut(Qt.CTRL+Qt.SHIFT+Qt.Key_Z) # set up incorrectly (for Mac) as "Ctrl+Y" # note: long before 060414 this is probably no longer needed # (since now done in gui.WhatsThisText_for_MainWindow.py), # but it's safe and can be left in as a backup. # exercise the debug-only old pref (deprecated to use it): self.auto_checkpoint_pref() # exercise this, so it shows up in the debug-prefs submenu right away # (fixes bug in which the pref didn't show up until the first undoable change was made) [060125] # now look at the official pref for initial state of autocheckpointing [060314] ## done later -- set_initial_AutoCheckpointing_enabled( ... ) return def _initial_checkpoint(self): #bruce 060223; not much happens until this is called (order is __init__, init1, _initial_checkpoint) """ Only called from self.clear_undo_stack(). """ set_initial_AutoCheckpointing_enabled( True ) # might have to be True for initial_checkpoint; do no UI effects or history msg; kluge that the flag is a global [060314] self.archive.initial_checkpoint() ## self.connect_or_disconnect_menu_signals(True) self.remake_UI_menuitems() # try to fix bug 1387 [060126] self.active = True # redundant env.command_segment_subscribers.append( self._in_event_loop_changed ) self._undo_manager_initialized = True ## redundant call (bug); i hope this is the right one to remove: self.archive.initial_checkpoint() # make sure the UI reflects the current pref for auto-checkpointing [060314] # (in practice this happens at startup and after File->Open); # only emit history message if it's different than it was last time this session, # or different than True the first time global _last_autocp autocp = env.prefs[undoAutomaticCheckpoints_prefs_key] update_UI = True print_to_history = (_last_autocp != autocp) _last_autocp = -1 # if there's an exception, then *always* print it next time around set_initial_AutoCheckpointing_enabled( autocp, update_UI = update_UI, print_to_history = print_to_history) _last_autocp = autocp # only print it if different, next time return def deinit(self): self.active = False ## self.connect_or_disconnect_menu_signals(False) # and effectively destroy self... [060126 precaution; not thought through] self.archive.destroy() self._current_main_menu_ops = {} self.assy = self.menus = None #e more?? return # this is useless, since we have to keep them always up to date for sake of accel keys and toolbuttons [060126] ## def connect_or_disconnect_menu_signals(self, connectQ): # this is a noop as of 060126 ## win = self.assy.w ## if connectQ: ## method = win.connect ## else: ## method = win.disconnect ## for menu in self.menus: ## method( menu, SIGNAL("aboutToShow()"), self.remake_UI_menuitems ) ####k ## pass ## return def clear_undo_stack(self, *args, **kws): #bruce 080229 revised docstring """ Intialize self if necessary, and make an initial checkpoint, discarding whatever undo archive data is recorded before that (if any). This can be used by our client to complete our initialization and define the earliest state which an Undo can get back to. (It is the preferred way for external code to do that.) And, it can be used later to redefine that point, making all earlier states inaccessible (as a user op for reducing RAM consumption). @note: calling this several times in the same user op is allowed, and leaves the state the same as if this had only been called the last of those times. @warning: the first time this is called, it scans and copies all currently reachable undoable state *twice*. All subsequent times, it does this only once. This means it should be called as soon as the client assy is fully initialized (when it is almost empty of undoable state), even if it will always be called again soon thereafter, after some initial (potentially large) data has been added to the assy. Otherwise, that second call will be the one which scans its state twice, and will take twice as long as necessary. """ # note: this is now callable from a debug menu / other command, # as of 060301 (experimental) if not self._undo_manager_initialized: self._initial_checkpoint() # have to do this here, not in archive.clear_undo_stack return self.archive.clear_undo_stack(*args, **kws) def menu_cmd_checkpoint(self): # no longer callable from UI as of 060301, and not recently reviewed for safety [060301 comment] self.checkpoint( cptype = 'user_explicit' ) def make_manual_checkpoint(self): #060312 """ #doc; called from editMakeCheckpoint, presumably only when autocheckpointing is disabled """ self.checkpoint( cptype = 'manual', merge_with_future = False ) # temporary comment 060312: this might be enough, once it sets up for remake_UI_menuitems return __begin_retval = None ###k this will be used when we're created by a cmd like file open... i guess grabbing pref then is best... def _in_event_loop_changed(self, beginflag, infodict, tracker): # 060127; 060321 added infodict to API "[this bound method will be added to env.command_segment_subscribers so as to be told when ..." # infodict is info about the nature of the stack change, passed from the tracker [bruce 060321 for bug 1440 et al] # this makes "report all checkpoints" useless -- too many null ones. # maybe i should make it only report if state changes or cmdname passed... if not self.active: self.__begin_retval = False #k probably doesn't matter return True # unsubscribe # print beginflag, len(tracker.stack) # typical: True 1; False 0 if 1: #bruce 060321 for bug 1440: we need to not do checkpoints in some cases. Not sure if this is correct re __begin_retval; # if not, either clean it up for that or pass the flag into the checkpoint routine to have it not really do the checkpoint # (which might turn out better for other reasons anyway, like tracking proper cmdnames for changes). ##e pushed = infodict.get('pushed') popped = infodict.get('popped') # zero or one of these exists, and is the op_run just pushed or popped from the stack if pushed is not None: typeflag = pushed.typeflag # entering this guy elif popped is not None: typeflag = popped.typeflag # leaving this guy (entering vs leaving doesn't matter for now) else: typeflag = '' # does this ever happen? (probably not) want_cp = (typeflag != 'beginrec') if not want_cp: if 0 and env.debug(): print "debug: skipping cp as we enter or leave recursive event processing" return # this might be problematic, see above comment [tho it seems to work for now, for Minimize All anyway]; # if it ever is, then instead of returning here, we'll pass want_cp to checkpoint routines below if beginflag: self.__begin_retval = self.undo_checkpoint_before_command() ###e grab cmdname guess from top op_run i.e. from begin_op? yes for debugging; doesn't matter in the end though. else: if self.__begin_retval is None: # print "self.__begin_retval is None" # not a bug, will be normal ... happens with file open (as expected) self.__begin_retval = self.auto_checkpoint_pref() self.undo_checkpoint_after_command( self.__begin_retval ) self.__begin_retval = False # should not matter return def checkpoint(self, *args, **kws): # Note, as of 060127 this is called *much* more frequently than before (for every signal->slot to a python slot); # we will need to optimize it when state hasn't changed. ###@@@ global _AutoCheckpointing_enabled, _disable_checkpoints res = None if not _disable_checkpoints: ###e are there any exceptions to this, like for initial cps?? (for open file in extrude) opts = dict(merge_with_future = not _AutoCheckpointing_enabled) # i.e., when not auto-checkpointing and when caller doesn't override, # we'll ask archive.checkpoint to (efficiently) merge changes so far with upcoming changes # (but to still cause real changes to trash redo stack, and to still record enough info # to allow us to properly remake_UI_menuitems) opts.update(kws) # we'll pass it differently from the manual checkpoint maker... ##e res = self.archive.checkpoint( *args, **opts ) self.remake_UI_menuitems() # needed here for toolbuttons and accel keys; not called for initial cp during self.archive init # (though for menu items themselves, the aboutToShow signal would be sufficient) return res # maybe no retval, this is just a precaution def auto_checkpoint_pref(self): ##e should remove calls to this, inline them as True return True # this is obsolete -- it's not the same as the checkmark item now in the edit menu! [bruce 060309] ## return debug_pref('undo: auto-checkpointing? (slow)', Choice_boolean_True, #bruce 060302 changed default to True, added ':' ## prefs_key = 'A7/undo/auto-checkpointing', ## non_debug = True) def undo_checkpoint_before_command(self, cmdname = ""): """ ###doc [returns a value which should be passed to undo_checkpoint_after_command; we make no guarantees at all about what type of value that is, whether it's boolean true, etc] """ #e should this be renamed begin_cmd_checkpoint() or begin_command_checkpoint() like I sometimes think it's called? # recheck the pref every time auto_checkpointing = self.auto_checkpoint_pref() # (this is obs, only True is supported, as of long before 060323) if not auto_checkpointing: return False # (everything before this point must be kept fast) cmdname2 = cmdname or "command" if undo_archive.debug_undo2: env.history.message("debug_undo2: begin_cmd_checkpoint for %r" % (cmdname2,)) # this will get fancier, use cmdname, worry about being fast when no diffs, merging ops, redundant calls in one cmd, etc: self.checkpoint( cptype = 'begin_cmd', cmdname_for_debug = cmdname ) if cmdname: self.archive.current_command_info(cmdname = cmdname) #060126 return True # this code should be passed to the matching undo_checkpoint_after_command (#e could make it fancier) def undo_checkpoint_after_command(self, begin_retval): assert begin_retval in [False, True], "begin_retval should not be %r" % (begin_retval,) if begin_retval: # this means [as of 060123] that debug pref for undo checkpointing is enabled if undo_archive.debug_undo2: env.history.message(" debug_undo2: end_cmd_checkpoint") # this will get fancier, use cmdname, worry about being fast when no diffs, merging ops, redundant calls in one cmd, etc: self.checkpoint( cptype = 'end_cmd' ) pass return # == def node_departing_assy(self, node, assy): #bruce 060315; # revised 060330 to make it almost a noop, since implem was obsolete and it caused bug 1797 #bruce 080219 making this a debug print only, since it happens with dna updater # (probably a bug) but exception may be causing further bugs; also adding a message. # Now I have a theory about the bug's cause: if this happens in a closed assy, # deinit has set self.assy to None. To repeat, open and close a dna file with dna updater # off, then turn dna updater on. Now this should cause the "bug (harmless?)" print below. #bruce 080314 update: that does happen, so that print is useless and verbose, # so disable it for now. Retain the other ones. if assy is None or node is None: print "\n*** BUG: node_departing_assy(%r, %r, %r) sees assy or node is None" % \ (self, node, assy) return if self.assy is None: # this will happen for now when the conditions that caused today's bug reoccur, # until we fix the dna updater to never run inside a closed assy (desirable) # [bruce 080219] if 0: #bruce 080314 print "\nbug (harmless?): node_departing_assy(%r, %r, %r), but " \ "self.assy is None (happens when self's file is closed)" % \ (self, node, assy) return if not (assy is self.assy): print "\n*** BUG: " \ "node_departing_assy(%r, %r, %r) sees wrong self.assy = %r" % \ (self, node, assy, self.assy) # assy is self.assy has to be true (given that neither is None), # since we were accessed as assy.undo_manager. return # == def current_command_info(self, *args, **kws): self.archive.current_command_info(*args, **kws) def undo_redo_ops(self): # copied code below [dup code is in undo_manager_older.py, not in cvs] # the following value for warn_when_change_indicators_seem_wrong is a kluge # (wrong in principle but probably safe, not entirely sure it's correct) [060309] # (note, same value was hardcoded inside that method before bruce 071025; # see comment there about when I see the warnings; it's known that it gives # false warnings if we pass True when _AutoCheckpointing_enabled is false): ops = self.archive.find_undoredos( warn_when_change_indicators_seem_wrong = _AutoCheckpointing_enabled ) # state_version - now held inside UndoArchive.last_cp (might be wrong) ###@@@ # [what the heck does that comment mean? bruce 071025 Q] undos = [] redos = [] d1 = {'Undo':undos, 'Redo':redos} for op in ops: optype = op.optype() d1[optype].append(op) # sort ops by type ## done in the subr: redos = filter( lambda redo: not redo.destroyed, redos) #060309 since destroyed ones are not yet unstored # remove obsolete redo ops if redos: lis = [ (redo.cps[1].cp_counter, redo) for redo in redos ] lis.sort() only_redo = lis[-1][1] redos = [only_redo] for obs_redo in lis[:-1]: if undo_archive.debug_undo2 or env.debug(): #060309 adding 'or env.debug()' since this should never happen once clear_redo_stack() is implemented in archive print "obsolete redo:", obs_redo pass #e discard it permanently? ####@@@@ return undos, redos def undo_cmds_menuspec(self, widget): # WARNING: this is not being maintained, it's just a development draft. # So far it lacks merging and history message and perhaps win_update and update_select_mode. [060227 comment] """ Return a menu_spec for including undo-related commands in a popup menu (to be shown in the given widget, tho i don't know why the widget could matter) """ del widget archive = self.archive # copied code below [dup code is in undo_manager_older.py, not in cvs] res = [] #bruce 060301 removing this one, since it hasn't been reviewed in awhile so it might cause bugs, # and maybe it did cause one... ## res.append(( 'undo checkpoint (in RAM only)', self.menu_cmd_checkpoint )) #060301 try this one instead: res.append(( 'clear undo stack (experimental)', self.clear_undo_stack )) undos, redos = self.undo_redo_ops() ###e sort each list by some sort of time order (maybe of most recent use of the op in either direction??), and limit lengths # there are at most one per chunk per undoable attr... so for this test, show them all, don't bother with submenus if not undos: res.append(( "Nothing we can Undo", noop, 'disabled' )) ###e should figure out whether "Can't Undo XXX" or "Nothing to Undo" is more correct for op in undos + redos: # for now, we're not even including them unless as far as we know we can do them, so no role for "Can't Undo" unless none arch = archive # it's on purpose that op itself has no ref to model, so we have to pass it [obs cmt?] cmd = lambda _guard1_ = None, _guard2_ = None, arch = arch: arch.do_op(op) #k guards needed? (does qt pass args to menu cmds?) ## text = "%s %s" % (op.type, op.what()) text = op.menu_desc() res.append(( text , cmd )) if not redos: res.append(( "Nothing we can Redo", noop, 'disabled' )) return res def remake_UI_menuitems(self): #e this should also be called again if any undo-related preferences change ###@@@ #e see also: void QPopupMenu::aboutToShow () [signal], for how to know when to run this (when Edit menu is about to show); # to find the menu, no easy way (only way: monitor QAction::addedTo in a custom QAction subclass - not worth the trouble), # so just hardcode it as edit menu for now. We'll need to connect & disconnect this when created/finished, # and get passed the menu (or list of them) from the caller, which is I guess assy.__init__. if undo_archive.debug_undo2: print "debug_undo2: running remake_UI_menuitems (could be direct call or signal)" global _disable_UndoRedo disable_reasons = list(_disable_UndoRedo) # avoid bugs if it changes while this function runs (might never happen) if disable_reasons: undos, redos = [], [] # note: more code notices the same condition, below else: undos, redos = self.undo_redo_ops() win = self.assy.w undo_mitem = win.editUndoAction redo_mitem = win.editRedoAction for ops, action, optype in [(undos, undo_mitem, 'Undo'), (redos, redo_mitem, 'Redo')]: #e or could grab op.optype()? extra = "" if disable_reasons: try: why_not = str(disable_reasons[0][1]) # kluges: depends on list format, and its whymsgs being designed for this use except: why_not = "" extra += " (not permitted %s)" % why_not # why_not is e.g. "during drag" (nim) or "during Extrude" if undo_archive.debug_undo2: extra += " (%s)" % str(time.time()) # show when it's updated in the menu text (remove when works) ####@@@@ if ops: action.setEnabled(True) if not ( len(ops) == 1): #e there should always be just one for now #060212 changed to debug msg, since this assert failed (due to process_events?? undoing esp image delete) print_compact_stack("bug: more than one %s op found: " % optype) op = ops[0] op = self.wrap_op_with_merging_flags(op) #060127 text = op.menu_desc() + extra #060126 action.setText(text) fix_tooltip(action, text) # replace description, leave (accelkeys) alone (they contain unicode chars on Mac) self._current_main_menu_ops[optype] = op #e should store it into menu item if we can, I suppose op.you_have_been_offered() # make sure it doesn't change its mind about being a visible undoable op, even if it gets merged_with_future # (from lack of autocp) and turns out to contain no net changes # [bruce 060326 re bug 1733; probably only needed for Undo, not Redo] else: action.setEnabled(False) ## action.setText("Can't %s" % optype) # someday we might have to say "can't undo Cmdxxx" for certain cmds ## action.setText("Nothing to %s" % optype) text = "%s%s" % (optype, extra) action.setText(text) # for 061117 commit, look like it used to look, for the time being fix_tooltip(action, text) self._current_main_menu_ops[optype] = None pass #bruce 060319 for bug 1421 win.editUndoAction.setWhatsThis( win.editUndoText ) win.editRedoAction.setWhatsThis( win.editRedoText ) from foundation.whatsthis_utilities import refix_whatsthis_text_and_links ## if 0: ## # this works, but is overkill and is probably too slow, and prints huge numbers of console messages, like this: ## ## TypeError: invalid result type from MyWhatsThis.text() ## # (I bet I could fix the messages by modifying MyWhatsThis.text() to return "" (guess)) ## from foundation.whatsthis_utilities import fix_whatsthis_text_and_links ## fix_whatsthis_text_and_links( win) ## if 0: ## # this prints no console messages, but doesn't work! (for whatsthis on tool buttons or menu items) ## # guess [much later]: it fails to actually do anything to these actions! ## from foundation.whatsthis_utilities import fix_whatsthis_text_and_links ## fix_whatsthis_text_and_links( win.editUndoAction ) ## fix_whatsthis_text_and_links( win.editRedoAction ) ## # try menu objects? and toolbars? refix_whatsthis_text_and_links( ) ###@@@ predict: will fix toolbuttons but not menu items #060304 also disable/enable Clear Undo Stack action = win.editClearUndoStackAction text = "Clear Undo Stack" + '...' # workaround missing '...' (remove this when the .ui file is fixed) #e future: add an estimate of RAM to be cleared action.setText(text) fix_tooltip(action, text) enable_it = not not (undos or redos) action.setEnabled( enable_it ) return # # the kinds of things we can set on one of those actions include: # # self.setViewFitToWindowAction.setText(QtGui.QApplication.translate(self.__class__.__name__, "Fit to Window")) # self.setViewFitToWindowAction.setText(QtGui.QApplication.translate(self.__class__.__name__, "&Fit to Window")) # self.setViewFitToWindowAction.setToolTip(QtGui.QApplication.translate(self.__class__.__name__, "Fit to Window (Ctrl+F)")) # self.setViewFitToWindowAction.setShortcut(QtGui.QApplication.translate(self.__class__.__name__, "Ctrl+F")) # self.viewRightAction.setStatusTip(QtGui.QApplication.translate(self.__class__.__name__, "Right View")) # self.helpMouseControlsAction.setWhatsThis(QtGui.QApplication.translate(self.__class__.__name__, "Displays help for mouse controls")) def wrap_op_with_merging_flags(self, op, flags = None): #e will also accept merging-flag or -pref arguments """ Return a higher-level op based on the given op, but with the appropriate diff-merging flags wrapped around it. Applying this higher-level op will (in general) apply op, then apply more diffs which should be merged with it according to those merging flags (though in an optimized way, e.g. first collect and merge the LL diffs, then apply all at once). The higher-level op might also have a different menu_desc, etc. In principle, caller could pass flag args, and call us more than once with different flag args for the same op; in making the wrapped op we don't modify the passed op. """ #e first we supply our own defaults for flags return self.archive.wrap_op_with_merging_flags(op, flags = flags) # main menu items (their slots in MWsemantics forward to assy which forwards to here) def editUndo(self): ## env.history.message(orangemsg("Undo: (prototype)")) self.do_main_menu_op('Undo') def editRedo(self): ## env.history.message(orangemsg("Redo: (prototype)")) self.do_main_menu_op('Redo') def do_main_menu_op(self, optype): """ @note: optype should be Undo or Redo """ op_was_available = not not self._current_main_menu_ops.get(optype) global _disable_UndoRedo if _disable_UndoRedo: #060414 env.history.message(redmsg("%s is not permitted now (and this action was only offered due to a bug)" % optype)) return global _AutoCheckpointing_enabled disabled = not _AutoCheckpointing_enabled #060312 if disabled: _AutoCheckpointing_enabled = True # temporarily enable it, just during the Undo or Redo command self.checkpoint( cptype = "preUndo" ) # do a checkpoint with it enabled, so Undo or Redo can work normally. # Note: in theory this might change what commands are offered and maybe even cause the error message below to come out # (so we might want to revise it when disabled is true ##e), but I think that can only happen if there's a change_counter # bug, since the only way the enabled cp will see changes not seen by disabled one is if archive.update_before_checkpoint() # is first to set the change_counters (probably a bug); if this happens it might make Redo suddenly unavailable. ####e if optype is Redo, we could pass an option to above checkpoint to not destroy redo stack or make it inaccessible! # (such an option is nim) try: op = self._current_main_menu_ops.get(optype) if op: undo_xxx = op.menu_desc() # note: menu_desc includes history sernos env.history.message(u"%s" % undo_xxx) #e say Undoing rather than Undo in case more msgs?? ######@@@@@@ TEST u"%s" self.archive.do_op(op) self.assy.w.update_select_mode() #bruce 060227 try to fix bug 1576 self.assy.w.win_update() #bruce 060227 not positive this isn't called elsewhere, or how we got away without it if not else: if not disabled: print "no op to %r; not sure how this slot was called, since it should have been disabled" % optype env.history.message(redmsg("Nothing to %s (and it's a bug that its menu item or tool button was enabled)" % optype)) else: print "no op to %r; autocp disabled (so ops to offer were recomputed just now; before that, op_was_available = %r); "\ "see code comments for more info" % ( optype, op_was_available) if op_was_available: env.history.message(redmsg("Nothing to %s (possibly due to a bug)" % optype)) else: env.history.message(redmsg("Nothing to %s (and this action was only offered due to a bug)" % optype)) pass except: print_compact_traceback() env.history.message(redmsg("Bug in %s; see traceback in console" % optype)) if disabled: # better get the end-cp done now (since we might be relying on it for some reason -- I'm not sure) self.checkpoint( cptype = "postUndo" ) _AutoCheckpointing_enabled = False # re-disable return pass # end of class AssyUndoManager # == #e refile def fix_tooltip(qaction, text): #060126 """ Assuming qaction's tooltip looks like "command name (accel keys)" and might contain unicode in accel keys (as often happens on Mac due to symbols for Shift and Command modifier keys), replace command name with text, leave accel keys unchanged (saving result into actual tooltip). OR if the tooltip doesn't end with ')', just replace the entire thing with text, plus a space if text ends with ')' (to avoid a bug the next time -- not sure if that kluge will work). """ whole = unicode(qaction.toolTip()) # str() on this might have an exception try: #060304 improve the alg to permit parens in text to remain; assume last '( ' is the one before the accel keys; # also permit no accel keys if whole[-1] == ')': # has accel keys (reasonable assumption, not unbreakably certain) sep = u' (' parts = whole.split(sep) parts = [text, parts[-1]] whole = sep.join(parts) else: # has no accel keys whole = text if whole[-1] == ')': whole = whole + ' ' # kluge, explained in docstring pass # print "formed tooltip",`whole` # printing whole might have an exception, but printing `whole` is ok qaction.setToolTip(whole) # no need for __tr, I think? except: print_compact_traceback("exception in fix_tooltip(%r, %r): " % (qaction, text) ) return # == debugging code - invoke undo/redo from debug menu (only) in initial test implem def undo_cmds_maker(widget): ###e maybe this belongs in assy module itself?? clue: it knows the name of assy.undo_manager; otoh, should work from various widgets """ [widget is the widget in which the debug menu is being put up right now] """ #e in theory we use that widget's undo-chain... but in real life this won't even happen inside the debug menu, so nevermind. # for now just always use the assy's undo-chain. # hmm, how do we find the assy? well, ok, i'll use the widget. try: assy = widget.win.assy except: if debug_flags.atom_debug: return [('atom_debug: no undo in this widget', noop, 'disabled')] return [] ## if 'kluge' and not hasattr(assy, 'undo_manager'): ## assy.undo_manager = UndoManager(assy) #e needs review; might just be a devel kluge, or might be good if arg type is unciv mgr = assy.undo_manager #k should it be an attr like this, or a sep func? return mgr.undo_cmds_menuspec(widget) register_debug_menu_command_maker( "undo_cmds", undo_cmds_maker) # fyi: this runs once when the first assy is being created, but undo_cmds_maker runs every time the debug menu is put up. # == # some global private state (which probably ought to be undo manager instance vars) try: _editAutoCheckpointing_recursing except: _editAutoCheckpointing_recursing = False # only if we're not reloading -- otherwise, bug when setChecked calls MWsem slot which reloads else: if _editAutoCheckpointing_recursing and env.debug(): pass # print "note: _editAutoCheckpointing_recursing true during reload" # this happens! try: _AutoCheckpointing_enabled # on reload, use old value unchanged (since we often reload automatically during debugging) except: _AutoCheckpointing_enabled = True # this might be changed based on env.prefs whenever an undo_manager gets created [060314] # older comment about that, not fully obs: #e this might be revised to look at env.prefs sometime during app startup, # and to call editAutoCheckpointing (or some part of it) with the proper initial state; # the current code is designed, internally, for checkpointing to be enabled except # for certain intervals, so we might start out True and set this to False when # an undo_manager is created... we'll see; maybe it won't even (or mainly or only) be a global? [060309] def _set_AutoCheckpointing_enabled( enabled): global _AutoCheckpointing_enabled _AutoCheckpointing_enabled = not not enabled return def set_initial_AutoCheckpointing_enabled( enabled, update_UI = False, print_to_history = False ): """ set autocheckpointing (perhaps for internal use), doing UI updates only if asked, emitting history only if asked """ if update_UI: win = env.mainwindow() else: # kluge: win is not needed in this case, # and I'm not sure it's not too early win = None editAutoCheckpointing(win, enabled, update_UI = update_UI, print_to_history = print_to_history) # we have the same API as this method except for the option default values return def editAutoCheckpointing(win, enabled, update_UI = True, print_to_history = True): """ This is called from MWsemantics.editAutoCheckpointing, which is documented as "Slot for enabling/disabling automatic checkpointing." It sets _AutoCheckpointing_enabled and optionally updates the UI and prints history. It's also called by undo_manager initialization code, so its UI effects are optional, but that's private -- all such use should go through set_initial_AutoCheckpointing_enabled. Another private fact: win is only used if update_UI is true. """ # Note: this would be in undo_UI except for its call from this file. # Probably the two global flags it affects should really be instance # variables in the undo manager object. If that's done, then maybe it # could move back to undo_UI if it could find the undo manager via win. # For that reason I might (later) put a version of it there which just # delegates to this one. [bruce 071026 comment] # # Note: the reason this doesn't need to call something in assy.undo_manager # (when used to implement the user change of the checkmark menu item for # this flag) is that it's called within a slot in the mainwindow, # which is itself wrapped by a begin_checkpoint and end_checkpoint, # one or the other of which will act as a real checkpoint, unaffected by # this flag. [bruce 060312 comment] global _editAutoCheckpointing_recursing # TODO: make this a um instance variable?? if _editAutoCheckpointing_recursing: # note: this happens! see comment where we set it below, for why. ## print "debug: _editAutoCheckpointing_recursing, returning as noop" return _set_AutoCheckpointing_enabled( enabled) # TODO: make this a um instance variable?? if print_to_history: if enabled: msg_short = "Autocheckpointing enabled" msg_long = "Autocheckpointing enabled -- each operation will be undoable" else: msg_short = "Autocheckpointing disabled" msg_long = "Autocheckpointing disabled -- only explicit Undo checkpoints are kept" #k length ok? env.history.statusbar_msg( msg_long) env.history.message( greenmsg(msg_short)) if update_UI: # Inserting/removing editMakeCheckpointAction from the standardToolBar # keeps the toolbar the correct length (i.e. no empty space at the end). # BTW, it is ok to call removeAction() even when the action doesn't live # in the toolbar. Mark 2008-03-01 win.standardToolBar.removeAction(win.editMakeCheckpointAction) if not enabled: win.standardToolBar.insertAction(win.editUndoAction, win.editMakeCheckpointAction) # This is needed to hide/show editMakeCheckpointAction in the "Edit" menu. win.editMakeCheckpointAction.setVisible(not enabled) # this is only needed when the preference changed, not when the menu item slot is used: _editAutoCheckpointing_recursing = True try: win.editAutoCheckpointingAction.setChecked( enabled ) # warning: this recurses, via slot in MWsemantics [060314] finally: _editAutoCheckpointing_recursing = False return # == # API for temporarily disabling undo checkpoints and/or Undo/Redo commands [bruce 060414, to help mitigate bug 1625 et al] try: # Whether to disable Undo checkpoints temporarily, due the mode, or (nim) being inside a drag, etc _disable_checkpoints # on reload, use old value unchanged except: _disable_checkpoints = [] # list of (code,string) pairs, corresponding to reasons we're disabled, most recent last # (usually only the first one will be shown to user) try: # Whether to disable offering of Undo and Redo commands temporarily _disable_UndoRedo except: _disable_UndoRedo = [] def disable_undo_checkpoints(whycode, whymsg = ""): """ Disable all undo checkpoints from now until a corresponding reenable call (with the same whycode) is made. Intended for temporary internal uses, or for use during specific modes or brief UI actions (eg drags). WARNING: if nothing reenables them, they will remain disabled for the rest of the session. """ global _disable_checkpoints _disable_checkpoints = _do_whycode_disable( _disable_checkpoints, whycode, whymsg) return def disable_UndoRedo(whycode, whymsg = ""): """ Disable the Undo/Redo user commands from now until a corresponding reenable call (with the same whycode) is made. Intended for temporary internal uses, or for use during specific modes or brief UI actions (eg drags). WARNING: if nothing reenables them, they will remain disabled for the rest of the session. """ global _disable_UndoRedo _disable_UndoRedo = _do_whycode_disable( _disable_UndoRedo, whycode, whymsg) #e make note of need to update UI? I doubt we need this or have a good place to see the note. #e ideally this would be part of some uniform scheme to let things subscribe to changes to that list. return def reenable_undo_checkpoints(whycode): global _disable_checkpoints _disable_checkpoints = _do_whycode_reenable( _disable_checkpoints, whycode) def reenable_UndoRedo(whycode): global _disable_UndoRedo _disable_UndoRedo = _do_whycode_reenable( _disable_UndoRedo, whycode) def _do_whycode_disable( reasons_list_val, whycode, whymsg): """ [private helper function for maintaining whycode,whymsg lists] """ res = filter( lambda (code, msg): code != whycode , reasons_list_val ) # zap items with same whycode if len(res) < len(reasons_list_val) and env.debug(): print_compact_stack("debug fyi: redundant call of _do_whycode_disable, whycode %r msg %r, preserved reasons %r" % \ ( whycode, whymsg, res ) ) res.append( (whycode, whymsg) ) # put the changed one at the end (#k good??) return res def _do_whycode_reenable( reasons_list_val, whycode): """ [private helper function for maintaining whycode,whymsg lists] """ res = filter( lambda (code, msg): code != whycode , reasons_list_val ) # zap items with same whycode if len(res) == len(reasons_list_val) and env.debug(): print_compact_stack("debug fyi: redundant call of _do_whycode_reenable, whycode %r, remaining reasons %r" % \ ( whycode, res ) ) return res # == # TODO: probably make these assy methods. And if it's true # that there's similar code elsewhere, merge it into them first. # [bruce 071025 comment] def external_begin_cmd_checkpoint(assy, cmdname = "command"): #bruce 060324 """ Call this with the assy you're modifying, or None. Pass whatever it returns to external_end_cmd_checkpoint later. """ # As of 060328 we use other code similar to these funcs in both GLPane.py and TreeWidget.py... # worry: those (on mouse press/release) might interleave with these cmenu versions, depending on details of popup menus! # But the worry is unfounded: If a click puts up the menu, no interleaving; if mouse stays down until command is done, # then the outer press/release wraps (properly) the inner cmenu cps, unless outer release is missing (absorbed by popup menu # as it apparently is), and then, releases's end-cp never occurs but cmenu's was enough. This might affect cmdname, but # checkpointing should be fine. Note, it doesn't do begin_op / end_op, which could be an issue someday. ###@@@ if assy is not None: begin_retval = assy.undo_checkpoint_before_command(cmdname) return True, begin_retval # don't bother to include assy -- it doesn't really matter if it corresponds return False, None def external_end_cmd_checkpoint(assy, begin_retval): if begin_retval is None: # for convenience of callers worried about e.g. "release without press" return flag, begin_retval = begin_retval if assy is not None: # seems best to do this even if flag is False... but begin_retval is required to be True or False... # which means this had a bug for a few days (passing None as begin_retval then) and was apparently # not happening with flag false (or a debug print would have complained about the None)... # so I should eliminate begin_retval someday, but for now, I need to fix that bug somehow. How about "or False". # warning: this is a poorly-considered kluge. ###@@@ [bruce 060328] assy.undo_checkpoint_after_command(begin_retval or False) return # == def wrap_callable_for_undo(func, cmdname = "menu command"): #bruce 060324; moved from widgets.py to undo_manager.py, bruce 080203 """ Wrap a callable object "func" so that begin and end undo checkpoints are performed for it; be sure the returned object can safely be called at any time in the future (even if various things have changed in the meantime). @warning: If a reference needs to be kept to the returned object, that's the caller's responsibility. """ # We use 3 guards in case PyQt passes up to 3 unwanted args to menu # callables, and we don't trust Python to preserve func and cmdname except # in the lambda default values. (Both of these precautions are generally # needed or you can have bugs, when returning lambdas out of the defining # scope of local variables they reference.) res = (lambda _g1_ = None, _g2_ = None, _g3_ = None, func = func, cmdname = cmdname: _do_callable_for_undo(func, cmdname) ) return res def _do_callable_for_undo(func, cmdname): #bruce 060324 """ [private helper for wrap_callable_for_undo] """ assy = env.mainwindow().assy # this needs review once we support multiple open files; # caller might have to pass it in begin_retval = external_begin_cmd_checkpoint(assy, cmdname = cmdname) try: res = func() # note: I don't know whether res matters to Qt except: print_compact_traceback("exception in menu command %r ignored: " % cmdname) # REVIEW this message -- is func always a menu command? res = None assy = env.mainwindow().assy # (since it might have changed! (in theory)) external_end_cmd_checkpoint(assy, begin_retval) return res # end