Search code examples
c#.netvb.netalgorithmundo-redo

Undo/Redo implementation for an edit-control, based on stacks


I'm trying to implement a simple undo/redo mechanism (based on stacks) for some events of a textbox.

Before asking this, I've seen a lot of undo/redo implementations like these, but more or less they are incomplete and showing things that I already knew (on the other hand, the profesional way using rare interfaces scapes from my compehenssion, so I want to follow this stack-based way), because those examples more than a undo/redo example for edit-controls, are a push/pop examples for stacks, but a undo/redo is a little more than writting a method to pop the last item of a "undo stack" and another method to pop the last item of a "redo stack", because in some point while the user is interacting with the control, the stacks should be cleared/resetted.

I mean that in a real undo/redo mechanism for edit-controls, the "redo stack" should be cleared when the user undoes and the user makes a text modification in the control while the "undo stack" still contain items, so in that point there is nothing to redo because a change occured while undoing. I didn't seen any complete example of a undo-redo mechanism in this way bearing in mind how must act the undo/redo stacks when changes occur in the control.

I need help to properlly implement the logic of my undo/redo stacks, I started trying it by my own for days with a couple of trial and erros, but always escapes me some detail, because when I get one (undo or redo)stack to work properly, the other one stops working as expected undoing what it should not undo or redoing what it should not redo, so I discarded out (again) all the conditional logic that I written because my logic always is wrong, I should start from zero again with a proper conditional algorithm, I mean the proper conditionals to push or pop the stack items at the right moment.

Then, more than words or suggestions, I need a working code that can solve the problem with my algorithm, I need to complete the algorithm logic of the AddUndoRedoItem method in the code below, this is a specific question about this.

If I am missing a simpler solution following the same principles (a undo and redo stacks), I will accept that solution too.

In C# or Vb.Net, no matter.

PD: If because my poor English I didin't explained some thing prroperly and you are not totally sure of what kind of undo/redo I'am asking for, just I'm asking for a undo/redo as it sounds, just test the Ctrl+Z(undo) and Ctrl+Y(redo) keys in the Notepad while performing changes in the text undone or redone, see how it acts, that is a real undo/redo implementation, what I'm trying to reproduce with the stacks.


This is the current code:

Public Enum UndoRedoCommand As Integer
    Undo
    Redo
End Enum

Public Enum UndoRedoTextBoxEvent As Integer
    TextChanged
End Enum

Public NotInheritable Class UndoRedoTextBox

    Private ReadOnly undoStack As Stack(Of KeyValuePair(Of UndoRedoTextBoxEvent, Object))
    Private ReadOnly redoStack As Stack(Of KeyValuePair(Of UndoRedoTextBoxEvent, Object))

    Private lastCommand As UndoRedoCommand
    Private lastText As String

    Public ReadOnly Property Control As TextBox
        Get
            Return Me.controlB
        End Get
    End Property
    Private WithEvents controlB As TextBox

    Public ReadOnly Property CanUndo As Boolean
        Get
            Return (Me.undoStack.Count <> 0)
        End Get
    End Property

    Public ReadOnly Property CanRedo As Boolean
        Get
            Return (Me.redoStack.Count <> 0)
        End Get
    End Property

    Public ReadOnly Property IsUndoing As Boolean
        Get
            Return Me.isUndoingB
        End Get
    End Property
    Private isUndoingB As Boolean

    Public ReadOnly Property IsRedoing As Boolean
        Get
            Return Me.isRedoingB
        End Get
    End Property
    Private isRedoingB As Boolean

    Public Sub New(ByVal tb As TextBox)

        Me.undoStack = New Stack(Of KeyValuePair(Of UndoRedoTextBoxEvent, Object))
        Me.redoStack = New Stack(Of KeyValuePair(Of UndoRedoTextBoxEvent, Object))

        Me.controlB = tb
        Me.lastText = tb.Text

    End Sub

    Public Sub Undo()

        If (Me.CanUndo) Then
            Me.InternalUndoRedo(UndoRedoCommand.Undo)
        End If

    End Sub

    Public Sub Redo()

        If (Me.CanRedo) Then
            Me.InternalUndoRedo(UndoRedoCommand.Redo)
        End If

    End Sub

    ' Undoes or redoues.
    Private Sub InternalUndoRedo(ByVal command As UndoRedoCommand)

        Dim undoRedoItem As KeyValuePair(Of UndoRedoTextBoxEvent, Object) = Nothing
        Dim undoRedoEvent As UndoRedoTextBoxEvent
        Dim undoRedoValue As Object = Nothing

        Select Case command

            Case UndoRedoCommand.Undo
                Me.isUndoingB = True
                undoRedoItem = Me.undoStack.Pop
                Me.AddUndoRedoItem(UndoRedoCommand.Redo, UndoRedoTextBoxEvent.TextChanged, Me.lastText, undoRedoItem.Value)

            Case UndoRedoCommand.Redo
                Me.isRedoingB = True
                undoRedoItem = Me.redoStack.Pop
                Me.AddUndoRedoItem(UndoRedoCommand.Undo, UndoRedoTextBoxEvent.TextChanged, undoRedoItem.Value, Me.lastText)

        End Select

        undoRedoEvent = undoRedoItem.Key
        undoRedoValue = undoRedoItem.Value

        Select Case undoRedoEvent

            Case UndoRedoTextBoxEvent.TextChanged
                Me.controlB.Text = CStr(undoRedoValue)

        End Select

        Me.isUndoingB = False
        Me.isRedoingB = False

    End Sub

    Private Sub AddUndoRedoItem(ByVal command As UndoRedoCommand, ByVal [event] As UndoRedoTextBoxEvent,
                                ByVal data As Object, ByVal lastData As Object)

        Console.WriteLine()
        Console.WriteLine("command     :" & command.ToString)
        Console.WriteLine("last command:" & lastCommand.ToString)
        Console.WriteLine("can undo    :" & Me.CanUndo)
        Console.WriteLine("can redo    :" & Me.CanRedo)
        Console.WriteLine("is undoing  :" & Me.isUndoingB)
        Console.WriteLine("is redoing  :" & Me.isRedoingB)
        Console.WriteLine("data        :" & data.ToString)
        Console.WriteLine("last data   :" & lastData.ToString)

        Dim undoRedoData As Object = Nothing
        Me.lastCommand = command

        Select Case command

            Case UndoRedoCommand.Undo

                If (Me.isUndoingB) Then
                    Exit Select
                End If

                undoRedoData = lastData
                Me.undoStack.Push(New KeyValuePair(Of UndoRedoTextBoxEvent, Object)([event], undoRedoData))

            Case UndoRedoCommand.Redo

                If (Me.isRedoingB) Then
                    Exit Select
                End If

                undoRedoData = lastData
                Me.redoStack.Push(New KeyValuePair(Of UndoRedoTextBoxEvent, Object)([event], undoRedoData))

        End Select

    End Sub

    Private Sub TextBox_TextChanged(ByVal sender As Object, ByVal e As EventArgs) _
    Handles controlB.TextChanged

        Dim currentText As String = Me.controlB.Text

        If Not String.Equals(Me.lastText, currentText, StringComparison.Ordinal) Then

            Select Case Me.lastCommand

                Case UndoRedoCommand.Undo

                    Me.AddUndoRedoItem(UndoRedoCommand.Undo, UndoRedoTextBoxEvent.TextChanged, currentText, Me.lastText)

                Case UndoRedoCommand.Redo
                    Me.AddUndoRedoItem(UndoRedoCommand.Redo, UndoRedoTextBoxEvent.TextChanged, Me.lastText, currentText)

            End Select

            Me.lastText = currentText

        End If

    End Sub

End Class

Solution

  • Thanks to @Plutonix I solved it, I really didn't believe that a simple comment could help me to solve logic, but yes, I was making things more complicated than they really are.

    I still need to think about how to manage disposable objects, but more or less the base of the idea is finished and the code below is working as expected (at least for what I expect).

    These are the parts of the base undo/redo class for controls:

    Public Enum UndoRedoCommand As Integer
        Undo
        Redo
    End Enum
    
    Public Class UndoRedoItem
        Public Property [Event] As Integer
        Public Property LastValue As Object
        Public Property CurrentValue As Object
    End Class
    
    Public MustInherit Class UndoRedo(Of T As Control)
    
    #Region " Private Fields "
    
        Private ReadOnly undoStack As Stack(Of UndoRedoItem)
        Private ReadOnly redoStack As Stack(Of UndoRedoItem)
    
    #End Region
    
    #Region " Properties "
    
        Public ReadOnly Property Control As T
            Get
                Return Me.controlB
            End Get
        End Property
        Protected WithEvents controlB As T
    
        Public ReadOnly Property CanUndo As Boolean
            Get
                Return (Me.undoStack.Count <> 0)
            End Get
        End Property
    
        Public ReadOnly Property CanRedo As Boolean
            Get
                Return (Me.redoStack.Count <> 0)
            End Get
        End Property
    
        Public ReadOnly Property IsUndoing As Boolean
            Get
                Return Me.isUndoingB
            End Get
        End Property
        Private isUndoingB As Boolean
    
        Public ReadOnly Property IsRedoing As Boolean
            Get
                Return Me.isRedoingB
            End Get
        End Property
        Private isRedoingB As Boolean
    
    #End Region
    
    #Region " Constructors "
    
        Private Sub New()
        End Sub
    
        Public Sub New(ByVal ctrl As T)
    
            Me.undoStack = New Stack(Of UndoRedoItem)
            Me.redoStack = New Stack(Of UndoRedoItem)
    
            Me.controlB = ctrl
    
        End Sub
    
    #End Region
    
    #Region " Public Methods "
    
        Public Sub Undo()
    
            If (Me.CanUndo) Then
                Me.InternalUndoRedo(UndoRedoCommand.Undo)
            End If
    
        End Sub
    
        Public Sub Redo()
    
            If (Me.CanRedo) Then
                Me.InternalUndoRedo(UndoRedoCommand.Redo)
            End If
    
        End Sub
    
    #End Region
    
    #Region " Private Methods "
    
        Private Sub InternalUndoRedo(ByVal command As UndoRedoCommand)
    
            Dim undoRedoItem As UndoRedoItem = Nothing
    
            Select Case command
    
                Case UndoRedoCommand.Undo
                    Me.isUndoingB = True
                    undoRedoItem = Me.undoStack.Pop
                    Me.AddUndoRedoItem(UndoRedoCommand.Redo, undoRedoItem.Event, undoRedoItem.LastValue, undoRedoItem.CurrentValue)
    
                Case UndoRedoCommand.Redo
                    Me.isRedoingB = True
                    undoRedoItem = Me.redoStack.Pop
    
            End Select
    
            Me.DoUndo(undoRedoItem.Event, undoRedoItem.CurrentValue)
    
            Me.isUndoingB = False
            Me.isRedoingB = False
    
        End Sub
    
        Protected MustOverride Sub DoUndo(ByVal [event] As Integer, ByVal data As Object)
    
        Protected Sub AddUndoRedoItem(ByVal command As UndoRedoCommand,
                                      ByVal [event] As Integer,
                                      ByVal currentData As Object,
                                      ByVal lastData As Object)
    
            Dim undoRedoItem As New UndoRedoItem
            undoRedoItem.Event = [event]
    
            Select Case command
    
                Case UndoRedoCommand.Undo
    
                    If (Me.isUndoingB) Then
                        Exit Select
                    End If
    
                    If (Me.CanUndo) AndAlso (Me.CanRedo) AndAlso Not (Me.IsRedoing) Then
                        Me.redoStack.Clear()
                    End If
    
                    undoRedoItem.CurrentValue = lastData
                    undoRedoItem.LastValue = currentData
                    Me.undoStack.Push(undoRedoItem)
    
                Case UndoRedoCommand.Redo
    
                    If (Me.isRedoingB) Then
                        Exit Select
                    End If
    
                    undoRedoItem.CurrentValue = currentData
                    undoRedoItem.LastValue = lastData
                    Me.redoStack.Push(undoRedoItem)
    
            End Select
    
        End Sub
    
    #End Region
    
    End Class
    

    And this the implementation for a undo/redo on a textbox:

    Public Enum UndoRedoTextBoxEvent As Integer
    
        TextChanged
        FontChanged
        BackColorChanged
        ForeColorChanged
    
    End Enum
    
    Public NotInheritable Class UndoRedoTextBox : Inherits UndoRedo(Of TextBox)
    
        Private lastText As String
        Private lastFont As Font
        Private lastBackColor As Color
        Private lastForeColor As Color
    
        Public Sub New(ByVal tb As TextBox)
            MyBase.New(tb)
        End Sub
    
        Protected Overrides Sub DoUndo([event] As Integer, data As Object)
    
            Select Case DirectCast([event], UndoRedoTextBoxEvent)
    
                Case UndoRedoTextBoxEvent.TextChanged
                    MyBase.controlB.Text = CStr(data)
    
                Case UndoRedoTextBoxEvent.FontChanged
                    MyBase.controlB.Font = DirectCast(data, Font)
    
                Case UndoRedoTextBoxEvent.BackColorChanged
                    MyBase.controlB.BackColor = DirectCast(data, Color)
    
                Case UndoRedoTextBoxEvent.ForeColorChanged
                    MyBase.controlB.ForeColor = DirectCast(data, Color)
    
            End Select
    
        End Sub
    
        Private Sub TextBox_TextChanged(ByVal sender As Object, ByVal e As EventArgs) _
        Handles controlB.TextChanged
    
            Dim currentText As String = MyBase.controlB.Text
    
            If Not String.Equals(Me.lastText, currentText, StringComparison.Ordinal) Then
    
                MyBase.AddUndoRedoItem(UndoRedoCommand.Undo, UndoRedoTextBoxEvent.TextChanged, currentText, Me.lastText)
                Me.lastText = currentText
    
            End If
    
        End Sub
    
        Private Sub TextBox_FontChanged(sender As Object, e As EventArgs) _
        Handles controlB.FontChanged
    
            Dim currentFont As Font = MyBase.controlB.Font
    
            If (Me.lastFont IsNot currentFont) Then
                MyBase.AddUndoRedoItem(UndoRedoCommand.Undo, UndoRedoTextBoxEvent.FontChanged, currentFont, Me.lastFont)
                Me.lastFont = currentFont
            End If
    
        End Sub
    
        Private Sub TextBox_BackColorChanged(sender As Object, e As EventArgs) _
        Handles controlB.BackColorChanged
    
            Dim currentBackColor As Color = MyBase.controlB.BackColor
    
            If (Me.lastBackColor <> currentBackColor) Then
                MyBase.AddUndoRedoItem(UndoRedoCommand.Undo, UndoRedoTextBoxEvent.BackColorChanged, currentBackColor, Me.lastBackColor)
                Me.lastBackColor = currentBackColor
            End If
    
        End Sub
    
        Private Sub TextBox_ForeColorChanged(sender As Object, e As EventArgs) _
        Handles controlB.ForeColorChanged
    
            Dim currentForeColor As Color = MyBase.controlB.ForeColor
    
            If (Me.lastForeColor <> currentForeColor) Then
                MyBase.AddUndoRedoItem(UndoRedoCommand.Undo, UndoRedoTextBoxEvent.ForeColorChanged, currentForeColor, Me.lastForeColor)
                Me.lastForeColor = currentForeColor
            End If
    
        End Sub
    
    End Class