Text widget undo/redo

Tom Wilkason - Many folks have requested a undo/redo mechanism for the Tk text widget over the years. An excellent one was implemented by Jean-Luc Fontaine (I think, correct this if wrong). I modified it slightly to support undo reset and not collide with Itcl class keyword. Below is the script made as a package. At the bottom is a short demo on how to hook it in. - TFW


GWM I thought a useful tool would be a general undo/redo mechanism for an interface with many widgets. Here it is Undo and Redo undoable widgets.


In Tcl/Tk 8.4 it's all done for you with the -undo flag.

Text widget undo/redo limitations and enhancements


Bryan Oakley's supertext widget has undo capabilities built in. Its behavior is slightly different than the below code, in that the supertext can also undo "undone" text (it is modeled after emacs). Contrast that to the code below which uses a separate action to undo undone text (ie: redo). As a side effect, the supertext version lets you undo absolutely everything, but the undo/redo mechanism doesn't. Also, the code below implements "undo reset", which was unfortunately left out of the supertext widget (though it's easy to add).

Some may prefer one behavior over the other. Plus, the supertext widget is a complete widget rather than a set of procs to attach to a plain widget, though it would probably be easy to extract the undo code. That might make for a nice project for someone...

The supertext widget can be found here: http://www1.clearlight.com/~oakley/tcl/

It's interesting to note that both solutions are approximately the same number of lines of code -- mid-200's sans comments -- though supertext has more comments :-)


 ;# *************************************************************************
 ;# * File   : undoer.tcl
 ;# * Purpose: Implement an undo/redo facility for a text widget
 ;# *
 ;# * Author : Tom Wilkason lifted from code by Jean-Luc Fontaine
 ;#
 ;# * Dated  : 9/3/2000
 ;# *
 ;# *************************************************************************
 set RH {
   Revision History:
   -----------------
   $Revision: 1.8 $
   $Log: 1333,v $
   Revision 1.8  2006-09-07 18:00:05  jcw
   1333-1157640938-84.92.246.238

   Revision 1.5  2004/09/09 06:00:19  jcw
   1333-1094647723-vince,62.244.183.98

 }

 if {![info exists classNewId]} {
     # work around object creation between multiple include of this file problem
     set classNewId 0
 }
 ;##
 ;# Call this to get a new undoer for some text widget
 ;# e.g. UnDonew textUndoer .text.widget
 ;#
 proc UnDonew {className args} {
     # calls the constructor for the class with optional arguments
     # and returns a unique object identifier independent of the class name

     global classNewId
     # use local variable for id for new can be called recursively
     set id [incr classNewId]
     if {[llength [info procs ${className}:$className]]>0} {
         # avoid catch to track errors
         eval ${className}:$className $id $args
     }
     return $id
 }

 proc UnDodelete {className id} {
     # calls the destructor for the class and UnDodelete all the object data members

     if {[llength [info procs ${className}:~$className]]>0} {
         # avoid catch to track errors
         catch {${className}:~$className $id}
     }
     global $className
     # and UnDodelete all this object array members if any (assume that they were stored as $className($id,memberName))
     foreach name [array names $className "$id,*"] {
         unset ${className}($name)
     }
 }

 proc udLifo:udLifo {id {size 2147483647}} {
     global udLifo
     set udLifo($id,maximumSize) $size
     udLifo:empty $id
 }

 proc udLifo:push {id data} {
     global udLifo saveTextMsg
     set saveTextMsg 1
     udLifo:tidyUp $id
     if {$udLifo($id,size)>=$udLifo($id,maximumSize)} {
         unset udLifo($id,data,$udLifo($id,first))
         incr udLifo($id,first)
         incr udLifo($id,size) -1
     }
     set udLifo($id,data,[incr udLifo($id,last)]) $data
     incr udLifo($id,size)
 }

 proc udLifo:pop {id} {
     global udLifo saveTextMsg
     set saveTextMsg 1
     udLifo:tidyUp $id
     if {$udLifo($id,last)<$udLifo($id,first)} {
         error "udLifo($id) pop error, empty"
     }
     # delay unsetting popped data to improve performance by avoiding a data copy
     set udLifo($id,unset) $udLifo($id,last)
     incr udLifo($id,last) -1
     incr udLifo($id,size) -1
     return $udLifo($id,data,$udLifo($id,unset))
 }

 proc udLifo:tidyUp {id} {
     global udLifo
     if {[info exists udLifo($id,unset)]} {
         unset udLifo($id,data,$udLifo($id,unset))
         unset udLifo($id,unset)
     }
 }

 proc udLifo:empty {id} {
     global udLifo
     udLifo:tidyUp $id
     foreach name [array names udLifo $id,data,*] {
         unset udLifo($name)
     }
     set udLifo($id,size) 0
     set udLifo($id,first) 0
     set udLifo($id,last) -1
 }

 proc textUndoer:textUndoer {id widget {depth 2147483647}} {
     global textUndoer

     if {[string compare [winfo class $widget] Text]!=0} {
         error "textUndoer error: widget $widget is not a text widget"
     }
     set textUndoer($id,widget) $widget
     set textUndoer($id,originalBindingTags) [bindtags $widget]
     bindtags $widget [concat $textUndoer($id,originalBindingTags) UndoBindings($id)]

     bind UndoBindings($id) <Control-u> "textUndoer:undo $id"

     # self destruct automatically when text widget is gone
     bind UndoBindings($id) <Destroy> "UnDodelete textUndoer $id"

     # rename widget command
     rename $widget [set textUndoer($id,originalCommand) textUndoer:original$widget]
     # and intercept modifying instructions before calling original command
     proc $widget {args} "textUndoer:checkpoint $id \$args;
       global search_count;
       eval $textUndoer($id,originalCommand) \$args"

     set textUndoer($id,commandStack) [UnDonew udLifo $depth]
     set textUndoer($id,cursorStack) [UnDonew udLifo $depth]
     #lee
     textRedoer:textRedoer $id $widget $depth
 }

 proc textUndoer:~textUndoer {id} {
     global textUndoer

     bindtags $textUndoer($id,widget) $textUndoer($id,originalBindingTags)
     rename $textUndoer($id,widget) ""
     rename $textUndoer($id,originalCommand) $textUndoer($id,widget)
     UnDodelete udLifo $textUndoer($id,commandStack)
     UnDodelete udLifo $textUndoer($id,cursorStack)
     #lee
     textRedoer:~textRedoer $id
 }

 proc textUndoer:checkpoint {id arguments} {
     global textUndoer textRedoer

     # do nothing if non modifying command
     if {[string compare [lindex $arguments 0] insert]==0} {
         textUndoer:processInsertion $id [lrange $arguments 1 end]
         if {$textRedoer($id,redo) == 0} {
            textRedoer:reset $id
         }
     }
     if {[string compare [lindex $arguments 0] delete]==0} {
         textUndoer:processDeletion $id [lrange $arguments 1 end]
         if {$textRedoer($id,redo) == 0} {
            textRedoer:reset $id
         }
     }
 }

 proc textUndoer:processInsertion {id arguments} {
     global textUndoer

     set number [llength $arguments]
     set length 0
     # calculate total insertion length while skipping tags in arguments
     for {set index 1} {$index<$number} {incr index 2} {
         incr length [string length [lindex $arguments $index]]
     }
     if {$length>0} {
         set index [$textUndoer($id,originalCommand) index [lindex $arguments 0]]
         udLifo:push $textUndoer($id,commandStack) "delete $index $index+${length}c"
         udLifo:push $textUndoer($id,cursorStack) [$textUndoer($id,originalCommand) index insert]
     }
 }

 proc textUndoer:processDeletion {id arguments} {
     global textUndoer

     set command $textUndoer($id,originalCommand)
     udLifo:push $textUndoer($id,cursorStack) [$command index insert]

     set start [$command index [lindex $arguments 0]]
     if {[llength $arguments]>1} {
         udLifo:push $textUndoer($id,commandStack) "insert $start [list [$command get $start [lindex $arguments 1]]]"
     } else {
         udLifo:push $textUndoer($id,commandStack) "insert $start [list [$command get $start]]"
     }
 }

 proc textUndoer:undo {id} {
     global textUndoer

     if {[catch {set cursor [udLifo:pop $textUndoer($id,cursorStack)]}]} {
         return
     }

     if {[catch {set popArgs [udLifo:pop $textUndoer($id,commandStack)]}]} {
         return
     }
     textRedoer:checkpoint $id $popArgs

     eval $textUndoer($id,originalCommand) $popArgs
     # now restore cursor position
     $textUndoer($id,originalCommand) mark set insert $cursor
     # make sure insertion point can be seen
     $textUndoer($id,originalCommand) see insert
 }


 proc textUndoer:reset {id} {
     global textUndoer
     udLifo:empty $textUndoer($id,commandStack)
     udLifo:empty $textUndoer($id,cursorStack)
 }

 #########################################################################
 proc textRedoer:textRedoer {id widget {depth 2147483647}} {
     global textRedoer
     if {[string compare [winfo class $widget] Text]!=0} {
         error "textRedoer error: widget $widget is not a text widget"
     }
     set textRedoer($id,commandStack) [UnDonew udLifo $depth]
     set textRedoer($id,cursorStack) [UnDonew udLifo $depth]
     set textRedoer($id,redo) 0
 }

 proc textRedoer:~textRedoer {id} {
     global textRedoer
     UnDodelete udLifo $textRedoer($id,commandStack)
     UnDodelete udLifo $textRedoer($id,cursorStack)
 }


 proc textRedoer:checkpoint {id arguments} {
     global textUndoer textRedoer
     # do nothing if non modifying command
     if {[string compare [lindex $arguments 0] insert]==0} {
         textRedoer:processInsertion $id [lrange $arguments 1 end]
     }
     if {[string compare [lindex $arguments 0] delete]==0} {
         textRedoer:processDeletion $id [lrange $arguments 1 end]
     }
 }

 proc textRedoer:processInsertion {id arguments} {
     global textUndoer textRedoer
     set number [llength $arguments]
     set length 0
     # calculate total insertion length while skipping tags in arguments
     for {set index 1} {$index<$number} {incr index 2} {
         incr length [string length [lindex $arguments $index]]
     }
     if {$length>0} {
         set index [$textUndoer($id,originalCommand) index [lindex $arguments 0]]
         udLifo:push $textRedoer($id,commandStack) "delete $index $index+${length}c"
         udLifo:push $textRedoer($id,cursorStack) [$textUndoer($id,originalCommand) index insert]
     }
 }

 proc textRedoer:processDeletion {id arguments} {
     global textUndoer textRedoer
     set command $textUndoer($id,originalCommand)
     udLifo:push $textRedoer($id,cursorStack) [$command index insert]

     set start [$command index [lindex $arguments 0]]
     if {[llength $arguments]>1} {
         udLifo:push $textRedoer($id,commandStack) "insert $start [list [$command get $start [lindex $arguments 1]]]"
     } else {
         udLifo:push $textRedoer($id,commandStack) "insert $start [list [$command get $start]]"
     }
 }
 proc textRedoer:redo {id} {
     global textUndoer textRedoer
     if {[catch {set cursor [udLifo:pop $textRedoer($id,cursorStack)]}]} {
         return
     }
     set textRedoer($id,redo) 1
     set popArgs [udLifo:pop $textRedoer($id,commandStack)]
     textUndoer:checkpoint $id $popArgs
     eval $textUndoer($id,originalCommand) $popArgs
     set textRedoer($id,redo) 0
     # now restore cursor position
     $textUndoer($id,originalCommand) mark set insert $cursor
     # make sure insertion point can be seen
     $textUndoer($id,originalCommand) see insert
 }

 ;##
 ;# Call this to reset the stacks, for example after reading a file in
 ;#
 proc textRedoer:reset {id} {
     global textRedoer
     udLifo:empty $textRedoer($id,commandStack)
     udLifo:empty $textRedoer($id,cursorStack)
 }
 package provide Undoer 1.0
 ;##
 ;# Create two text widgets, each with their own undo
 ;#
 proc textUndoer:demo {} {
       package require Undoer     ;# This implements the undo stuff
       ;# Couple of extra keys for undo/redo
       toplevel .top
       pack [text .top.text1 -width 80 -height 10] -expand true -fill both
       pack [text .top.text2 -width 80 -height 10] -expand true -fill both
       set undo_id1 [UnDonew textUndoer .top.text1]
       set undo_id2 [UnDonew textUndoer .top.text2]
       bind .top.text1 <Control-z> [list textUndoer:undo $undo_id1]
       bind .top.text2 <Control-z> [list textUndoer:undo $undo_id2]
       bind .top.text1 <Control-y> [list textRedoer:redo $undo_id1]
       bind .top.text2 <Control-y> [list textRedoer:redo $undo_id2]
 }

Category GUI - Category String Processing