SOLVED How To Add Undo For Description Added Dynamically

Hi folks,

I'm still trying to make a python tag plugin which has 2 buttons in tag property. It dynamically adds new descriptions to tag property when "add" button is clicked, and it dynamically removes those descriptions when "remove" button is clicked. So far my code works but I noticed undo doesn't work for these descriptions (see below).

Is there a way to add undo for descriptions created dynamically? Please tell me how to add undo for that if it's possible for python plugin.
I tried to insert basic undo things (StartUndo(), AddUndo(), and EndUndo()) into Message() function but it doesn't seem to work.
My goal is it works like "IK-Spline" does. (see below)

"Current Status"
undo_issue.gif

"My Goal"
like_ik_spline.gif

Here's my code.

test_tag.pyp

import os
import c4d
from c4d import plugins


PLUGIN_ID = *******
LINK_NO = 1100
FLOAT_NO = 1200


class TestTagData(c4d.plugins.TagData):
    def Init(self, node):
        self.controllers_num = 0
        pd = c4d.PriorityData()
        if pd is None:
            raise MemoryError("Failed to create a priority data.")
        pd.SetPriorityValue(lValueID = c4d.PRIORITYVALUE_MODE, data = c4d.CYCLE_EXPRESSION)
        node[c4d.EXPRESSION_PRIORITY] = pd
        return True

    def Execute(self, tag, doc, op, bt, priority, flags):
        return c4d.EXECUTIONRESULT_OK

    def Message(self, node, type, data):
        if type == c4d.MSG_DESCRIPTION_COMMAND:
            if data["id"][0].id == c4d.TTESTTAG_BUTTON_ADD:
                doc = c4d.documents.GetActiveDocument()
                doc.StartUndo()                    #---------- This doesn't seem to work! ----------
                doc.AddUndo(c4d.UNDOTYPE_CHANGE_NOCHILDREN, node)
                self.controllers_num += 1
                doc.EndUndo()                    #----------------------------------------
                node.SetDirty(c4d.DIRTYFLAGS_DESCRIPTION)
            elif data["id"][0].id == c4d.TTESTTAG_BUTTON_REMOVE:
                if self.controllers_num > 0:
                    doc = c4d.documents.GetActiveDocument()
                    doc.StartUndo()                    #---------- This doesn't seem to work! ----------
                    doc.AddUndo(c4d.UNDOTYPE_CHANGE_NOCHILDREN, node)
                    self.controllers_num -= 1
                    doc.EndUndo()                    #----------------------------------------
                    node.SetDirty(c4d.DIRTYFLAGS_DESCRIPTION)
        return True

    def GetDDescription(self, node, description, flags):
        if not description.LoadDescription(node.GetType()):
            return False
        singleId = description.GetSingleDescID()
        groupId = c4d.DescID(c4d.DescLevel(c4d.ID_TAGPROPERTIES))
        controllers_num = self.controllers_num
        if controllers_num > 0:
            for i in range(controllers_num):
                linkId = c4d.DescID(c4d.DescLevel(LINK_NO + (i + 1)))
                if singleId is None or linkId.IsPartOf(singleId)[0]:
                    link_bc = c4d.GetCustomDataTypeDefault(c4d.DTYPE_BASELISTLINK)
                    link_bc.SetString(c4d.DESC_NAME, "Controller " + str(i + 1))
                    link_bc.SetString(c4d.DESC_SHORT_NAME, "Controller " + str(i + 1))
                    link_bc.SetInt32(c4d.DESC_ANIMATE, c4d.DESC_ANIMATE_ON)
                    if not description.SetParameter(linkId, link_bc, groupId):
                        return False
                floatId = c4d.DescID(c4d.DescLevel(FLOAT_NO + (i + 1)))
                if singleId is None or floatId.IsPartOf(singleId)[0]:
                    float_bc = c4d.GetCustomDataTypeDefault(c4d.DTYPE_REAL)
                    float_bc.SetString(c4d.DESC_NAME, "Rate")
                    float_bc.SetString(c4d.DESC_SHORT_NAME, "Rate")
                    float_bc.SetFloat(c4d.DESC_DEFAULT, 1)
                    float_bc.SetInt32(c4d.DESC_ANIMATE, c4d.DESC_ANIMATE_ON)
                    float_bc.SetInt32(c4d.DESC_UNIT, c4d.DESC_UNIT_PERCENT)
                    float_bc.SetFloat(c4d.DESC_MIN, -1000)
                    float_bc.SetFloat(c4d.DESC_MAX, 1000)
                    float_bc.SetFloat(c4d.DESC_MINSLIDER, -1)
                    float_bc.SetFloat(c4d.DESC_MAXSLIDER, 1)
                    float_bc.SetFloat(c4d.DESC_STEP, 0.01)
                    float_bc.SetInt32(c4d.DESC_CUSTOMGUI, c4d.CUSTOMGUI_REALSLIDER)
                    if not description.SetParameter(floatId, float_bc, groupId):
                        return False
                separatorId = c4d.DescID(0)
                if singleId is None or separatorId.IsPartOf(singleId)[0]:
                    separator_bc = c4d.GetCustomDataTypeDefault(c4d.DTYPE_SEPARATOR)
                    separator_bc.SetInt32(c4d.DESC_CUSTOMGUI, c4d.CUSTOMGUI_SEPARATOR)
                    separator_bc.SetBool(c4d.DESC_SEPARATORLINE, True)
                    if not description.SetParameter(separatorId, separator_bc, groupId):
                        return False
        return (True, flags | c4d.DESCFLAGS_DESC_LOADED)


if __name__ == "__main__":
    c4d.plugins.RegisterTagPlugin(id = PLUGIN_ID, str = "Test Tag", info = c4d.TAG_EXPRESSION|c4d.TAG_VISIBLE, g = TestTagData, description = "ttesttag", icon = None)

ttesttag.res

CONTAINER Ttesttag
{
    NAME Ttesttag;
    INCLUDE Texpression;

    GROUP ID_TAGPROPERTIES
    {
        GROUP TTESTTAG_BUTTONS_GROUP
        {
            COLUMNS 2;
            BUTTON TTESTTAG_BUTTON_ADD { SCALE_H; }
            BUTTON TTESTTAG_BUTTON_REMOVE { SCALE_H; }
        }
    }
}

ttesttag.h

#ifndef _TTESTTAG_H_
#define _TTESTTAG_H_

enum
{
    TTESTTAG_BUTTONS_GROUP = 1001,
    TTESTTAG_BUTTON_ADD = 1002,
    TTESTTAG_BUTTON_REMOVE = 1003,
}

#endif

ttesttag.str

STRINGTABLE Ttesttag
{
    Ttesttag "Test Tag";
    TTESTTAG_BUTTONS_GROUP "";
    TTESTTAG_BUTTON_ADD "Add";
    TTESTTAG_BUTTON_REMOVE "Remove";
}

---------- User Information ----------

Cinema 4D version: R23
OS: Windows 10
Language: Python

Just from looking at the code: The description is created dynamically, based on

self.controllers_num

This is the only parameter that you change when pressing a button. However, it's an instance attribute of the Python object, and I very much doubt that it is stored with the Undo data in C4D. So any undo action will not restore this attribute, and the description will still be created with the same number of entries.

If I had the time, I would attempt to store the number of controllers in the BaseContainer of the tag instead so it becomes "visible" to the C4D data that is actually part of the Undo. (No guarantee, I need to walk the dog.)

@Cairyn Thank you for your time!

Hmm, I see. I'm not sure the actual solution for the point you mention but your explanation is so helpful. Anyway, I'll try some workaround on my end.

So I tried some changes based on a hint @Cairyn points out, my code seems to work now.
But I'm still not sure this is a proper way to add undo for dynamic descriptions, so please let me know if you know the correct / better way!

test_tag.pyp

import os
import c4d
from c4d import plugins


PLUGIN_ID = *******
LINK_NO = 1100
FLOAT_NO = 1200


class TestTagData(c4d.plugins.TagData):
    def Init(self, node):
        self.InitAttr(node, int, c4d.TTESTTAG_CONTROLLERS_NUM)  #-------------------- Changed --------------------
        bc = node.GetDataInstance()  #-------------------- Changed --------------------
        bc.SetInt32(c4d.TTESTTAG_CONTROLLERS_NUM, 0)  #-------------------- Changed --------------------
        pd = c4d.PriorityData()
        if pd is None:
            raise MemoryError("Failed to create a priority data.")
        pd.SetPriorityValue(lValueID = c4d.PRIORITYVALUE_MODE, data = c4d.CYCLE_EXPRESSION)
        node[c4d.EXPRESSION_PRIORITY] = pd
        return True

    def Execute(self, tag, doc, op, bt, priority, flags):
        return c4d.EXECUTIONRESULT_OK

    def Message(self, node, type, data):
        if type == c4d.MSG_DESCRIPTION_COMMAND:
            if data["id"][0].id == c4d.TTESTTAG_BUTTON_ADD:
                doc = c4d.documents.GetActiveDocument()
                doc.StartUndo()
                doc.AddUndo(c4d.UNDOTYPE_CHANGE_SMALL, node)
                bc = node.GetDataInstance()  #-------------------- Changed --------------------
                bc.SetInt32(c4d.TTESTTAG_CONTROLLERS_NUM, bc.GetInt32(c4d.TTESTTAG_CONTROLLERS_NUM) + 1)  #-------------------- Changed --------------------
                doc.EndUndo()
            elif data["id"][0].id == c4d.TTESTTAG_BUTTON_REMOVE:
                bc = node.GetDataInstance()  #-------------------- Changed --------------------
                controllers_num = bc.GetInt32(c4d.TTESTTAG_CONTROLLERS_NUM)  #-------------------- Changed --------------------
                if controllers_num > 0:  #-------------------- Changed --------------------
                    doc = c4d.documents.GetActiveDocument()
                    doc.StartUndo()
                    doc.AddUndo(c4d.UNDOTYPE_CHANGE_SMALL, node)
                    bc.SetInt32(c4d.TTESTTAG_CONTROLLERS_NUM, controllers_num - 1)  #-------------------- Changed --------------------
                    doc.EndUndo()
        return True

    def GetDDescription(self, node, description, flags):
        if not description.LoadDescription(node.GetType()):
            return False
        singleId = description.GetSingleDescID()
        groupId = c4d.DescID(c4d.DescLevel(c4d.ID_TAGPROPERTIES))
        bc = node.GetDataInstance()  #-------------------- Changed --------------------
        controllers_num = bc.GetInt32(c4d.TTESTTAG_CONTROLLERS_NUM)  #-------------------- Changed --------------------
        if controllers_num > 0:
            for i in range(controllers_num):
                linkId = c4d.DescID(c4d.DescLevel(LINK_NO + (i + 1)))
                if singleId is None or linkId.IsPartOf(singleId)[0]:
                    link_bc = c4d.GetCustomDataTypeDefault(c4d.DTYPE_BASELISTLINK)
                    link_bc.SetString(c4d.DESC_NAME, "Controller " + str(i + 1))
                    link_bc.SetString(c4d.DESC_SHORT_NAME, "Controller " + str(i + 1))
                    link_bc.SetInt32(c4d.DESC_ANIMATE, c4d.DESC_ANIMATE_ON)
                    if not description.SetParameter(linkId, link_bc, groupId):
                        return False
                floatId = c4d.DescID(c4d.DescLevel(FLOAT_NO + (i + 1)))
                if singleId is None or floatId.IsPartOf(singleId)[0]:
                    float_bc = c4d.GetCustomDataTypeDefault(c4d.DTYPE_REAL)
                    float_bc.SetString(c4d.DESC_NAME, "Rate")
                    float_bc.SetString(c4d.DESC_SHORT_NAME, "Rate")
                    float_bc.SetFloat(c4d.DESC_DEFAULT, 1)
                    float_bc.SetInt32(c4d.DESC_ANIMATE, c4d.DESC_ANIMATE_ON)
                    float_bc.SetInt32(c4d.DESC_UNIT, c4d.DESC_UNIT_PERCENT)
                    float_bc.SetFloat(c4d.DESC_MIN, -1000)
                    float_bc.SetFloat(c4d.DESC_MAX, 1000)
                    float_bc.SetFloat(c4d.DESC_MINSLIDER, -1)
                    float_bc.SetFloat(c4d.DESC_MAXSLIDER, 1)
                    float_bc.SetFloat(c4d.DESC_STEP, 0.01)
                    float_bc.SetInt32(c4d.DESC_CUSTOMGUI, c4d.CUSTOMGUI_REALSLIDER)
                    if not description.SetParameter(floatId, float_bc, groupId):
                        return False
                separatorId = c4d.DescID(0)
                if singleId is None or separatorId.IsPartOf(singleId)[0]:
                    separator_bc = c4d.GetCustomDataTypeDefault(c4d.DTYPE_SEPARATOR)
                    separator_bc.SetInt32(c4d.DESC_CUSTOMGUI, c4d.CUSTOMGUI_SEPARATOR)
                    separator_bc.SetBool(c4d.DESC_SEPARATORLINE, True)
                    if not description.SetParameter(separatorId, separator_bc, groupId):
                        return False
        return (True, flags | c4d.DESCFLAGS_DESC_LOADED)


if __name__ == "__main__":
    c4d.plugins.RegisterTagPlugin(id = PLUGIN_ID, str = "Test Tag", info = c4d.TAG_EXPRESSION|c4d.TAG_VISIBLE, g = TestTagData, description = "ttesttag", icon = None)

ttesttag.res

CONTAINER Ttesttag
{
    NAME Ttesttag;
    INCLUDE Texpression;

    GROUP ID_TAGPROPERTIES
    {
        GROUP TTESTTAG_BUTTONS_GROUP
        {
            COLUMNS 2;
            BUTTON TTESTTAG_BUTTON_ADD { SCALE_H; }
            BUTTON TTESTTAG_BUTTON_REMOVE { SCALE_H; }
            LONG TTESTTAG_CONTROLLERS_NUM { HIDDEN; ANIM OFF; MIN 0; MAX 10000; }  //-------------------- Added --------------------
        }
    }
}

ttesttag.h

#ifndef _TTESTTAG_H_
#define _TTESTTAG_H_

enum
{
    TTESTTAG_BUTTONS_GROUP = 1001,
    TTESTTAG_BUTTON_ADD = 1002,
    TTESTTAG_BUTTON_REMOVE = 1003,
    TTESTTAG_CONTROLLERS_NUM = 1004,  //-------------------- Added --------------------
}

#endif

ttesttag.str

STRINGTABLE Ttesttag
{
    Ttesttag "Test Tag";
    TTESTTAG_BUTTONS_GROUP "";
    TTESTTAG_BUTTON_ADD "Add";
    TTESTTAG_BUTTON_REMOVE "Remove";
    TTESTTAG_CONTROLLERS_NUM "";  //-------------------- Added --------------------
}

hi,

as @Cairyn said, when you add the undo to the stack, it does store a copy of your object.

The "problem" is that your objet is storing your data in the instance itself. Cinema4D doesn't know anything about it.

Two solution, either you store this value (controllers_num) in a BaseContainer so cinema4D will copy the basecontainer.

Either you implement CopyTo to make cinema4D aware on how to handle that data.
As said in the documentation, if you implement the CopyTo, you also need to implement Read and Write functions (used when you save or load a c4d file)

   def CopyTo(self, dest, snode, dnode, flags, trn):
        dest.controllers_num = self.controllers_num
        return True

Or you just store the data in the BaseContainer of the object and c4d will manage it for you.

Cheers,
Manuel.

@m_magalhaes Thank you for your explanation! It's really helpful to know several solutions for a problem.
C4D python doc is not user friendly for newbies like me but this community is super friendly and pretty awesome! 🤛

@beatgram Just for good style, it is not necessary to add a LONG to the .res file. You can store a value in the BaseContainer without making it accessible in the GUI. The decisive thing is just that C4D knows about the value. Which can be achieved by writing it into the BaseContainer (which is a C4D data structure and "known" by default), or by explicitly handling it by implementing the CopyTo, Read, Write function group.

@Cairyn Thank you so much for helping me again! 😊