SOLVED Undo Block through Context Manager

I was just brushing up on the Undo system for a simple Python script and it occurred to me that a context manager like this:

with doc.UndoBlock():
    doc.AddUndo(...)
    #code that needs to be undone

Might be a nice little quality of life improvement. I suspect that this would be a break from the C++ API so perhaps it's not in the cards, but I thought it was worth bringing up. I would propose that the current method remain valid but a context manager be added as well.

Hello @wuzelwazel,

thank you for reaching out to us. Unfortunately, it is very unlikely that we will ever implement something like this, as it goes into the direct opposite direction of where we want to go - parity between C++ and Python. But even if this restriction would not be there, I would be hesitant to do this, because one could end up with code like this:

with MyUndoContext(*args):
    #some code ...
    if some_condition:
        doc.EndUndo()
        with MyUndoContext(*args):
            # some code
        doc.StartUndo() 

I.e. when you have an undo wrapped in an undo, you would have an EndUndo which refers to the enclosing context, then a nested context and then reopen the previously closed undo context. Which does not read very well and would in the case of Cinema 4D also not work. There is also a problem with providing the undo document, which is sort of undefined for UNDOTYPE_NEW, which leads to a bit weird argument syntax when implementing this.

But implementing it yourself is not that hard. Below you can find an example implementation which can be used like this:

with UndoContext(node, c4d.UNDOTYPE_CHANGE):
    node[c4d.ID_BASEOBJECT_REL_POSITION] = c4d.Vector(100, 0, 0)

Cheers,
Ferdinand

"""Example for implementing a context manger for Undo operations.

As discussed in:
    https://plugincafe.maxon.net/topic/13358
"""

import c4d

# The relevant Undo flags.
UNDO_TYPES = (
    c4d.UNDOTYPE_NONE,
    c4d.UNDOTYPE_CHANGE,
    c4d.UNDOTYPE_CHANGE_NOCHILDREN,
    c4d.UNDOTYPE_CHANGE,
    c4d.UNDOTYPE_CHANGE_SMALL,
    c4d.UNDOTYPE_CHANGE_SELECTION,
    c4d.UNDOTYPE_NEW,
    c4d.UNDOTYPE_DELETE,
    c4d.UNDOTYPE_BITS,
    c4d.UNDOTYPE_HIERARCHY_PSR
)


class UndoContext():
    """A class based context manager for Cinema 4D Undo operations.

    This could also be done with decorator gymnastics and I would actually
    prefer that approach due to its greater flexibility. But I think this is 
    the currently the recommended way to implement context mangers for 
    Python 3. So, class approach it is :)
    """

    def __init__(self, node, flag, doc=None):
        """Context Initializator.

        Args:
            node (c4d.BaseList2D): The node to add an undo for.
            flag (int): The undo flag.
            doc (None | c4d.documents.BaseDocument, optional): The document
             to add the undo to. Has only to be provided for UNDOTYPE_NEW,
             since the document there is not defined yet for a node. Defaults
             to None.

        Raises:
            TypeError: For invalid argument types.
            ValueError: For invalid flag values/types.
        """
        # Sort out some invalid arguments.
        if flag not in UNDO_TYPES:
            raise ValueError(f"Unexpected flag symbol: {flag}")
        if not isinstance(node, c4d.BaseList2D):
            raise TypeError(f"Unexpected node type: {type(node)}")
        doc = doc or node.GetDocument()
        if not isinstance(doc, c4d.documents.BaseDocument):
            raise TypeError(f"Unexpected document type: {type(doc)}")

        # Some private attributes.
        self._node = node
        self._doc = doc
        self._flag = flag

    def __enter__(self):
        """Called when the context is being entered.
        """
        self._doc.StartUndo()
        # All Undo operations except UNDOTYPE_NEW have to be invoked before
        # the changes to be applied.
        if self._flag != c4d.UNDOTYPE_NEW:
            self._doc.AddUndo(self._flag, self._node)

    def __exit__(self, type, value, traceback):
        """Called when the context is being exited.
        """
        if self._flag == c4d.UNDOTYPE_NEW:
            self._doc.AddUndo(self._flag, self._node)
        self._doc.EndUndo()


def main():
    """Example usage.
    """
    # When there is an active object, modify its position wrapped in an undo.
    if op is not None:
        with UndoContext(op, c4d.UNDOTYPE_CHANGE):
            op[c4d.ID_BASEOBJECT_REL_POSITION] = c4d.Vector(100, 0, 0)

    # Add a new cube object wrapped in an undo.
    myCube = c4d.BaseList2D(c4d.Ocube)
    with UndoContext(myCube, c4d.UNDOTYPE_NEW, doc):
        myCube[c4d.PRIM_CUBE_DOFILLET] = True
        myCube[c4d.PRIM_CUBE_FRAD] = 25.
        doc.InsertObject(myCube)

    # Push update notification to Cinema 4D.
    c4d.EventAdd()


if __name__ == '__main__':
    main()

Hello @wuzelwazel,

thank you for reaching out to us. Unfortunately, it is very unlikely that we will ever implement something like this, as it goes into the direct opposite direction of where we want to go - parity between C++ and Python. But even if this restriction would not be there, I would be hesitant to do this, because one could end up with code like this:

with MyUndoContext(*args):
    #some code ...
    if some_condition:
        doc.EndUndo()
        with MyUndoContext(*args):
            # some code
        doc.StartUndo() 

I.e. when you have an undo wrapped in an undo, you would have an EndUndo which refers to the enclosing context, then a nested context and then reopen the previously closed undo context. Which does not read very well and would in the case of Cinema 4D also not work. There is also a problem with providing the undo document, which is sort of undefined for UNDOTYPE_NEW, which leads to a bit weird argument syntax when implementing this.

But implementing it yourself is not that hard. Below you can find an example implementation which can be used like this:

with UndoContext(node, c4d.UNDOTYPE_CHANGE):
    node[c4d.ID_BASEOBJECT_REL_POSITION] = c4d.Vector(100, 0, 0)

Cheers,
Ferdinand

"""Example for implementing a context manger for Undo operations.

As discussed in:
    https://plugincafe.maxon.net/topic/13358
"""

import c4d

# The relevant Undo flags.
UNDO_TYPES = (
    c4d.UNDOTYPE_NONE,
    c4d.UNDOTYPE_CHANGE,
    c4d.UNDOTYPE_CHANGE_NOCHILDREN,
    c4d.UNDOTYPE_CHANGE,
    c4d.UNDOTYPE_CHANGE_SMALL,
    c4d.UNDOTYPE_CHANGE_SELECTION,
    c4d.UNDOTYPE_NEW,
    c4d.UNDOTYPE_DELETE,
    c4d.UNDOTYPE_BITS,
    c4d.UNDOTYPE_HIERARCHY_PSR
)


class UndoContext():
    """A class based context manager for Cinema 4D Undo operations.

    This could also be done with decorator gymnastics and I would actually
    prefer that approach due to its greater flexibility. But I think this is 
    the currently the recommended way to implement context mangers for 
    Python 3. So, class approach it is :)
    """

    def __init__(self, node, flag, doc=None):
        """Context Initializator.

        Args:
            node (c4d.BaseList2D): The node to add an undo for.
            flag (int): The undo flag.
            doc (None | c4d.documents.BaseDocument, optional): The document
             to add the undo to. Has only to be provided for UNDOTYPE_NEW,
             since the document there is not defined yet for a node. Defaults
             to None.

        Raises:
            TypeError: For invalid argument types.
            ValueError: For invalid flag values/types.
        """
        # Sort out some invalid arguments.
        if flag not in UNDO_TYPES:
            raise ValueError(f"Unexpected flag symbol: {flag}")
        if not isinstance(node, c4d.BaseList2D):
            raise TypeError(f"Unexpected node type: {type(node)}")
        doc = doc or node.GetDocument()
        if not isinstance(doc, c4d.documents.BaseDocument):
            raise TypeError(f"Unexpected document type: {type(doc)}")

        # Some private attributes.
        self._node = node
        self._doc = doc
        self._flag = flag

    def __enter__(self):
        """Called when the context is being entered.
        """
        self._doc.StartUndo()
        # All Undo operations except UNDOTYPE_NEW have to be invoked before
        # the changes to be applied.
        if self._flag != c4d.UNDOTYPE_NEW:
            self._doc.AddUndo(self._flag, self._node)

    def __exit__(self, type, value, traceback):
        """Called when the context is being exited.
        """
        if self._flag == c4d.UNDOTYPE_NEW:
            self._doc.AddUndo(self._flag, self._node)
        self._doc.EndUndo()


def main():
    """Example usage.
    """
    # When there is an active object, modify its position wrapped in an undo.
    if op is not None:
        with UndoContext(op, c4d.UNDOTYPE_CHANGE):
            op[c4d.ID_BASEOBJECT_REL_POSITION] = c4d.Vector(100, 0, 0)

    # Add a new cube object wrapped in an undo.
    myCube = c4d.BaseList2D(c4d.Ocube)
    with UndoContext(myCube, c4d.UNDOTYPE_NEW, doc):
        myCube[c4d.PRIM_CUBE_DOFILLET] = True
        myCube[c4d.PRIM_CUBE_FRAD] = 25.
        doc.InsertObject(myCube)

    # Push update notification to Cinema 4D.
    c4d.EventAdd()


if __name__ == '__main__':
    main()

Hello @wuzelwazel,

without further questions or postings, we will consider this topic as solved by Wednesday and flag it accordingly.

Thank you for your understanding,
Ferdinand