UNSOLVED Python Plugin GUI Tutorial

Hi,

I am starting to finish my code in the script manager and I want to start coding the GUI for it to create a proper plugin.
Where can I find a written or video tutorial on how to create a GUI with python?
I have read the python SDK "tutorial" on GUI but it only talks about the layout files and does not give a starting example of how to create a GUI.

Thank you so much for your time and help.

Joel.

Hi Joel,

I would suggest you take a look at the Maxon SDK github examples. There are some GUI examples and samoe examples that have a GUI. Its not very encompasing but if you take a look you wil manage I guess.

kind regards

Hi mogh,

I was able to create a basic GUI with the github examples. But now I want to improve it. By improving it I mean that the user does not have to type the name of the object to select but rather that the GUI lets the user choose the object of the viewport.
I don't really know how to do it but I found that a linkbox might be the solution however I am unable to find any examples of how to use it.

Do you have any or do you know where I can find a tutorial on how to use the customgui?

Joel.

Hello @joel,

Thank you for reaching out to us. Yeah, the GUI examples for Python are in a bit rough state. I would recommend having a look at the C++ Docs, as they cover more ground.

There are in principal two ways to define GUIs in Cinema 4D, dialogs and descriptions (see the C++ Manual for details). Dialogs are primarily used for things like CommandData plugins, i.e., when you need a separate window. NodeData plugins (an object, material, shader, tag, etc.) use description resources to define their GUIs and are displayed in the Attribute Manger.

I should really find the time to write a new GUI manual both for C++ and Python, we are aware that it is urgent, but I never got to it yet, because it will be quite some undertaking. But I have written a little example for your specific case, in the hopes that it will help you. It goes over some basic and more advanced techniques:

  • How to bind a CommandData plugin and a GeDialog together.
  • What the major overwritable methods do.
  • How GeDialog.CreateLayout works.
  • Using custom GUIs
  • Implementing a (very simple) data model for a dialog.

Cheers,
Ferdinand

The result:
gui_link.gif

The code:

"""Implements a CommandData plugin with dialog with a dynamic GUI, using multiple CUSTOMGUI_LINKBOX
gadgets.

Save this file as "someName.pyp" and target Cinema 4D's plugin directory list to the directory
containing the file "someName.pyp". The plugin will appear as "Dialog Manager Command" in the
"Extensions" menu.
"""

import c4d
import typing


class MyDialog(c4d.gui.GeDialog):
    """Implements a dialog with link box gadgets which can be dynamically added and removed at
    runtime.

    This also demonstrates how one can put an abstraction layer / data model (or however one wants
    to call such thing) on top of a couple of gadgets, here the link box GUIs.
    """
    # The gadget IDs of the dialog.

    # The three groups.
    ID_GRP_MAIN: int = 1000
    ID_GRP_ELEMENTS: int = 1001
    ID_GRP_BUTTONS: int = 1002

    # The three buttons at the bottom.
    ID_BTN_ADD: int = 2000
    ID_BTN_REMOVE: int = 2001
    ID_BTN_PRINT: int = 2002

    # The dynamic elements. They start at 3000 and then go NAME, LINK, NAME, LINK, ...
    ID_ELEMENTS_START: int = 3000
    ID_ELEMENT_NAME: int = 0
    ID_ELEMENT_LINK: int = 1

    # A default layout flag for GUI gadgets and a default gadget spacing.
    DEFAULT_FLAGS: int = c4d.BFH_SCALEFIT | c4d.BFV_SCALEFIT
    DEFAULT_SPACE: tuple[int] = (5, 5, 5, 5)

    # A settings container for a LinkBoxGui instance, these are all default settings, so we could
    # pass the empty BaseContainer instead with the same effect. But here you can tweak the settings
    # of a custom GUI. Since we want all link boxes to look same, this is done as a class constant.
    LINKBOX_SETTINGS: c4d.BaseContainer = c4d.BaseContainer()
    LINKBOX_SETTINGS.SetBool(c4d.LINKBOX_HIDE_ICON, False)
    LINKBOX_SETTINGS.SetBool(c4d.LINKBOX_LAYERMODE, False)
    LINKBOX_SETTINGS.SetBool(c4d.LINKBOX_NO_PICKER, False)
    LINKBOX_SETTINGS.SetBool(c4d.LINKBOX_NODE_MODE, False)

    def __init__(self, items: list[c4d.BaseList2D] = []) -> None:
        """Initializes a MyDialog instance.

        Args:
            items (list[c4d.BaseList2D]): The items to init the dialog with.
        """
        super().__init__()

        self._items: list[c4d.BaseList2D] = []  # The items linked in the dialog.
        self._doc: typing.Optional[c4d.documents.BaseDocument] = None  # The document of the dialog.
        self._hasCreateLayout: bool = False  # If CrateLayout() has run for the dialog or not.

        # Bind the dialog to the passed items.
        self.Items = items

    # Our data model, we expose _items as a property, so that we can read and write items from
    # the outside. For basic type gadgets, e.g., string, bool, int, float, etc., there are
    # convenience methods attached to GeDialog like Get/SetString. But there is no GetLink() method.
    # So one must do one of two things:
    #
    #   1. Store all custom GUI gadgets in a list and manually interact with them.
    #   2. Put a little abstraction layer on top of things as I did here.
    #
    # Calling myDialogInstance.Items will always yield all items in the order as shown in the GUI,
    # and calling my myDialogInstance.Items = [a, b, c] will then show them items [a, b, c] in three
    # link boxes in the dialog. No method is really intrinsically better, but I prefer it like this.
    @property
    def Items(self) -> list[c4d.BaseList2D]:
        """gets all items linked in the link boxes.
        """
        return self._items

    @Items.setter
    def Items(self, value: list[c4d.BaseList2D]) -> None:
        """Sets all items linked in link boxes.
        """
        if not isinstance(value, list):
            raise TypeError(f"Items: {value}")

        # Set the items and get the associated document from the first item.
        self._items = value
        self._doc = value[0].GetDocument() if len(self._items) > 0 else None

        # Update the GUI when this setter is being called after CreateLayout() has already run.
        if self._hasCreateLayout:
            self.PopulateDynamicGroup(isUpdate=True)

    def InitValues(self) -> bool:
        """Called by Cinema 4D once CreateLayout() has ran.

        Not needed in this case.
        """
        return super().InitValues()

    def CreateLayout(self) -> bool:
        """Called once by Cinema 4D when a dialog opens to populate the dialog with gadgets.

        But one is not bound to adding only items from this method, a dialog can be repopulated
        dynamically.
        """
        self._hasCreateLayout = True
        self.SetTitle("Dialog Manager Command")

        # The outmost layout group of the dialog. It has one column and we will only place other
        # groups in it. Items are placed like this:
        #
        #   Main {
        #       a,
        #       b,
        #       c,
        #       ...
        #   }
        #
        self.GroupBegin(id=self.ID_GRP_MAIN, flags=c4d.BFH_SCALEFIT | c4d.BFV_SCALEFIT, cols=1)
        # Set the group spacing of ID_GRP_MAIN to (5, 5, 5, 5)
        self.GroupBorderSpace(*self.DEFAULT_SPACE)

        # An layout group inside #ID_GRP_MAIN, it has two columns and we will place pairs of
        # labels and link boxes in it. The layout is now:
        #
        #   Main {
        #       Elements {
        #           a, b,
        #           c, d,
        #           ... }
        #       b,
        #       c,
        #       ...
        #   }
        #
        self.GroupBegin(id=self.ID_GRP_ELEMENTS, flags=c4d.BFH_SCALEFIT | c4d.BFV_SCALEFIT, cols=2)
        # Set the group spacing of ID_GRP_ELEMENTS to (5, 5, 5, 5).
        self.GroupBorderSpace(*self.DEFAULT_SPACE)
        # Call our PopulateDynamicGroup() method, here with isUpdate=False, so that group
        # ID_GRP_ELEMENTS won't be flushed the first time its is being built. Doing this is the same
        # as moving all the code from PopulateDynamicGroup() to the line below.
        self.PopulateDynamicGroup(isUpdate=False)
        self.GroupEnd()  # ID_GRP_ELEMENTS

        # A second layout group inside ID_GRP_MAIN, its has three columns and will place our buttons
        # in it. The layout is now:
        #
        #   Main {
        #       Elements {
        #           a, b,
        #           c, d,
        #           ... }
        #       Buttons {
        #           a, b, c,
        #           e, f, g,
        #           ...},
        #       c,
        #       ...
        #   }
        #
        self.GroupBegin(id=self.ID_GRP_BUTTONS, flags=c4d.BFH_SCALEFIT, cols=3)
        self.GroupBorderSpace(*self.DEFAULT_SPACE)
        # The three buttons.
        self.AddButton(id=self.ID_BTN_ADD, flags=c4d.BFH_SCALEFIT, name="Add Item")
        self.AddButton(id=self.ID_BTN_REMOVE, flags=c4d.BFH_SCALEFIT, name="Remove Last Item")
        self.AddButton(id=self.ID_BTN_PRINT, flags=c4d.BFH_SCALEFIT, name="Print Items")
        self.GroupEnd()  # ID_GRP_BUTTONS

        self.GroupEnd()  # ID_GRP_MAIN

        return super().CreateLayout()

    def PopulateDynamicGroup(self, isUpdate: bool = False):
        """Builds the dynamic part of the GUI.

        This is a custom method that is not a member of GeDialog.

        Args:
            isUpdate (bool, optional): If this is an GUI update event. Defaults to False.

        Raises:
            MemoryError: On gadget allocation failure.
            RuntimeError: On linking objects failure.
        """
        # When this is an update event, i.e., the group #ID_GRP_ELEMENTS has been populated before,
        # flush the items in the group and set the gadget insertion pointer of the this dialog to
        # the start of #ID_GRP_ELEMENTS. Everything else done in CreateLayout(), the groups, the
        # buttons, the spacings, remains intact.
        if isUpdate:
            self.LayoutFlushGroup(self.ID_GRP_ELEMENTS)

        # For each item in self._items ...
        for i, item in enumerate(self.Items):
            # Define the current starting id: 3000, 3002, 3004, 3006, ...
            offset: int = self.ID_ELEMENTS_START + (i * 2)

            # Add a static text element containing the class name of #item or "Empty" when the
            # item is None.
            self.AddStaticText(id=offset + self.ID_ELEMENT_NAME,
                               flags=c4d.BFH_LEFT,
                               name=item.__class__.__name__ if item else "Empty")

            # Add a link box GUI, a custom GUI is added by its gadget ID, its plugin ID, here
            # CUSTOMGUI_LINKBOX, and additionally a settings container, here the constant
            # self.LINKBOX_SETTINGS.
            gui: c4d.gui.LinkBoxGui = self.AddCustomGui(
                id=offset + self.ID_ELEMENT_LINK,
                pluginid=c4d.CUSTOMGUI_LINKBOX,
                name="",
                flags=c4d.BFH_SCALEFIT,
                minw=0,
                minh=0,
                customdata=self.LINKBOX_SETTINGS)
            if not isinstance(gui, c4d.gui.LinkBoxGui):
                raise MemoryError("Could not allocate custom GUI.")

            # When item is not a BaseList2D, i.e., None, we do not have to set the link.
            if not isinstance(item, c4d.BaseList2D):
                continue

            # Otherwise try to link #item in the link box GUI.
            if not gui.SetLink(item):
                raise RuntimeError("Failed to set node link from data.")

        if isUpdate:
            self.LayoutChanged(self.ID_GRP_ELEMENTS)

    def AddEmptyItem(self) -> None:
        """Adds a new empty item to the data model and updates the GUI.

        This is a custom method that is not a member of GeDialog.
        """
        self._items.append(None)
        self.PopulateDynamicGroup(isUpdate=True)

    def RemoveLastItem(self) -> None:
        """Removes the last item from the data model and updates the GUI.

        This is a custom method that is not a member of GeDialog.
        """
        if len(self._items) > 0:
            self._items.pop()
            self.PopulateDynamicGroup(isUpdate=True)

    def UpdateItem(self, cid: int):
        """Updates an item in list of link boxes.

        This is a custom method that is not a member of GeDialog.

        Args:
            cid (int): The gadget ID for which this event was fired (guaranteed to be a link box
                GUI gadget ID unless I screwed up somewhere :D).

        """
        # The index of the link box and therefore index in self._items, e.g., the 0, 1, 2, 3, ...
        # link box GUI / item.
        index: int = int((cid - self.ID_ELEMENTS_START) * 0.5)

        # Get the LinkBoxGui associated with the ID #cid.
        gui: c4d.gui.LinkBoxGui = self.FindCustomGui(id=cid, pluginid=c4d.CUSTOMGUI_LINKBOX)
        if not isinstance(gui, c4d.gui.LinkBoxGui):
            raise RuntimeError(f"Could not access link box GUI for gadget id: {cid}")

        # Retrieve the item in the link box gui. This can return None, but in this case we are
        # okay with that, as we actually want to reflect in our data model self._items when
        # link box is empty. The second argument to GetLink() is a type filter. We pass here
        # #Tbaselist2d to indicate that we are interested in anything that is a BaseList2D. When
        # would pass Obase (any object), and the user linked a material, the method would return
        # None. If we would pass Ocube, only cube objects would be retrieved.
        item: typing.Optional[c4d.BaseList2D] = gui.GetLink(self._doc, c4d.Tbaselist2d)

        # Write the item into our data model and update the GUI.
        self.Items[index] = item
        self.PopulateDynamicGroup(isUpdate=True)

    def PrintItems(self) -> None:
        """Prints all items held by the dialog to the console.

        This is a custom method that is not a member of GeDialog.
        """
        for item in self.Items:
            print(item)

    def Command(self, cid: int, msg: c4d.BaseContainer) -> bool:
        """Called by Cinema 4D when the user interacts with a gadget.

        Args:
            cid (int): The id of the gadget which has been interacted with.
            msg (c4d.BaseContainer): The command data, not used here.

        Returns:
            bool: Success of the command.
        """
        # You could also put a lot of logic into this method, but for an example it might be better
        # to separate out the actual logic into methods to make things more clear.

        # The "Add Item" button has been clicked.
        if cid == self.ID_BTN_ADD:
            self.AddEmptyItem()
        # The "Remove Item" button has been clicked.
        elif cid == self.ID_BTN_REMOVE:
            self.RemoveLastItem()
        # The "Print Items" button has been clicked.
        elif cid == self.ID_BTN_PRINT:
            self.PrintItems()
        # One of the link boxes has received an interaction.
        elif (cid >= self.ID_ELEMENTS_START and (cid - self.ID_ELEMENTS_START) % 2 == self.ID_ELEMENT_LINK):
            self.UpdateItem(cid)

        return super().Command(cid, msg)

    def CoreMessage(self, cid: int, msg: c4d.BaseContainer) -> bool:
        """Called by Cinema 4D when a core event occurs.

        You could use this to automatically update the dialog when the selection state in the 
        document has changed. I did not flesh this one out.
        """
        # When "something" has happened in the, e.g., the selection has changed ...
        # if cid == c4d.EVMSG_CHANGE:
        # items: list[c4d.BaseList2D] = (
        #     self._doc.GetSelection() + self._doc.GetActiveMaterials() if self._doc else [])

        # newItems: list[c4d.BaseList2D] = list(n for n in items if n not in self._items)
        # self.Items = self.Items + newItems

        return super().CoreMessage(cid, msg)


class DialogManagerCommand (c4d.plugins.CommandData):
    """Provides an implementation for a command data plugin with a foldable dialog.

    This will appear as the entry "Dialog Manager Command" in the extensions menu.
    """
    ID_PLUGIN: int = 1060264  # The plugin ID of the command plugin.
    REF_DIALOG: typing.Optional[MyDialog] = None  # The dialog hosted by the plugin.

    def GetDialog(self, doc: typing.Optional[c4d.documents.BaseDocument] = None) -> MyDialog:
        """Returns a class bound MyDialog instance.

        Args:
            doc (typing.Optional[c4d.documents.BaseDocument], optional): The active document. 
                Defaults to None.

        This is a custom method that is not a member of CommandData.
        """
        # Get the union of all selected objects, tags, and materials in #doc or define the empty
        # list when doc is None. Doing it in this form is necessary, because GetState() will call
        # this method before Execute() and we only want to populate the dialog when the user invokes
        # the command.
        items: list[c4d.BaseList2D] = doc.GetSelection() + doc.GetActiveMaterials() if doc else []

        # Instantiate a new dialog when there is none.
        if self.REF_DIALOG is None:
            self.REF_DIALOG = MyDialog(items)
        # Update the dialog state when the current document selection state is different. This will
        # kick in when the user selects items, opens the dialog, closes the dialog, and changes the
        # selection. This very much a question of what you want, and one could omit doing this or
        # do it differently.
        elif doc is not None and self.REF_DIALOG.Items != items:
            self.REF_DIALOG.Items = items

        # Return the dialog instance.
        return self.REF_DIALOG

    def Execute(self, doc: c4d.documents.BaseDocument) -> bool:
        """Folds or unfolds the dialog.
        """
        # Get the dialog bound to this command data plugin type.
        dlg: MyDialog = self.GetDialog(doc)
        # Fold the dialog, i.e., hide it if it is open and unfolded.
        if dlg.IsOpen() and not dlg.GetFolding():
            dlg.SetFolding(True)
        # Open or unfold the dialog.
        else:
            dlg.Open(c4d.DLG_TYPE_ASYNC, self.ID_PLUGIN)

        return True

    def RestoreLayout(self, secret: any) -> bool:
        """Restores the dialog on layout changes.
        """
        return self.GetDialog().Restore(self.ID_PLUGIN, secret)

    def GetState(self, doc: c4d.documents.BaseDocument) -> int:
        """Sets the command icon state of the plugin.

        With this you can tint the command icon blue when the dialog is open or grey it out when
        some condition is not met (not done here). You could for example disable the plugin when
        there is nothing selected in a scene, when document is not in polygon editing mode, etc.
        """
        # The icon is never greyed out, the button can always be clicked.
        result: int = c4d.CMD_ENABLED

        # Tint the icon blue when the dialog is already open.
        dlg: MyDialog = self.GetDialog()
        if dlg.IsOpen() and not dlg.GetFolding():
            result |= c4d.CMD_VALUE

        return result


def RegisterDialogManagerCommand() -> bool:
    """Registers the example.
    """
    # Load one of the builtin icons of Cinema 4D as the icon of the plugin, you can browse the
    # builtin icons under:
    #   https://developers.maxon.net/docs/Cinema4DPythonSDK/html/modules/c4d.bitmaps/RESOURCEIMAGE.html
    bitmap: c4d.bitmaps.BaseBitmap = c4d.bitmaps.InitResourceBitmap(c4d.Tdisplay)

    # Register the plugin.
    return c4d.plugins.RegisterCommandPlugin(
        id=DialogManagerCommand.ID_PLUGIN,
        str="Dialog Manager Command",
        info=c4d.PLUGINFLAG_SMALLNODE,
        icon=bitmap,
        help="Opens a dialog with scene element link boxes in it.",
        dat=DialogManagerCommand())


# Called by Cinema 4D when this plugin module is loaded.
if __name__ == '__main__':
    if not RegisterDialogManagerCommand():
        raise RuntimeError(
            f"Failed to register {DialogManagerCommand} plugin.")