Undo and Redo undoable widgets

Undoing events. Almost all modern programs use an undoing mechanism - so here is one for Tk.

Use the Undo - Redo or Control-z, Control-y to undo/redo the events you perform on the widgets in the interface created. Press any button, menu item, enter text in the text widget, use the spinbox move the sliders with either left drag or right-click - all these options are undoable.

The new 'class' is the undoable - its history of events can be regressed to an earlier state or progressed to recover a state which had been undone. The key is the "-kind XXX" where XXX is any Tk widget; undo operations are provided for entry, scale, spinbox widgets. Buttons (checkbox, radio button) may require some user code. Any widget can have an undocommand - a script that is exercised after a widget's state has been undone.

Each implementation of a widget may require special undo operations - for example if we have 30 objects, all red or yellow then a button changes the yellow objects to red then the undo for this button is NOT change all the red objects (back to) yellow, but change back all the objects which were previously changed. The implementation below provides an '-undocommand' option for each undoable widget; this must be created by the user to provide an exact undo mechanism. Typically each widget would be extended by the user to include a history of the effects of the widget, and then when its 'undo' command is called the last history event would be undone.

proc comment text {}

proc dbg args { tk_messageBox -message "$args" ; return 1}

  proc testop {s S P V} { ;# this is the validation routine for entry e2
        puts "testop validating $s $S -> $P type $V"
        return 1 ;# or 0 if not OK!
  }

  proc undomenu {menubtn menu} {
        puts "Undoing $menubtn $menu [$menubtn cget -text]"
  }
  proc scalevalue {w v} {
        puts "scale $w set to value $v"
  }
  proc testUndo {} { ;# create an interface using "undoable" Tk items.
        console show ;# debug and check infomration appears here via puts commands.

        pack [frame .fr]
        pack [button .fr.undo -text Undo -command "undolast"] -side left
        pack [button .fr.redo -text Redo -command "redolast"] -side left
        bind . <Control-z> "undolast"
        bind . <Control-y> "redolast"
        pack [button .fr.undos -text "Show Undos" -command "showundos"] -side left
        pack [button .fr.redos -text "Show Redos" -command "showredos"] -side left
        pack [button .fr.exit -text Exit -command "exit 1"] -side left
        set testfr [frame .tests]
        pack $testfr
        # default type is an entry.
        pack [undoable $testfr.e1  -validate focus] -side top
        pack [undoable $testfr.e2 -kind entry -vcmd "testop %s %S %P %V"  -validate all] -side top
        pack [undoable $testfr.sp2 -kind spinbox -from 1 -to 12 \
                -vcmd "testop %s %S %P %V" -validate all] -side top

        pack [undoable $testfr.b1 -kind menubutton -bg red -menu $testfr.b1.menu1 -textvariable menval -bd 2 -relief raised] -side top
        undoable $testfr.b1.menu1 -kind menu -tearoff 0 -undocommand "undomenu $testfr.b1 $testfr.b1.menu1"
        foreach num {One Two Three} {        $testfr.b1.menu1 add command -label $num -command "puts \"menu item $num chosen\"; set ::menval $num"}
        $testfr.b1.menu1 add cascade -label "Roman" -menu $testfr.b1.menu1.cascade
        undoable $testfr.b1.menu1.cascade -kind menu -tearoff 0 -undocommand "puts \"Undoing $testfr.b1.menu1.cascade\""
        foreach num {I II III} {        $testfr.b1.menu1.cascade add command -label $num -command "puts \"menu item $num chosen\"; set ::menval $num"}
        set ::menval Number

        pack [undoable $testfr.bmen1 -kind menubutton  -menu $testfr.bmen1.menu1 -text "Dropdown Menu" -bd 2 -relief raised] -side top
        undoable $testfr.bmen1.menu1 -kind menu -tearoff 0 -undocommand "undomenu $testfr.bmen1 $testfr.bmen1.menu1"
        foreach num {One Two Three} {        $testfr.bmen1.menu1 add command -label $num -command "puts \"menu item $num chosen\""}
        $testfr.bmen1.menu1 add cascade -label "Roman" -menu $testfr.bmen1.menu1.cascade
        undoable $testfr.bmen1.menu1.cascade -kind menu -tearoff 0 -undocommand "puts \"Undoing $testfr.bmen1.menu1.cascade\""
        foreach num {I II III} {        $testfr.bmen1.menu1.cascade add command -label $num -command "puts \"menu item $num chosen\""}
         
        pack [undoable $testfr.b2 -kind button -text "Press Me" -command "puts {Button pressed}" \
                -undocommand "puts {Button press undone}" ] -side top
        pack [undoable $testfr.b3 -kind checkbutton -text "Check Me" -command "puts {checkButton pressed}" \
                -undocommand "puts {checkButton press undone}" ] -side top
        pack [undoable $testfr.b4 -kind radiobutton -text "Check Me" -command "puts {radioButton pressed}" \
                -undocommand "puts {radioButton press undone}" ] -side top
        pack [undoable $testfr.e3 -kind entry \
                -vcmd "puts {validating %s %S -> %P type %V}; return 1" \
                -validate all] -side top
        pack [undoable $testfr.s1 -kind scale -from 100 -to 200 \
                -command "scalevalue $testfr.s1" -orient horiz] -side top
        pack [undoable $testfr.s2 -kind scale -from 0 -to 120 \
                -command "scalevalue $testfr.s2" -orient horiz] -side top
        pack [undoable $testfr.lb2 -kind listbox -command "puts {Listbox Selected}" \
                -undocommand "puts {Listbox undone}" ] -side top
        foreach fruit [list "Apple" "Peach" "Pear" "Banana" "Strawberry" \
                                "Lingonberry" "Blackberry" "Damson" "Plum"]  {
                $testfr.lb2 insert end $fruit 
        }
        pack [undoable $testfr.tx1 -kind text -undo true ] -side top
  }

 comment {
This is the important part - a list of undoable events, and a redo list (in case you undid
an event and realised it should not have been undone).

Each undo event has two parts (it is a list) - the record of old values needing to be undone,
and the redo event (a copy of the original event).

The class undoable is a [polymorphism%|%polymorphic] or Template class which records the actions
of any Tk widget and can 'undo' these actions. 

Records events in all undoable widgets as a list, then you can undo the list 
(and possibly redo). All Tk widgets (except canvas?) can be used as undoables.
"Polymorphism" means that the undoable widget can inherit from any of the Tk widgets.
It should also be able to represent an Iwidget or BWidget.

Usually 'entry' 'menu' and 'scale' widgets won't need an undocommand as they call the 
return the value of the widget to its previous value, which calls the standard        
'item changed command' for the widget (which should cause all changes to be reset
as if the menu/entry/scale had been set manually). 

undoableCmd is the place where the undo events are coded and interpreted.
 }

  global undoings ;# list of undoable things - each is a 2 part list 
        # first the arguments to undo the operation;
        # then the arguments to redo the operation.
  set undoings {}
  global redoings ;# list of redoable things - copy of those undoings which have been undone.
  set redoings {}


  proc showundos {} { ;# display list of undo operations.
        global undoings
        foreach un $undoings { puts "Undo:: [lindex $un 0] ::redo:: [lindex $un 1]"}
  }
  proc showredos {} { ;# display list of redo operations.
        global redoings
        foreach un $redoings { puts "Redo:: [lindex $un 0] ::undo:: [lindex $un 1]"}
  }

  proc undolast {} { ;# undoes last undoable operation.
        global undoings
        if {[llength $undoings]>0} {
                set undothis [lindex [lindex $undoings end] 0]
                set widget [lindex $undothis 0]
                eval $widget undo $undothis
                global redoings

                lappend redoings [lindex $undoings end]
                set undoings [lrange $undoings 0 end-1]
        } else {
                puts "No more undoable events"
        }
  }

  proc redolast {} {
        global redoings
        if {[llength $redoings]>0} {
                set redothis [lindex $redoings end]
                set redocommand [lindex $redothis 1]
                set widget [lindex $redocommand 0]
                eval $widget undo $widget [lrange $redocommand 1 end]
                update idletasks
                set redoings [lrange $redoings 0 end-1]

                global undoings
                lappend undoings $redothis
        }
  }

  proc undoable {w args} { ;# an undoable is a widget
        # and allows new smooth shaped buttons.
        global $w.props ;# an array of options specific to the undoable 'class'
        # set by .this -<option> <value>
        array set $w.props {-kind entry -undoing 0 -undocommand "" -command "" oldvalue "0"}
        # define the option list and default values
        # kind is the type of Tk widget (entry, button, menu ection...)
        # undoing is true if we are in an undo operation (does not get put on the undoable list)
        # undocommand may be supplied for items such as buttons which may invoke complex operations
        #  and hence require a complex undo operation. 
        upvar #0 $w.props props ;# get local address for global array

        set baseArgs {} ;# list of arguments not specific to the class
        set options(-kind) entry
        set options(-vcmd) "return 1"
        # extract special arguments for w - command is special in that commands (from buttons etc)
        # need to record their actions to be undone
        foreach {opt val} $args {
                if {[array names $w.props $opt]!=""} { 
                        set options($opt) $val
                        set $w.props($opt) $val
                } else {        lappend baseArgs $opt $val }
        }
        # make the base widget
        eval $options(-kind) $w $baseArgs ;# create the "procedure" w

        interp hide {} $w
        # Install the alias:
        interp alias {} $w {} undoableCmd $w ;# undoableCmd processes sub-commands for undoable class
        switch [winfo class $w] {
                {Listbox} {
                        bind $w <ButtonPress-1> "$w Select %y"
                }
                {Text} {
                        bind $w <Enter> "$w TextSavepoint"
                        bind $w <Leave> "$w TextSavepoint"
                }
                default {}
        }
        foreach opt [array names options] {
                if {[$w isanoption $opt] != ""} {
                        switch -- $opt {
                                "-command" { ;# assemble complete validation command - saves history of behaviours
                                         set undocmd "$w undooptions ; $options($opt)"
                                        eval interp invokehidden {{}} $w configure "$opt" \"$undocmd\"
                                }
                                "-validatecommand" -
                                "-vcmd" { ;# assemble complete Entry validation command - saves history of behaviours
                                         set undocmd "$w undooptions %P ; $options($opt)"
                                        eval interp invokehidden {{}} $w configure "$opt" \"$undocmd\"
                                }
                                default {        $w configure $opt "$options($opt)" }
                        }
                } else { 
                        set $w.props($opt) $options($opt)
                }
        }
        return $w ;# the original object
  }

  proc undoableCmd {self cmd args} {
        global $self.props
        switch -- $cmd {
                configure {eval undoableConfigure $self $args}
                cget {eval undoableCget $self $args}
                {undooptions} { ;# save undooptions sufficient to undo and redo an action
                        if {![$self cget -undoing]} { ;# not in an undo so save event.
                                global  undoings ;# store state before and after event change.
                                set dodata [lindex $args end] ;# and record the redo event
                                switch [winfo class $self] {
                                        {Menu} { ;#  [file rootname $self] is the menu parent - button or cascade
                                                set menubutt [file rootname $self]
                                                while {[winfo class $menubutt]!="Menubutton"} {
                        # ascend tree to an actual menubutton.
                                                        set menubutt [file rootname $menubutt]
                                                }
                                                set tvar [$menubutt cget -text]
                                                set dodata [list $self $cmd $menubutt $args]
                                                set undodata [list $self $cmd $menubutt $tvar]
                                        }
                                        {Button} -
                                        {Checkbutton} -
                                        {Radiobutton} {
                                                set undodata [list $self $cmd $args]
                                                set dodata [list $self $cmd $args ]
                                        }
                                        {Entry} {
                                                set undodata [list $self [$self oldvalue] ]
                                                set dodata [list $self $args ]
                                        }
                                        {Scale} {
                                                set dodata [list $self [$self oldvalue] ]
                                                set undodata [list $self [$self cget oldvalue]]
                                                puts "Undo scale save $self [$self oldvalue] [$self cget oldvalue]"
                                        }
                                        {Listbox} {
                                                set undodata [list $self [$self curselection ] ]
                                                set dodata [list $self [lindex $args 0]]
                                        }
                                        {Text} { 
                        # NB this records undo state when mouse enters into text widget
                                                set undodata [list $self "[$self oldvalue]"]
                                                set dodata $undodata
                                        }
                                        {Spinbox} {
                                                set undodata [list $self [$self get ] ]
                                                set dodata [list $self $args]
                                        }
                                        default {
                                                set undodata [list $self "Dont know how to undo [winfo class $self]"]
                                        }
                                }
                                # foreach un $undoings { puts "Undo:: $un"}
                                lappend undoings [list $undodata $dodata] ;# and record the undo event
                        } ;# else { puts "In undo dont save event $cmd $args"}
                        set $self.props(oldvalue) [$self oldvalue] ;# saved for redo record.
                }
                {undo} { ;# the action invoked by an undo
                        set $self.props(-undoing) 1
                        switch [winfo class $self] {
                                 {Entry} {
                                        $self selection range 0 end                                 
                                        if {[$self selection present]} {                $self delete sel.first sel.last                }
                                        $self insert insert [lindex $args 1] ;# insert
                                }
                                {Menu} { ;# here we want to perform some undoing mechanism
                                        set tvar [ [lindex $args 2] cget -textvariable]
                                        if {$tvar !=""} { 
                                                global $tvar
                                                set $tvar [lindex $args 3] 
                                        }
                                        puts "menu $self undo [winfo class $self] event $args"
                                }
                                {Listbox} {
                                        $self selection clear 0 end
                                        if {[lindex $args 1]!=""} {
                                                $self selection set [lindex $args 1]
                                                puts "Undo [lindex $args 0] oldvalues [lindex $args 1] new [lindex $args 2]"
                                        }
                                }
                                {Checkbutton} -
                                {Radiobutton} -
                                {Button} { ;# these Tk items usually need their own undocommand
                                        puts "Undoing [winfo class $self] called $self"
                                }
                                {Scale} { ;# scale set should call its own -command option
                                        eval $self set [lindex $args 1]
                                }
                                {Spinbox} {
                                        eval $self set [lindex $args 1]
                                }
                                {Text} { ;# NB this reverts to state at time of mouse entering/leaving text widget
                                        $self delete 0.0 end
                                        $self insert end [lindex $args 1]
                                }
                                default {
                                        puts "? undo [winfo class $self] event $args"
                                }
                        }
                        set undoc [$self cget "-undocommand"]
                        if {$undoc!=""} { eval $undoc}
                        update idletasks ;# updates all the -vcmds etc before setting the undo flag
                        set $self.props(-undoing) 0 ;# start collecitng widget events for undoing again
                }
                  {Select} { ;# in Listbox - no automatic setting of current selection in Tk(!)
                        set y [$self nearest [lindex $args 0]]
                        $self undooptions $y
                        $self selection set $y
                        upvar #0 $self.props props 
                        eval $props(-command)
                  }
                {TextSavepoint} { ;# mouse has entered or left text - create an undo event
                        $self undooptions 
                }
                {oldvalue} { ;# for generic resetting - some classes save the old value
                        switch [winfo class $self] {
                                {Entry} -
                                {Scale} { return [$self get]}
                                {Text} { return [$self get 0.0 end]}
                                default { return ""}
                        }
                }

                {isanoption} { ;# check all the declared options of self for $args being valid.
                        foreach op [$self config] {
                                set n [lsearch $op $args]
                                if {$n>=0} { return $op}
                        }
                        return ""
                }
                {add} { ;# for type menu add an option (command etc) means add an undoable menu item
                        if {[$self isanoption $cmd] != ""} {
                                puts "$self Could not $cmd with $args - $self should be a menu item."
                        } else {
                                set undoargs [lindex $args 0]
                                set pog [lsearch $args "-label"]
                                if {$pog>0} {
                                        incr pog
                                        set pog [lindex $args $pog]
                                }
                                foreach {opt val} [lrange $args 1 end] {  ;# first arg arg[0] is command (or radiobutton, checkbutton, etc)
                                        switch -- $opt {
                                                {-command} {
                                                        lappend undoargs $opt "$self undooptions $pog  ; $val"
                                                }
                                                default {lappend undoargs $opt $val }
                                        }
                                }
                                set id [eval interp invokehidden {{}} $self $cmd $undoargs]
                        }
                }
                {default} { ;# use default $cmd to widget
                        eval interp invokehidden {{}} $self $cmd $args
                }
        }
  }

  proc undoableConfigure {self args} {
        # 3 scenarios:
        #
        # $args is empty -> return all options with their values
        # $args is one element -> return current values
        # $args is 2+ elements -> configure the options
        #puts "Config comd $self $cmd $args [llength $args]"
        global $self.props
        switch [llength $args] {
                0 { ;# return all options
                        set result [array names $self.props]
                        return $result
                }
                1 { ;# return argument values
                        if {[array names $w.props $opt]!=""} { lappend opts [$self cget $args]
                        } else { puts "No option $opt in $self specific arguments." }
                        return $opts
                }
                default { ;# >1 arg - an option and its value
                        # go through each option:
                        foreach {option value} [lrange $args 0 end] {
                                if {[array names $self.props $option]!=""} { ;# set global array element for special option.
                                        set $self.props($option) "$value"
                                } else {
                                        eval interp invokehidden {{}} $self configure $option $value
                                }
                        }
                        return {}
                }
        }
  }

  proc undoableCget {self args} {        ;# cget defaults done by the interp cget command
        upvar #0 $self.props props ;# get local address for global array
        if {[array names props $args ]!=""} {
                return $props($args)
        }
        return [uplevel 1 [list interp invokehidden {} $self cget $args]]
  }

  testUndo ;# call the test routine