SOLVED Custom GUI Linkbox checking for Accepted types?

Hello; (followup to previous Custom GUI question)

setting up a linkbox in a dialog through a CUSTOMGUI_LINKBOX is easy enough programmatically, and it works fine. However, by default that linkbox accepts any BaseList2D class, object, material, or tag (and more).

If I were in a Resource, I could use the ACCEPT notation to define the accepted types, but I want to keep the whole micro-plugin in one file, so I am building the GUI through Python code... and the LinkBoxGui class has no Accept method, nor an Accept key for the setup BaseContainer, nor is there anything in the class hierarchy that looks like it.

On this forum, I found on older post telling me to evaluate the MSG_DESCRIPTION_CHECKDRAGANDDROP message. This apparently needs to be done in the dialog's Message() function, and yes, I do receive this message:

import c4d
import ctypes
import _ctypes


PLUGIN_ID = 1000001 # this is a test ID, get your own!

ID_BUTTON_CLOSE = 1001
ID_BUTTON_CHECK = 1002
ID_LINKBOX = 1003


class LinkDialog(c4d.gui.GeDialog):

    def CreateLayout(self):
        self.SetTitle("Link Test")
        self.GroupBorderSpace(10, 10, 10, 10)

        if self.GroupBegin(id=0, flags=c4d.BFH_SCALEFIT, cols=1, title="", groupflags=0):

            bc = c4d.BaseContainer()
            bc[c4d.LINKBOX_HIDE_ICON] = False
            bc[c4d.LINKBOX_NO_PICKER] = False
            bc[c4d.LINKBOX_LAYERMODE] = False

            self.linkControl = self.AddCustomGui(ID_LINKBOX, c4d.CUSTOMGUI_LINKBOX,
                    name="A link", flags = c4d.BFH_SCALEFIT | c4d.BFV_FIT,
                    minw=10, minh=10, customdata=bc)
            self.GroupEnd()

        if self.GroupBegin(id=0, flags=c4d.BFH_CENTER, cols=2, title="", groupflags=0):
            self.GroupBorderSpace(0, 20, 0, 0)
            self.AddButton(ID_BUTTON_CLOSE, c4d.BFH_SCALEFIT, name="Close")
            self.AddButton(ID_BUTTON_CHECK, c4d.BFH_SCALEFIT, name="Check")
            self.GroupEnd()

        return True

    def Command(self, messageId, bc):
        if messageId == ID_BUTTON_CLOSE:
            self.Close()
        if messageId == ID_BUTTON_CHECK:
            obj = self.linkControl.GetLink(c4d.documents.GetActiveDocument())
            if obj == None:
                print("No object linked!")
            else:
                print("Name: ", obj.GetName())
                print("Class: ", type(obj))
                print("# of direct children: ", len(obj.GetChildren()))
        return True

    def Message(self, msg, result):
        if msg.GetId() == c4d.MSG_DESCRIPTION_CHECKDRAGANDDROP: # = numerically 26
            ctrlID = msg.GetLong(c4d.LINKBOX_ACCEPT_MESSAGE_CONTROL_ID)
            print (ctrlID)
            if ctrlID == ID_LINKBOX:
                print ("Type ID: ", msg[c4d.LINKBOX_ACCEPT_MESSAGE_TYPE]) # 201, not MSG_DESCRIPTION_CHECKDRAGANDDROP !
                print ("Element: ", msg[c4d.LINKBOX_ACCEPT_MESSAGE_ELEMENT])
                print ("Element type: ", type(msg[c4d.LINKBOX_ACCEPT_MESSAGE_ELEMENT]))
                print ("Accept: ", msg[c4d.LINKBOX_ACCEPT_MESSAGE_ACCEPT])
                # ptr = ctypes.pythonapi.PyCapsule_GetPointer(msg[c4d.LINKBOX_ACCEPT_MESSAGE_ELEMENT], None)
                obj = self.linkControl.GetLink(c4d.documents.GetActiveDocument())
                if obj != None:
                    print (obj.GetName())
                    if not isinstance (obj, c4d.BaseObject):
                        print ("Delete dragged object!")
                        self.linkControl.SetLink(None)
                    else:
                        print ("Accepted.")
        return c4d.gui.GeDialog.Message(self, msg, result)
        
        
class LinkCommandData(c4d.plugins.CommandData):

    dialog = None

    def Execute(self, doc):
        if self.dialog is None:
            self.dialog = LinkDialog()
        return self.dialog.Open(dlgtype=c4d.DLG_TYPE_ASYNC, pluginid=PLUGIN_ID, defaulth=40, defaultw=200)

    def RestoreLayout(self, sec_ref):
        if self.dialog is None:
            self.dialog = LinkDialog()
        return self.dialog.Restore(pluginid=PLUGIN_ID, secret=sec_ref)


if __name__ == "__main__":
    c4d.plugins.RegisterCommandPlugin(
                id=PLUGIN_ID,
                str="Link custom GUI test",
                help="Demonstrates Link field usage",
                info=0,
                dat=LinkCommandData(),
                icon=None)

Unfortunately, that post seems to be too old (8 years or so) to be of use, because it says I would receive this message after the value in the control is changed. That is not the case.

The message appears once when I use the Pick functionality, and many times when I drag an object into the link box. Apparently, I receive this message as long as I hover the mouse over the link field.

It is unclear under the circumstances which of these message is chronologically the last one (on releasing the mouse button). The object returned by GetLink() on the control is always the old/previous object, not the one I drag into the link field. The dragged object from the message itself, received as "Element", is a PyCapsule (for Python3).

So, how to proceed? To inspect the dragged object, I need to unwrap it from the PyCapsule; sadly, that class is not documented. I understand that I need to call PyCapsule_GetPointer somehow, and that's where my understanding ends (trying samples from this forum all end in a crash of C4D).
Then, I am probably(?) required to store a key/value in result (a BaseContainer)... and return False? (Returning False alone does not do anything.)

And is the evaluation of this method the same for Pick and for Drag activities? Am I even in the correct message?

Hello @cairyn,

Thank you for reaching out to us. I think you are making your life unnecessarily complicated.

PyCapsule is documented but it is documented in the CPython documentation, which I am sure you are aware of since you imported ctypes and were trying to poke around in the data. The type description tells your everything you need to know in the first sentence:

type PyCapsule: This subtype of PyObject represents an opaque value, useful for C extension modules who need to pass an opaque value (as a void* pointer) through Python code to other C code.

You cannot make any use of capsules as they are only a data exchange type for the C layer. Python has only its bindings so that it can act as a glue layer and pass data on. That is what they mean by opaque here. There are some cases where you can hack stuff and write values directly to memory so that the data makes sense for the C/C++ layer or that you can read data from the C++ layer. You will need to know the memory layout of data for that, use a library like struct to en-/decode data, and we will obviously not support this.

So, retrieving a PyCapsule instance is usually the end of the road, as it means that something in the C++ API has not been wrapped. But here you do not really need all the stuff from the message. Just cache the value when a drag and drop occurs and restore the value when something has been written which is not to your liking. Find an example below. I have a hunch that you will consider this 'not what I wan't, since I am sure you could have come up with this yourself. I would suggest embracing Python and not turning this into a technical exercise, as there is only little to win here.

Cheers,
Ferdinand

The result, these three nodes can only be linked in this order, as only the first link box does accept nodes of any type, the second only objects, and the last only objects of type Ocube. Dragging mat onto the last slot will result in the value Cube to remain.
Screenshot 2022-11-28 at 18.55.07.png
The code:

"""Demonstrates how to validate and reject linked nodes in link boxes with a simple data model.

Can be run as a Script Manager script.
"""

import c4d
import typing

class LinkDialog(c4d.gui.GeDialog):
    """Provides an example implementation for a set of link boxes in a dialog which only accept
    certain node types.
    """
    ID_GRP_MAIN: int = 1000

    # The IDs of link boxes and their accepted node types as a hashmap.
    LNK_ITEMS: dict[int, int] = {
        1001: c4d.Tbaselist2d, # This link box accepts any node of type BaseList2D
        1002: c4d.Obase,       # This link box accepts any node of type BaseObject
        1003: c4d.Ocube        # This link box accepts only nodes of type Ocube
    }

    # The settings container used by all link boxes.
    BC_LNK_SETTINGS: c4d.BaseContainer = c4d.BaseContainer()

    def __init__(self) -> None:
        """Initializes the internal cache used to emulate accept events in Python.
        """
        # The link cache organized as (gadget id, value) pairs.
        self._linkCache: dict[int, c4d.BaseList2D] = {}
        super().__init__()

    def CreateLayout(self) -> bool:
        """
        """
        self.SetTitle("Link Test")
        self.GroupBorderSpace(10, 10, 10, 10)

        if not self.GroupBegin(LinkDialog.ID_GRP_MAIN, c4d.BFH_SCALEFIT, 1):
            return False

        # Add the link boxes, I went here not for storing the LinkBoxGui references but retrieving
        # them when needed later.
        for gid in LinkDialog.LNK_ITEMS.keys():
            gadget: c4d.gui.LinkBoxGui =  self.AddCustomGui(
                gid, c4d.CUSTOMGUI_LINKBOX, "Link", c4d.BFH_SCALEFIT | c4d.BFV_FIT, 10, 10, 
                LinkDialog.BC_LNK_SETTINGS)
            if not isinstance(gadget, c4d.gui.LinkBoxGui):
                return False
        
        if not self.GroupEnd():
            return False

        return True

    def _validateLink(self, gid: int) -> bool:
        """Validates the link box gadget at #gid.

        When #gid has an illegal value, its cached state will be restored.
        """
        # The linked item is valid, we have nothing to do.
        node: typing.Optional[c4d.BaseList2D] = self._getLink(gid)
        if node is not None:
            return True
        
        # It is not, but the cache somehow went out of whack.
        if gid not in self._linkCache:
            raise RuntimeError(f"Validation event for uncached parameter value for gid: {gid}")
        
        # Restore the previous state.
        res: bool = self._setLink(gid, self._linkCache[gid])
        if res:
            del(self._linkCache[gid])
        
        return res
        

    def _getLink(self, gid: int) -> typing.Optional[c4d.BaseList2D]:
        """Gets the node value of the link box at #gid.

        This will use LinkDialog.LNK_ITEMS to determine the type of nodes #gid is supposed to be
        able to hold. When #gid holds an 'illegal' type, this will return None.
        """
        if gid not in LinkDialog.LNK_ITEMS.keys():
            return None
        
        gadget: c4d.gui.LinkBoxGui = self.FindCustomGui(gid, c4d.CUSTOMGUI_LINKBOX)
        if not isinstance(gadget, c4d.gui.LinkBoxGui):
            raise RuntimeError(f"{gadget = }, {gid = }")
        
        # Get the accepted node types from LNK_ITEMS and rely on the type checking of .GetLink. We
        # could also just retrieve the node without that argument and check ourselves when we want
        # to do more fancy things like (Obase | Mbase), i.e., accept a BaseObject or a BaseMaterial.
        typeId: int = LinkDialog.LNK_ITEMS[gid]
        return gadget.GetLink(c4d.documents.GetActiveDocument(), typeId)

    def _setLink(self, gid: int, value: c4d.BaseList2D) -> bool:
        """Sets the node value of the link box at #gid.
        """
        if gid not in LinkDialog.LNK_ITEMS.keys():
            return False
        
        gadget: c4d.gui.LinkBoxGui = self.FindCustomGui(gid, c4d.CUSTOMGUI_LINKBOX)
        if not isinstance(gadget, c4d.gui.LinkBoxGui):
            raise RuntimeError(f"{gadget = }, {gid = }")
        
        return gadget.SetLink(value)

    def Command(self, mid: int, msg: c4d.BaseContainer) -> bool:
        """Called by Cinema 4D when an action has occurred on one of the gadgets.
        """
        # One of the link boxes has been set, we validate the new state and optionally revert to
        # the old state. This could also be done with BFM_ACTION in Message().
        if mid in LinkDialog.LNK_ITEMS.keys():
            self._validateLink(mid)

        return super().Command(mid, msg)

    def Message(self, msg: c4d.BaseContainer, result: c4d.BaseContainer) -> int:
        """Called by Cinema 4D for all GUI events in the dialog.
        """
        # This is a drag and drop event for a link box, we store its current state before the drag
        # and drop has been carried out.
        if msg.GetId() == c4d.MSG_DESCRIPTION_CHECKDRAGANDDROP:
            gid: int = msg.GetLong(c4d.LINKBOX_ACCEPT_MESSAGE_CONTROL_ID)
            if gid in LinkDialog.LNK_ITEMS.keys():
                self._linkCache[gid] = self._getLink(gid)
        return super().Message(msg, result)

if __name__ == "__main__":
    dlg = LinkDialog()
    dlg.Open(c4d.DLG_TYPE_ASYNC, defaultw=400, default=200)

@ferdinand Thanks for the reply! Ah, of course, I should have considered that Command is actually the last message I get for that operation. This Message stuff totally got me on the wrong track.

Naturally, resetting the link and thereby cancelling and reverting the user's drag or pick operation is not as nice as showing a "stop sign" mouse pointer during the operation. Since the ACCEPT clause in a Resource allows setting accepted types, I wonder why there is no API functionality allowing the same. It's not even in the C++ implementation. I suppose people should use external resources anyway, but seeing stuff in the API makes functionality clearer to me at least.

Regarding PyCapsule, no, I didn't happen across that previously, I just took code from some other thread on this forum to get into the message. I was lazy and looked for PyCapsule in the Cinema 4D API doc, and assumed it undocumented when it wasn't found. Totally my fault, I should have checked the web too. Never program in an unfamiliar scope past midnight! 😹

Nice code btw.