Tim Peters writes:

I'm still unsure, but couldn't stand the virtual event trickery so tried a
different sin (adding undo_block_start/stop methods to the Text instance in
EditorWindow.py).  Like it or not, it's efficient and works <wink>.  Better
idea?

Give the attached a whirl.  Even if you hate the implementation, I think
you'll like the results.  Think I caught all the "block edit" cmds,
including Format Paragraph, plus subtler ones involving smart indents and
backspacing.
diff --git a/Tools/idle/AutoIndent.py b/Tools/idle/AutoIndent.py
index 4de7ad0..386490d 100644
--- a/Tools/idle/AutoIndent.py
+++ b/Tools/idle/AutoIndent.py
@@ -142,20 +142,24 @@
             last = text.index("sel.last")
         except TclError:
             first = last = None
-        if first and last:
-            if index2line(first) != index2line(last):
-                return self.indent_region_event(event)
-            text.delete(first, last)
-            text.mark_set("insert", first)
-        if self.prefertabs:
-            pad = '\t'
-        else:
-            n = len(self.spaceindent)
-            prefix = text.get("insert linestart", "insert")
-            pad = ' ' * (n - len(prefix) % n)
-        text.insert("insert", pad)
-        text.see("insert")
-        return "break"
+        text.undo_block_start()
+        try:
+            if first and last:
+                if index2line(first) != index2line(last):
+                    return self.indent_region_event(event)
+                text.delete(first, last)
+                text.mark_set("insert", first)
+            if self.prefertabs:
+                pad = '\t'
+            else:
+                n = len(self.spaceindent)
+                prefix = text.get("insert linestart", "insert")
+                pad = ' ' * (n - len(prefix) % n)
+            text.insert("insert", pad)
+            text.see("insert")
+            return "break"
+        finally:
+            text.undo_block_stop()
 
     def newline_and_indent_event(self, event):
         text = self.text
@@ -164,28 +168,32 @@
             last = text.index("sel.last")
         except TclError:
             first = last = None
-        if first and last:
-            text.delete(first, last)
-            text.mark_set("insert", first)
-        line = text.get("insert linestart", "insert")
-        i, n = 0, len(line)
-        while i < n and line[i] in " \t":
-            i = i+1
-        indent = line[:i]
-        # strip trailing whitespace
-        i = 0
-        while line and line[-1] in " \t":
-            line = line[:-1]
-            i = i + 1
-        if i:
-            text.delete("insert - %d chars" % i, "insert")
-        text.insert("insert", "\n" + indent)
-        if _is_block_opener(line):
-            self.smart_indent_event(event)
-        elif indent and _is_block_closer(line) and line[-1:] != "\\":
-            self.smart_backspace_event(event)
-        text.see("insert")
-        return "break"
+        text.undo_block_start()
+        try:
+            if first and last:
+                text.delete(first, last)
+                text.mark_set("insert", first)
+            line = text.get("insert linestart", "insert")
+            i, n = 0, len(line)
+            while i < n and line[i] in " \t":
+                i = i+1
+            indent = line[:i]
+            # strip trailing whitespace
+            i = 0
+            while line and line[-1] in " \t":
+                line = line[:-1]
+                i = i + 1
+            if i:
+                text.delete("insert - %d chars" % i, "insert")
+            text.insert("insert", "\n" + indent)
+            if _is_block_opener(line):
+                self.smart_indent_event(event)
+            elif indent and _is_block_closer(line) and line[-1:] != "\\":
+                self.smart_backspace_event(event)
+            text.see("insert")
+            return "break"
+        finally:
+            text.undo_block_stop()
 
     auto_indent = newline_and_indent_event
 
@@ -275,8 +283,10 @@
             return
         text.tag_remove("sel", "1.0", "end")
         text.mark_set("insert", head)
+        text.undo_block_start()
         text.delete(head, tail)
         text.insert(head, newchars)
+        text.undo_block_stop()
         text.tag_add("sel", head, "insert")
 
 def tabify(line, tabsize=8):
diff --git a/Tools/idle/EditorWindow.py b/Tools/idle/EditorWindow.py
index 2ae69cf..8b6a0b4 100644
--- a/Tools/idle/EditorWindow.py
+++ b/Tools/idle/EditorWindow.py
@@ -147,6 +147,8 @@
         self.undo = undo = self.UndoDelegator(); per.insertfilter(undo)
         self.io = io = self.IOBinding(self)
 
+        text.undo_block_start = undo.undo_block_start
+        text.undo_block_stop = undo.undo_block_stop
         undo.set_saved_change_hook(self.saved_change_hook)
         io.set_filename_change_hook(self.filename_change_hook)
 
diff --git a/Tools/idle/FormatParagraph.py b/Tools/idle/FormatParagraph.py
index f8827e7..e17f54c 100644
--- a/Tools/idle/FormatParagraph.py
+++ b/Tools/idle/FormatParagraph.py
@@ -37,8 +37,10 @@
         text.tag_remove("sel", "1.0", "end")
         if newdata != data:
             text.mark_set("insert", first)
+            text.undo_block_start()
             text.delete(first, last)
             text.insert(first, newdata)
+            text.undo_block_stop()
         else:
             text.mark_set("insert", last)
         text.see("insert")
diff --git a/Tools/idle/UndoDelegator.py b/Tools/idle/UndoDelegator.py
index 39c8e63..ec7af81 100644
--- a/Tools/idle/UndoDelegator.py
+++ b/Tools/idle/UndoDelegator.py
@@ -49,6 +49,7 @@
         self.was_saved = -1
         self.pointer = 0
         self.undolist = []
+        self.undoblock = 0  # or a CommandSequence instance
         self.set_saved(1)
 
     def set_saved(self, flag):
@@ -82,8 +83,40 @@
     def delete(self, index1, index2=None):
         self.addcmd(DeleteCommand(index1, index2))
 
-    def addcmd(self, cmd):
-        cmd.do(self.delegate)
+    # Clients should call undo_block_start() and undo_block_stop()
+    # around a sequence of editing cmds to be treated as a unit by
+    # undo & redo.  Nested matching calls are OK, and the inner calls
+    # then act like nops.  OK too if no editing cmds, or only one
+    # editing cmd, is issued in between:  if no cmds, the whole
+    # sequence has no effect; and if only one cmd, that cmd is entered
+    # directly into the undo list, as if undo_block_xxx hadn't been
+    # called.  The intent of all that is to make this scheme easy
+    # to use:  all the client has to worry about is making sure each
+    # _start() call is matched by a _stop() call.
+
+    def undo_block_start(self):
+        if self.undoblock == 0:
+            self.undoblock = CommandSequence()
+        self.undoblock.bump_depth()
+
+    def undo_block_stop(self):
+        if self.undoblock.bump_depth(-1) == 0:
+            cmd = self.undoblock
+            self.undoblock = 0
+            if len(cmd) > 0:
+                if len(cmd) == 1:
+                    # no need to wrap a single cmd
+                    cmd = cmd.getcmd(0)
+                # this blk of cmds, or single cmd, has already
+                # been done, so don't execute it again
+                self.addcmd(cmd, 0)
+
+    def addcmd(self, cmd, execute=1):
+        if execute:
+            cmd.do(self.delegate)
+        if self.undoblock != 0:
+            self.undoblock.append(cmd)
+            return
         if self.can_merge and self.pointer > 0:
             lastcmd = self.undolist[self.pointer-1]
             if lastcmd.merge(cmd):
@@ -264,6 +297,44 @@
         text.see('insert')
         ##sys.__stderr__.write("undo: %s\n" % self)
 
+class CommandSequence(Command):
+
+    # Wrapper for a sequence of undoable cmds to be undone/redone
+    # as a unit
+
+    def __init__(self):
+        self.cmds = []
+        self.depth = 0
+
+    def __repr__(self):
+        s = self.__class__.__name__
+        strs = []
+        for cmd in self.cmds:
+            strs.append("    " + `cmd`)
+        return s + "(\n" + string.join(strs, ",\n") + "\n)"
+
+    def __len__(self):
+        return len(self.cmds)
+
+    def append(self, cmd):
+        self.cmds.append(cmd)
+
+    def getcmd(self, i):
+        return self.cmds[i]
+
+    def redo(self, text):
+        for cmd in self.cmds:
+            cmd.redo(text)
+
+    def undo(self, text):
+        cmds = self.cmds[:]
+        cmds.reverse()
+        for cmd in cmds:
+            cmd.undo(text)
+
+    def bump_depth(self, incr=1):
+        self.depth = self.depth + incr
+        return self.depth
 
 def main():
     from Percolator import Percolator