summaryrefslogtreecommitdiff
path: root/cad/src/foundation/undo_manager.py
blob: d3e6e333f54672d9add9092b70258acf87adf2a9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
# 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