Treeview does not refresh

Hi guys,

I'm sorry to bother you again with questions regarding Treeviews. But I noticed that the Treeview is not refreshing when wrapping Cinemas native objects into a custom class for the Treeview to display.

Here's the code I'm using. It's a simple Treeview example made by Donovan Keith.

Just create some materials and execute the script. The Treeview should display those materials. You will however notice that it is not updating when you for example create new materials or delete existing ones.

One could argue why not simply use Cinemas native Baselist2D objects directly. The problem is that there seems to be an issue when it comes to selection states when using the native objects in the Treeview. As discussed here. With a custom class, selecting, adding, subtracting even shift-selecting works as expected. While using Baselist2D objects it does not.

Cheers,
Sebastian

"""ObjectBrowser
Based on: https://developers.maxon.net/?p=439"""

# ====== IMPORTS ====== #

import c4d


# ====== GLOBALS ====== #

debug = True

PLUGIN_ID = 1037588
PLUGIN_NAME = "Object Browser"
PLUGIN_HELP = "A simple treeview example"

class ListItem(object):
    """A wrapper class for c4d.BaseList2D."""

    def __init__(self, obj):
        self.obj = obj

    def GetName(self):
        return self.obj.GetName()

    def IsSelected(self):
        return self.obj.GetBit(c4d.BIT_ACTIVE)

    def Select(self):
        self.obj.SetBit(c4d.BIT_ACTIVE)

    def Deselect(self):
        self.obj.DelBit(c4d.BIT_ACTIVE)

    def IsOpened(self):
        return self.obj.GetBit(c4d.BIT_OFOLD)

# ====== TREEVIEW ====== #

class ObjectTree(c4d.gui.TreeViewFunctions):
    """Data structure for a TreeView of Materials & Shaders."""

    def __init__(self, dlg):
        self.items = []
        self.LoadObjects()

    def LoadObjects(self):

        doc = c4d.documents.GetActiveDocument()
        if not doc:
            return

        obj = doc.GetFirstMaterial()
        while obj:
            wrapped_obj = ListItem(obj)
            self.items.append(wrapped_obj)
            obj = obj.GetNext()

    def GetFirst(self, root, userdata):
        """Returns the first Material in the document."""

        if self.items:
            return self.items[0]

    def GetDown(self, root, userdata, obj):
        """Get the next shader in the list."""

        return None

    def GetNext(self, root, userdata, obj):
        """Get the next material/shader in the list."""

        if obj in self.items:
            obj_index = self.items.index(obj)
            next_index = obj_index + 1
            if next_index < len(self.items):
                return self.items[next_index]

    def GetPred(self, root, userdata, obj):
        if obj in self.items:
            obj_index = self.items.index(obj)
            prev_index = obj_index - 1
            if prev_index > 0:
                return self.items[prev_index]

    def GetName(self, root, userdata, obj):
        """Returns the name of obj."""

        if not obj:
            return

        return obj.GetName()

    def IsOpened(self, root, userdata, obj):
        """Returns True if obj is unfolded."""

        return obj.IsOpened()

    def IsSelected(self, root, userdata, obj):
        """Returns True if obj is selected."""

        return obj.IsSelected()

    def Select(self, root, userdata, obj, mode):
        """Selects `obj` based on `mode`."""

        if mode == c4d.SELECTION_NEW:
            for item in self.items:
                item.Deselect()
                if item == obj:
                    item.Select()
        elif mode == c4d.SELECTION_ADD:
            obj.Select()
        elif mode == c4d.SELECTION_SUB:
            obj.Deselect()

    def Open(self, root, userdata, obj, onoff):
        """Folds or unfolds obj based on onoff."""

        if not obj:
            return

        if onoff:
            obj.SetBit(c4d.BIT_OFOLD)
        else:
            obj.DelBit(c4d.BIT_OFOLD)

        c4d.EventAdd()

# ====== DIALOG ====== #

class ObjectBrowser(c4d.gui.GeDialog):
    """Dialog that contains a list of Materials & Shaders in the active document."""

    _tree_gui = None

    def CreateLayout(self):
        """Build the overall dialog layout."""

        self.SetTitle(PLUGIN_NAME)

        # Build the ShaderTree GUI Element
        tree_gui_settings = c4d.BaseContainer()
        tree_gui_settings.SetLong(c4d.TREEVIEW_BORDER, c4d.BORDER_THIN_IN)
        tree_gui_settings.SetBool(c4d.TREEVIEW_HAS_HEADER, True)
        tree_gui_settings.SetBool(c4d.TREEVIEW_HIDE_LINES, False)
        tree_gui_settings.SetBool(c4d.TREEVIEW_MOVE_COLUMN, True)
        tree_gui_settings.SetBool(c4d.TREEVIEW_RESIZE_HEADER, True)
        tree_gui_settings.SetBool(c4d.TREEVIEW_FIXED_LAYOUT, True)  # Don't allow Columns to be re-ordered
        tree_gui_settings.SetBool(c4d.TREEVIEW_ALTERNATE_BG, True)  # Alternate Light/Dark Gray BG
        tree_gui_settings.SetBool(c4d.TREEVIEW_CURSORKEYS, True)  # Process Up/Down Arrow Keys

        self._tree_gui = self.AddCustomGui(
            0,
            c4d.CUSTOMGUI_TREEVIEW,
            "",
            c4d.BFH_SCALEFIT | c4d.BFV_SCALEFIT,
            300,
            300,
            tree_gui_settings
        )

        return True

    def InitValues(self):
        """Set initial values when dialog is first opened."""

        tree_data = ObjectTree(self)
        self._tree_gui.SetRoot(None, tree_data, None)

        return True

    def CoreMessage(self, id, msg):

        if id == c4d.EVMSG_CHANGE:
            print("c4d.EVMSG_CHANGE")
            self._tree_gui.Refresh()

        return True


# ====== COMMAND ====== #

class ObjectBrowserCommand(c4d.plugins.CommandData):
    """Command that opens a ObjectTree dialog."""

    dlg = None

    def Execute(self, doc):
        if self.dlg is None:
            self.dlg = ObjectBrowser()

        return self.dlg.Open(
            dlgtype=c4d.DLG_TYPE_ASYNC,
            pluginid=PLUGIN_ID,
            xpos=-1,
            ypos=-1,
            defaultw=300,
            defaulth=500
        )

    def GetState(self, doc):
        return c4d.CMD_ENABLED

    def RestoreLayout(self, sec_ref):
        if self.dlg is None:
            self.dlg = ObjectBrowser()

        return self.dlg.Restore(PLUGIN_ID, secret=sec_ref)

def main():
    """Register the plugin with Cinema 4D."""

    global dialog
    dialog = ObjectBrowser()
    dialog.Open(
            c4d.DLG_TYPE_ASYNC,
            PLUGIN_ID,
            defaulth=300,
            defaultw=300
        )

if __name__ == "__main__":
    main()

Hello @herrmay,

Thank you for reaching out to us and no worries, we are here to answer your questions. That your material browser does not update is not too surprising, because you never update the data.

The classic API does not have a too granular event system and one is often forced to react to the very broad core message EVMSG_CHANGE which just conveys that something has changed in a scene, e.g., a material has been add, removed, renamed, etc. - or that the polygon object in the top left corner of the active camera changed its opinion about what is its favorite vertex :) So, EVMSG_CHANGE is being fired a lot and reacting to it directly can produce unwanted overhead or, when you do not care about overhead, can lead to unwanted behaviours when a system updates when it is not meant to update.

You implement a dialog here which receives such core messages via GeDialog.CoreMessage, you even use some code which has 90% of the work done. You just have to update you actual data when the message is being fired.

    def CoreMessage(self, id, msg):

        if id == c4d.EVMSG_CHANGE:
            # Reinitialize the object tree so that it reflects the new scene state. We could write
            # custom code or be lazy like me and reuse InitValues() :) . In practice you might want
            # to be a bit more selective about when you reinitialize your ObjectTree, as doing
            # so will flush the selection state etc. You could for example by caching a list of 
            # UUIDs of all materials and on every EVMSG_CHANGE check if something about this list
            # changed, i.e., if a material has been added or removed. If you would also track
            # the data container dirty count of each material in that list, you would have a pretty
            # water tight "on_material_data_change" event.
            #
            # The alternative would be to fashion your ObjectTree so that a running instance can
            # be updated with scene data. In both cases you will need a solid understanding of 
            # node UUIDs to identify materials as Python's id() will not work due to nodes being
            # reallocated. Here we talked about them in the context of materials:
            #   https://plugincafe.maxon.net/topic/14266/
            # You can find more UUID posting made by me under:
            #   https://plugincafe.maxon.net/search?term=UUID&in=titlesposts&by[]=ferdinand 
            self.InitValues()
            self._tree_gui.Refresh()

        return True

As lined out in the comment, there are more complex approaches to this. It might also be a good idea to read the Message System Manual

Cheers,
Ferdinand

Result
materials.gif

MAXON SDK Specialist
developers.maxon.net

Hello @ferdinand,

first of all thank you for your endless effort and the detailed explanations and solutions you always come up with.

I heard what you said regarding c4d.EVMSG_CHANGE being a possible but rather broad notification about changes in a scene. In theory I understand. In practice - well that's another story.

The problem with calling self.InitValues() on every change in a document is that I can not select items in the Treeview any longer because that in itself is a change which calls c4d.EVMSG_CHANGE. Which in turn invokes self.InitValues()again. I guess this is what you meant when you were talking about screwing up selection states.

I actually read the posts you mentioned about c4d.MAXON_CREATOR_ID prior to this thread and also about your fantastic example about "permanently" storing a collection of materials. I stole that part and implemented it in the code below to distinguish between changes.

My idea was to compare a stored collection with a newly created one. If they differ in length materials must have been created or removed. In addition to that I compare the dirty states of the collections. If they differ something in the data of a material must have changed which means a user changed some values. In both cases I update the Treeview. It works but to be honest neither do I know if this a viable solution nor if it is "the" right way to do it.

The problem is that every time the Treeview is updated the folding of items isn't respected. It makes sense because self.InitValues() calls the code with the initial state of the node. Which in my case is True. I struggle to find a solution as how to store the new state of the folding so that a potential update of the Treeview respects it.

@ferdinand I don't expect you or anyone to write complete solutions for me, but I'm more than thankful if someone can tell if my idea of comparing two collections is a good or maybe "the" way to go. If someone can also shed some light on the part folding part that would be awesome.

Thanks for reading this rather long babbling of mine.

Cheers,
Sebastian

#More information http://www.plugincafe.com/forum/forum_posts.asp?TID=14102&PID=56287#56287
import c4d
import weakref

# Be sure to use a unique ID obtained from http://www.plugincafe.com/.
PLUGIN_ID = 1000010 # TEST ID ONLY

# TreeView Column IDs.
ID_CHECKBOX = 1
ID_TYPE = 2
ID_NAME = 3


class MaterialCollection(object):
    """Handles a collection of materials from a singular document for long-term referencing.

    Will reestablish material references when possible and also handles a document reference.
    """

    def __init__(self, doc, collection):
        """Initializes the collection with a list of materials.
        """
        if not isinstance(collection, list):
            raise TypeError("{collection = }".format(collection=collection))
        if not isinstance(doc, c4d.documents.BaseDocument):
            raise TypeError("{doc = }".format(doc=doc))
        if not doc.IsAlive():
            raise RuntimeError("{doc = } is not alive.".format(doc=doc))

        # The list of materials, the list of markers for them, their associated document, and its
        # marker.
        self._materialCollection = []
        self._materialMarkerCollection = []
        self._materialDirtyCount = []
        self._doc = doc
        self._docMarker = MaterialCollection.GetUUID(doc)

        # Validate all materials and get their markers, we must do that before the reference is dead.
        for material in collection:
            if not isinstance(material, c4d.BaseMaterial) or not material.IsAlive():
                raise RuntimeError("{material} is not alive or not a BaseMaterial.".format(material=material))
            if material.GetDocument() is None:
                raise RuntimeError("Dangling material cannot be managed.")
            if material.GetDocument() != doc:
                raise RuntimeError("Material is not part of passed reference document.")

            # Append the material and its UUID marker to the internal lists.
            self._materialCollection.append(material)
            self._materialMarkerCollection.append(MaterialCollection.GetUUID(material))
            self._materialDirtyCount.append(material.GetDirty(c4d.DIRTYFLAGS_DATA))


    def __repr__(self):
        return "{}({})".format(self.__class__.__name__, len(self))


    def __len__(self):
        """Returns the number of managed materials.
        """
        return len(self._materialCollection)


    def __getitem__(self, index):
        """Returns the material at #index.
        """
        if len(self._materialCollection) - 1 < index:
            raise IndexError("The index '{index}' is out of bounds.".format(index=index))

        # The item is still alive, we can return it directly.
        item = self._materialCollection[index]
        if item.IsAlive():
            return item

        # --- The item is not alive anymore, try to find its new instance. -------------------------

        # Get the marker identifying the material at #index.
        materialMarker = self._materialMarkerCollection[index]

        # Iterate over all materials in #_doc to find one with a matching marker. We now call here
        # the property #Document instead of _doc, to also allow for reestablishing the document
        # reference.
        for material in self.Document.GetMaterials():
            otherMarker = MaterialCollection.GetUUID(material)

            # We found a match, update the internal data, and return the material.
            if materialMarker == otherMarker:
                self._materialCollection[index] = material
                return material

        # There is no material with this marker anymore.
        raise RuntimeError(
            "The material at {index} is not valid anymore and cannot be re-established.".format(inde=index))


    def __iter__(self):
        """Iterates over all materials in the collection.
        """
        for index in range(len(self._materialCollection)):
            yield self[index]


    @property
    def Document(self):
        """Returns the managed document reference.

        Will try to reestablish a document reference within the open documents when necessary.
        """
        # Return the still valid reference.
        if self._doc.IsAlive():
            return self._doc

        # Search all open documents for one with a UUID that matches self._docMarker.
        doc = c4d.documents.GetFirstDocument()
        while doc:
            docMarker = MaterialCollection.GetUUID(doc)
            if docMarker == self._docMarker:
                self._doc = doc
                return doc

            doc = doc.GetNext()

        # We could also search and load documents from #c4d.documents.GetRecentDocumentsList() here
        # but I did not do that, as it seems unlikely that you want to do that.

        raise RuntimeError(
                "A reference to the document UUID '{self._docMarker}' cannot be reestablished. "
                "The document has probably been closed.".format(self._docMarker))


    @property
    def Materials(self):
        """Returns an immutable collection of the internally managed materials.

        Might contain dangling material references, i.e., "dead" materials.
        """
        return tuple(self._materialCollection)


    @property
    def Markers(self):
        """Returns an immutable collection of the internally managed material markers.
        """
        return tuple(self._materialMarkerCollection)


    @property
    def Dirty(self):
        """Returns an immutable collection of the internally managed material dirty state.
        """
        return tuple(self._materialDirtyCount)


    @staticmethod
    def GetUUID(atom):
        """Retrieves the UUID of an atom as a bytes object.

        Args:
            item (c4d.C4DAtom): The atom to get the UUID hash for.

        Raises:
            RuntimeError: On type assertion failure.

        Returns:
            bytes: The UUID of #atom.
        """
        if not isinstance(atom, c4d.C4DAtom):
            raise TypeError(atom)

        # Get the MAXON_CREATOR_ID marker uniquely identifying the atom.
        uuid = atom.FindUniqueID(c4d.MAXON_CREATOR_ID)
        #if not isinstance(uuid, memoryview):
        #    raise RuntimeError("Illegal non-marked atom: {atom}".format(atom=atom))

        return bytes(uuid)


def iter_shaders(node):
    """Yields all descendants of ``node`` in a truly iterative fashion.

    The passed node itself is yielded as the first node and the node graph is
    being traversed in depth first fashion.

    This will not fail even on the most complex scenes due to truly
    hierarchical iteration. The lookup table to do this, is here solved with
    a dictionary which yields favorable look-up times in especially larger
    scenes but results in a more convoluted code. The look-up could
    also be solved with a list and then searching in the form ``if node in
    lookupTable`` in it, resulting in cleaner code but worse runtime metrics
    due to the difference in lookup times between list and dict collections.
    """
    if not node:
        return

    # The lookup dictionary and a terminal node which is required due to the
    # fact that this is truly iterative, and we otherwise would leak into the
    # ancestors and siblings of the input node. The terminal node could be
    # set to a different node, for example ``node.GetUp()`` to also include
    # siblings of the passed in node.
    visisted = {}
    terminator = node

    while node:

        #if isinstance(node, c4d.Material) and not node.GetFirstShader():
        #    break
        
        if isinstance(node, c4d.Material) and node.GetFirstShader():
            node = node.GetFirstShader()
        
        # C4DAtom is not natively hashable, i.e., cannot be stored as a key
        # in a dict, so we have to hash them by their unique id.
        node_uuid = node.FindUniqueID(c4d.MAXON_CREATOR_ID)
        if not node_uuid:
            raise RuntimeError("Could not retrieve UUID for {}.".format(node))

        # Yield the node when it has not been encountered before.
        if not visisted.get(bytes(node_uuid)):
            yield node
            visisted[bytes(node_uuid)] = True

        # Attempt to get the first child of the node and hash it.
        child = node.GetDown()

        if child:
            child_uuid = child.FindUniqueID(c4d.MAXON_CREATOR_ID)
            if not child_uuid:
                raise RuntimeError("Could not retrieve UUID for {}.".format(child))

        # Walk the graph in a depth first fashion.
        if child and not visisted.get(bytes(child_uuid)):
            node = child

        elif node == terminator:
            break

        elif node.GetNext():
            node = node.GetNext()

        else:
            node = node.GetUp()


def walk_shadertree(mat):
    
    for shader in iter_shaders(mat):
        
        parent = shader.GetUp() if shader.GetUp() else mat
        pred = shader.GetPred() if shader.GetPred() else None
        nxt = shader.GetNext() if shader.GetNext() else None
        children = shader.GetChildren() if shader.GetDown() else []

        yield {
            "node": shader.GetName(), 
            "parent": parent.GetName() if parent else parent, 
            "pred": pred.GetName() if pred else pred, 
            "nxt": nxt.GetName() if nxt else nxt, 
            "children": [child.GetName() for child in children]
        }


def NodeIterator(lst):

    for parent in lst:
        yield parent
        for child in NodeIterator(parent.GetChildren()):
            yield child


class Node(object):
    """Class which represent a an item in our Tree."""

    def __init__(self, obj):
        self.obj = obj
        self.children = []
        self._selected = False
        self._open = True
        self._parent = None

    def __repr__(self):
        return str(self)

    def __str__(self):
        return self.obj.GetName()

    @property
    def IsSelected(self):
        return self._selected

    def Select(self):
        self._selected = True
        self.obj.SetBit(c4d.BIT_ACTIVE)

    def Deselect(self):
        self._selected = False
        self.obj.DelBit(c4d.BIT_ACTIVE)

    @property
    def IsOpen(self):
        return self._open

    def Open(self):
        self._open = True
        self.obj.DelBit(c4d.BIT_OFOLD)

    def Close(self):
        self._open = False
        self.obj.SetBit(c4d.BIT_OFOLD)

    def AddChild(self, obj):
        obj._parent = weakref.ref(self)
        self.children.append(obj)

    def GetChildren(self):
        return self.children

    def GetParent(self):
        if self._parent:
            return self._parent()
        return None

    def GetName(self):
        return self.obj.GetName()

    def GetTypeName(self):
        return self.obj.GetTypeName()

    def SetName(self, name):
        self.obj.SetName(name)


def recurse_shader(shader, obj):
    # Not really happy with this algorithm.
    # It works but the recursion is bothering me.

    if shader.GetDown():
        shader = shader.GetDown()

        while shader:
            child = Node(shader)
            obj.AddChild(child)

            if shader.GetDown():
                recurse_shader(shader, child)

            shader = shader.GetNext()


class ShaderBrowser(c4d.gui.TreeViewFunctions):

    def __init__(self, dlg):
        self._dlg = weakref.ref(dlg)


    def Load(self):

        doc = c4d.documents.GetActiveDocument()
        self.nodes = []
        self.collection = MaterialCollection(doc, doc.GetMaterials())

        for material in self.collection:
            shader = material.GetFirstShader()
            root = Node(material)

            while shader:
                child = Node(shader)
                root.AddChild(child)
                recurse_shader(shader, child)
                shader = shader.GetNext()

            self.nodes.append(root)


    def IsResizeColAllowed(self, root, userdata, lColID):

        if lColID == ID_NAME:
            return True

        return False


    def IsTristate(self, root, userdata):
        return False


    def GetColumnWidth(self, root, userdata, obj, col, area):
        """Measures the width of cells.
        Although this function is called #GetColumnWidth and has a #col, it is
        not only executed by column but by cell. So, when there is a column
        with items requiring the width 5, 10, and 15, then there is no need
        for evaluating all items. Each item can return its ideal width and
        Cinema 4D will then pick the largest value.

        Args:
            root (any): The root node of the tree view.
            userdata (any): The user data of the tree view.
            obj (any): The item for the current cell.
            col (int): The index of the column #obj is contained in.
            area (GeUserArea): An already initialized GeUserArea to measure
             the width of strings.

        Returns:
            TYPE: Description
        """
        # The default width of a column is 80 units.
        width = 80
        # Replace the width with the text width. area is a prepopulated
        # user area which has already setup all the font stuff, we can
        # measure right away.

        if col == ID_TYPE:
            ICON_SIZE = 16
            TEXT_SPACER = 6
            return area.DrawGetTextWidth(obj.GetTypeName()) + ICON_SIZE + TEXT_SPACER + 5

        if col == ID_NAME:
            return area.DrawGetTextWidth(obj.GetName()) + 5

        return width


    def IsMoveColAllowed(self, root, userdata, lColID):
        # The user is allowed to move all columns.
        # TREEVIEW_MOVE_COLUMN must be set in the container of AddCustomGui.
        return True


    def GetFirst(self, root, userdata):
        """
        Return the first element in the hierarchy, or None if there is no element.
        """

        return None if not self.nodes else self.nodes[0]


    def GetDown(self, root, userdata, obj):
        """
        Return a child of a node, since we only want a list, we return None everytime
        """
        children = obj.GetChildren()
        if children:
            return children[0]

        return None


    def GetNext(self, root, userdata, obj):
        """
        Returns the next Object to display after arg:'obj'
        """

        parent = obj.GetParent()
        nodes = parent.GetChildren() if parent is not None else self.nodes
        indx = nodes.index(obj)
        nxt = indx + 1

        return nodes[nxt] if nxt < len(nodes) else None


    def GetPred(self, root, userdata, obj):
        """
        Returns the previous Object to display before arg:'obj'
        """

        parent = obj.GetParent()
        nodes = parent.GetChildren() if parent is not None else self.nodes
        indx = nodes.index(obj)
        prev = indx - 1

        return nodes[prev] if 0 <= prev < len(nodes) else None


    def GetId(self, root, userdata, obj):
        """
        Return a unique ID for the element in the TreeView.
        """
        return hash(obj)


    def Select(self, root, userdata, obj, mode):
        """
        Called when the user selects an element.
        """
        doc = c4d.documents.GetActiveDocument()
        doc.StartUndo()

        if mode == c4d.SELECTION_NEW:
            for node in NodeIterator(self.nodes):
                node.Deselect()

            obj.Select()

        elif mode == c4d.SELECTION_ADD:
            obj.Select()

        elif mode == c4d.SELECTION_SUB:
            obj.Deselect()

        doc.EndUndo()
        c4d.EventAdd()


    def IsSelected(self, root, userdata, obj):
        """
        Returns: True if *obj* is selected, False if not.
        """
        return obj.IsSelected


    def SetCheck(self, root, userdata, obj, column, checked, msg):
        """
        Called when the user clicks on a checkbox for an object in a
        `c4d.LV_CHECKBOX` column.
        """
        if checked:
            obj.Select()
        else:
            obj.Deselect()


    def IsChecked(self, root, userdata, obj, column):
        """
        Returns: (int): Status of the checkbox in the specified *column* for *obj*.
        """
        if obj.IsSelected:
            return c4d.LV_CHECKBOX_CHECKED | c4d.LV_CHECKBOX_ENABLED
        else:
            return c4d.LV_CHECKBOX_ENABLED


    def IsOpened(self, root, userdata, obj):
        """
        Returns: (bool): Status If it's opened = True (folded) or closed = False.
        """

        return obj.IsOpen


    def Open(self, root, userdata, obj, onoff):
        """
        Called when the user clicks on a folding state of an object to display/hide its children
        """

        doc = obj.obj.GetDocument()
        doc.StartUndo()
        doc.AddUndo(c4d.UNDOTYPE_CHANGE_SMALL, obj.obj)

        if onoff:
            obj.Open()
        else:
            obj.Close()

        doc.EndUndo()


    def GetName(self, root, userdata, obj):
        """
        Returns the name to display for arg:'obj', only called for column of type LV_TREE
        """
        return str(obj)


    def DrawCell(self, root, userdata, obj, col, drawinfo, bgColor):
        """
        Draw into a Cell, only called for column of type LV_USER
        """

        if col == ID_TYPE:
            ICON_SIZE = drawinfo["height"]
            TEXT_SPACER = 6

            icon = obj.obj.GetIcon()
            drawinfo["frame"].DrawBitmap(
                icon["bmp"], drawinfo["xpos"], drawinfo["ypos"],
                16, 16, icon["x"], icon["y"], icon["w"], icon["h"], c4d.BMP_ALLOWALPHA)

            name = obj.GetTypeName()
            geUserArea = drawinfo["frame"]
            w = geUserArea.DrawGetTextWidth(name)
            h = geUserArea.DrawGetFontHeight()
            xpos = drawinfo["xpos"] + ICON_SIZE + TEXT_SPACER
            ypos = drawinfo["ypos"] + drawinfo["height"]
            drawinfo["frame"].DrawText(name, int(xpos), int(ypos - h * 1.1))


    def DoubleClick(self, root, userdata, obj, col, mouseinfo):
        """
        Called when the user double-clicks on an entry in the TreeView.

        Returns:
          (bool): True if the double-click was handled, False if the
            default action should kick in. The default action will invoke
            the rename procedure for the object, causing `SetName()` to be
            called.
        """

        if col == ID_NAME:
            return False

        if col == ID_TYPE:
            mode = (c4d.ACTIVEOBJECTMODE_SHADER
                if isinstance(obj.obj, c4d.BaseShader) else c4d.ACTIVEOBJECTMODE_MATERIAL
            )
            c4d.gui.ActiveObjectManager_SetObject(
                id=mode,
                op=obj.obj,
                flags=c4d.ACTIVEOBJECTMANAGER_SETOBJECTS_OPEN,
                activepage=c4d.DescID()
            )
            return True

        return True


    def SetName(self, root, userdata, obj, name):
        """
        Called when the user renames the element. `DoubleClick()` must return
        False for this to work.
        """

        doc = c4d.documents.GetActiveDocument()
        doc.StartUndo()
        doc.AddUndo(c4d.UNDOTYPE_CHANGE_SMALL, obj.obj)
        obj.SetName(name)
        doc.EndUndo()
        c4d.EventAdd()


    def DeletePressed(self, root, userdata):
        "Called when a delete event is received."

        doc = c4d.documents.GetActiveDocument()
        doc.StartUndo()

        for node in reversed(list(NodeIterator(self.nodes))):
            if not node.IsSelected:
                continue

            parent = node.GetParent()
            nodes = parent.GetChildren() if parent is not None else self.nodes
            nodes.remove(node)
            doc.AddUndo(c4d.UNDOTYPE_DELETE, node.obj)
            node.obj.Remove()

        doc.EndUndo()
        c4d.EventAdd()


class ShaderBrowserDialog(c4d.gui.GeDialog):

    def CreateLayout(self):

        self.SetTitle("Shader Browser")

        customgui = c4d.BaseContainer()
        customgui.SetBool(c4d.TREEVIEW_BORDER, c4d.BORDER_THIN_IN)
        customgui.SetBool(c4d.TREEVIEW_HAS_HEADER, True) # True if the tree view may have a header line.
        customgui.SetBool(c4d.TREEVIEW_HIDE_LINES, False) # True if no lines should be drawn.
        customgui.SetBool(c4d.TREEVIEW_MOVE_COLUMN, False) # True if the user can move the columns.
        customgui.SetBool(c4d.TREEVIEW_RESIZE_HEADER, True) # True if the column width can be changed by the user.
        customgui.SetBool(c4d.TREEVIEW_FIXED_LAYOUT, True) # True if all lines have the same height.
        customgui.SetBool(c4d.TREEVIEW_ALTERNATE_BG, True) # Alternate background per line.
        customgui.SetBool(c4d.TREEVIEW_CURSORKEYS, True) # True if cursor keys should be processed.
        customgui.SetBool(c4d.TREEVIEW_NOENTERRENAME, False) # Suppresses the rename popup when the user presses enter.

        self._treegui = self.AddCustomGui(1000, c4d.CUSTOMGUI_TREEVIEW, "", c4d.BFH_SCALEFIT | c4d.BFV_SCALEFIT, 300, 150, customgui)

        return True

    def InitValues(self):

        layout = c4d.BaseContainer()
        layout.SetLong(ID_CHECKBOX, c4d.LV_CHECKBOX)
        layout.SetLong(ID_NAME, c4d.LV_TREE)
        layout.SetLong(ID_TYPE, c4d.LV_USER)
        self._treegui.SetLayout(3, layout)

        self._treegui.SetHeaderText(ID_CHECKBOX, "Check")
        self._treegui.SetHeaderText(ID_TYPE, "Type")
        self._treegui.SetHeaderText(ID_NAME, "Name")

        self._browser = ShaderBrowser(self)
        self._browser.Load()
        
        self._treegui.SetRoot(self._treegui, self._browser, None)
        self._treegui.Refresh()

        return True


    def CoreMessage(self, id, msg):

        if id == c4d.EVMSG_CHANGE:

            doc = c4d.documents.GetActiveDocument()
            materials = doc.GetMaterials()
            collection = MaterialCollection(doc, materials)
            nodes = self._browser.nodes[:]
            node_uuids = {bytes(node.obj.FindUniqueID(c4d.MAXON_CREATOR_ID)): node for node in nodes}

            # Compare the stored length of Material Collection to a newly allocated.
            # If they dont match a material must have been added or removed.
            # Better update the Treeview.
            if len(self._browser.collection.Markers) != len(collection.Markers):
                print("Material must have been added or removed.")
                self.InitValues()
                self._treegui.Refresh()

            # Compare the dirty states of the stored Material Collection with a newly allocated.
            # If the dirts states dont match something in the container of at least one material
            # must have been changed. Better update the Treeview.
            olddirty = self._browser.collection.Dirty
            for dirty in collection.Dirty:
                if dirty not in olddirty:
                    print("something must have changed")
                    self.InitValues()
                    # self._browser.Load()
                    self._treegui.Refresh()
                    break

        return c4d.gui.GeDialog.CoreMessage(self, id, msg)


def main():

    global dialog
    dialog = ShaderBrowserDialog()
    dialog.Open(c4d.DLG_TYPE_ASYNC, defaulth=600, defaultw=600)


if __name__ == "__main__":
    main()

Hello @herrmay,

The problem with calling self.InitValues() on every change in a document is that I can not select items in the Treeview any longer because that in itself is a change which calls c4d.EVMSG_CHANGE. Which in turn invokes self.InitValues() again. I guess this is what you meant when you were talking about screwing up selection states.

No, I was not thinking of this kind of feedback loop in particular, but you are encountering here the frequent problem of being too loose with EVMSG_CHANGE and then landing in a feedback loop of your own changes.

I actually read the posts you mentioned about c4d.MAXON_CREATOR_ID prior to this thread ...

Yes, you did things correctly. Although I would say the solution is a bit overengineered in this case, you do not really need a managing interface here. You were also missing the part where you track the data dirtyness of materials. You could also expand the whole thing to shaders if you wanted to, but I think there are no cases where a shader is dirty without its material not being dirty, but I might be wrong. If so, you would have also to track shaders. My less fancy solution would be:

class ShaderBrowserDialog(c4d.gui.GeDialog):

    # (f_hoppe): Start of changes
    def __init__(self) -> None:
        """
        """
        # Stores the material scene state managed by the current tree view instance for dirty comparisons
        self._materialStateCache: set[tuple(bytes, int)] = {}
        super().__init__()
    # (f_hoppe): End of changes

    # ...

    # (f_hoppe): Start of changes
    def IsMaterialDirty(self) -> bool:
        """Returns if the state of all materials in the active document is different from the
        state currently tracked by the tree view.

        Will also update the tracked state.
        """
        doc: c4d.documents.BaseDocument = c4d.documents.GetActiveDocument()
        materialState: set[tuple(bytes, int)] = {
            (bytes(mat.FindUniqueID(c4d.MAXON_CREATOR_ID)), mat.GetDirty(c4d.DIRTYFLAGS_DATA))
            for mat in doc.GetMaterials()
        }
        res: bool = materialState != self._materialStateCache
        self._materialStateCache = materialState

        return res


    def CoreMessage(self, id, msg):
        """
        """
        if id == c4d.EVMSG_CHANGE and self.IsMaterialDirty():
            print ("Updating material data.")
            self.InitValues()

        return c4d.gui.GeDialog.CoreMessage(self, id, msg)
    # (f_hoppe): End of changes

The problem is that every time the Treeview is updated the folding of items isn't respected.

That was what I meant by having problems with selection states :) In the previous example, we dealt with BaseObject instances which naturally have a folding state which is stored in the actual (object) node itself. In this code, you use Node._open in the tree node type to effectively store that state. When you throw away all the data on a "something is dirty about materials"-event, you also throw away that folding state. You are also still using here the folding flag of nodes BIT_OFOLD but in the crucial bit, your IsOpen(), you are not relying on it.

   def __init__(self, obj):
       self.obj = obj
       self. Children True

    @property
    def IsOpen(self):
        return self._open

    def Open(self):
        self._open = True
        self.obj.DelBit(c4d.BIT_OFOLD)

    def Close(self):
        self._open = False
        self.obj.SetBit(c4d.BIT_OFOLD)

I went here for a fix which stores arbitrary data in a global lookup table over node UUIDs as this a more universal fix. One could also fix this with folding bits to have the data stored in the materials and shaders themselves (I think, did not try). My fix is also a bit from the tribe of "sledgehammer-fixes", as g_isopen_states will grow over the lifetime of a Cinema 4D instance, and could end up taking up a few kilobytes if you really go to town with materials and shaders in a scene. More fancy book keeping could help but is not worth the hassle IMHO.

class Node(object):
    """Class which represent a an item in our Tree."""
    # (f_hoppe): Start of changes

    # A class bound look up table for the folding state of all ever encountered unique C4DAtom 
    # instances passed to Node.__init__ as `obj`.
    g_isopen_states: dict[bytes, bool] = {}

    def __init__(self, obj):
        self.obj: c4d.C4DAtom = obj
        self.children = []
        self._selected = False
        self._parent = None

        # Hash the node into its UUID.
        self._uuid: bytes = bytes(self.obj.FindUniqueID(c4d.MAXON_CREATOR_ID))

        # Not needed anymore.
        # self._open = False

    # (f_hoppe): End of changes

    # ...

    # (f_hoppe): Start of changes

    @property
    def IsOpen(self):
        """Returns the folding state of the node.
        
        The state is stored over the hash of the attached atom on the class interface. Not the most
        elegant design, but it will work :)
        """
        state: bool = Node.g_isopen_states.get(self._uuid, None)
        if state is None:
            Node.g_isopen_states[self._uuid] = False
            return False

        return state

    def Open(self):
        state: bool = Node.g_isopen_states.get(self._uuid, None)
        state = False if state is None else not state
        Node.g_isopen_states[self._uuid] = state

    def Close(self):
        Node.g_isopen_states[self._uuid] = False

    # (f_hoppe): End of changes

Cheers,
Ferdinand

File: shader_viewp.py
I have fenced in my changes with (comments marked by f_hoppe).

Result:

We see the selection states being maintained due to being stored over UUIDs and also the data model of the tree view updating when one of the materials becomes data dirty by for example a paramater being changed.

shader_browser_2.gif

MAXON SDK Specialist
developers.maxon.net

Hello @ferdinand,

sorry for coming back this late. Work kept me quite busy. :face_with_rolling_eyes: :smile:

@ferdinand so, I had a play with your code example. It works like a charm and also seems to have a lot of potential for all kinds of update/dirty checking issues.

Thanks again @ferdinand for your help, the effort you always put into answering our questions and the eloborate examples you come up with!

Cheers,
Sebastian