In the same way that the cello::Value class corresponds directly with a single property in a ValueTree, the cello::Object class corresponds directly with a ValueTree itself:

  • it has a juce::Identifier specifying its type
  • it has zero or more properties, most likely (but not necessarily) represented by Value objects
  • it may have a parent
  • its ValueTree may have child ValueTrees that might have associated Object classes defined for them

In addition to the attributes listed above, the Object class contains additional features for power and convenience:

  • Undo/Redo management for an entire hierarchy of Objects can be handled automatically
  • Persistence to/from disk as XML (text), XML (zipped), or JUCE’s propeietary binary format
  • Simple database functionality: searching, sorting, adding, and updating records in place
  • Typesafe API for dynamic ‘Pythonesque’ property access

Warning

This document may be out of date with the current state of the released library; always check the latest source.

Construction

And if a bird can speak, who once was a dinosaur
And a dog can dream; should it be implausible?
That a man might supervise the construction of light
The construction of light
King Crimson, “The ConstruKction of Light”

There’s a fairly significant difference to keep in mind when working with cello::Objects compared to regular C++ objects: your Object outsources some or all of its storage of state to a ValueTree, and that ValueTree is likely to be a node that’s part of a larger hierarchy and that node knows about its parent tree and any child trees that it has. This opens up some unusual patterns for object creation that make things simpler and more flexible once you start passing your entire application state around freely inside the code.

There are two basic modes of construction that an Object can support:

  • Creation The Object creates and initializes a ValueTree.
  • Wrapping The Object is given an existing ValueTree or Object and ‘wraps’ it as its source of state storage.

Creating

The base class constructor that supports creation is this:

Object::Object (const juce::String& type, const Object* state)

// or alternately, initialize from a raw ValueTree 
Object::Object (const juce::String& type, juce::ValueTree tree);

Passing in a nullptr as the state argument here will tell Cello that it should create a new ValueTree of type type and use that as its storage. Once that’s complete, any classes derived from Object will be constructed, using that new ValueTree as their storage; any Values in those classes will be added to the tree and initialized automatically as part of the construction sequence, leaving you with an initialized Object that’s ready to use.

If you pass in an existing Object to this constructor, Cello will wrap it instead, and this is where things get interesting.

Wrapping

There are two main modes (and a third advanced mode that we’ll ignore for now) that wrapping an Object works:

Direct Wrapping

The base Object constructor checks the type of the ValueTree that it was given as an argument. If that type matches the type that it’s being told to create, it sets its internal data ValueTree member to use that ValueTree as its storage (as-is—none of the Values in the Object will be re-initialized), and the resulting Object is ready to use.

Child Wrapping

Let’s assume that you’ve got a ValueTree that uses this hierarchy:

<parent>
    <child1 prop1="1", prop2="2" />
    <child2 prop3="3" />
</parent>

…and there are corresponding Parent, Child1, Child2, and (also) Child3 Cello classes defined.

My usual practice is to pass around an Object (by reference) that’s the root of my app’s state hierarchy and then use that to get access to any of the child Objects that are needed. If this had been written using plain C++, you’d need to do something to accommodate that, whether it’s making the child objects public or adding methods like Parent::getChild1() to retrieve them.

With Cello, it’s built into the base Object constructor:

  • when passed an Object that is of the same type as that being created, we wrap its ValueTree.
  • when passed a Parent but we’re trying to create a Child, we check the parent’s ValueTree for a child tree of that type.
  • If a child ValueTree of that type is present, we wrap it and use it.
  • If there’s no child of the type we’re looking for, we create one, initialize it, and append it as a child to the parent ValueTree.

The Object class can tell you which of these paths an instance took to become created using the enum and member functions:

enum class CreationType
{
    initialized,    // this object was default initialized 
                    // when created.
    wrapped         // this object wrapped an existing tree.
};

/**
    * @brief Determine how this object was created, which will be
    *        one of:
    * * CreationType::initialized -- All values were
    *   default-initialized
    * * CreationType::wrapped -- this object refers to a value
    *   tree that already existed
    *
    * It might be an error in your application to expect one or
    * the other and not find it at runtime.
    *
    * @return CreationType
    */
CreationType getCreationType () const { return creationType; }

/**
    * @brief utility method to test the creation type as a bool.
    * @return true if this object was created by wrapping an 
                existing tree.
    */
bool wasWrapped () const { return creationType == CreationType::wrapped; }

/**
    * @brief utility method to test the creation type as a bool.
    * @return true if this object was created by default initialization.
    */
bool wasInitialized () const { return creationType == CreationType::initialized; }

If it’s not immediately apparent why you’d be interested in this—it can be useful when developing and debugging to ensure that when you’re creating an Object from its parent (as you’ll frequently do when passed the root Object that contains all app state) it’s useful to be certain that the child tree for the Object you need had already been created, initialized, and is at the location in the tree where you expect it to be.

So, you might have code that creates local Objects from your application state tree that looks like:

void myFunction (Parent& parent)
{
    // Direct wrapping -- we were passed what we are creating.
    Parent localParent { parent };
    jassert (localParent.wasWrapped ());

    // Child wrapping -- get the child1 tree and wrap it:
    Child1 child1 { parent };
    jassert (child1.wasWrapped ());

    // Initialize a `Child3` object -- it's not in the parent tree 
    Child3 child3 { parent };
    jassert (child3.wasInitialized ());
}

After this block of code runs, the XML representation of our state will look like this, with the newly added and initialized child3 node.

<parent>
    <child1 prop1="1", prop2="2" />
    <child2 prop3="3" />
    <child3 prop4="initVal" />
</parent>

Load from File

Objects can be persisted to file between runs of the application. To create an Object hierarchy from disk, use this constructor:

/**
 * @brief Construct a new Object by attempting to load it from 
 * a file on disk. You can test whether this succeeded by checking 
 * the return value of `getCreationType()` -- if its value is 
 * `CreationType::initialized`, the load from disk failed, and this 
 * instance was default-initialized.
 *
 * @param type
 * @param file
 * @param format
 */
Object (const juce::String& type, juce::File file, FileFormat format = FileFormat::xml);

where the options for FileFormat are:

  • FileFormat::xml – save as plain text XML
  • FileFormat::binary – use JUCE’s proprietary binary encoding
  • FileFormat::zipped – GZIPped JUCE binary.

You can also load data from a file into an already existing Object using the load() member function.

Save to File

To write an Object and its children to disk:

/**
 * @brief Save the object tree to disk.
 *
 * @param file
 * @param format one of (xml, binary, zipped)
 * @return Result of the save operation.
 */
juce::Result save (juce::File file, FileFormat format = FileFormat::xml) const;

Clone an Existing Object

Sometimes it’s useful to be able to duplicate an existing Object that isn’t connected to the same ValueTree internally. For example, when we treat an Object as a database, a common pattern is:

  1. clone the object
  2. make changes to each of the cloned child objects
  3. update the original objects in bulk

The method

juce::ValueTree clone (bool deep) const;

lets you perform this duplication, returning a new ValueTree that contains copies of an Objects properties, and (optionally) all of its children and descendants.

Operators

operator == and !=

Tests for object equivalence of the underlying ValueTree, not for equivalence of properties. The usefulness here is knowing that if celloObject1 == celloObject2 then any change made to either one will be reflected in the other since they share storage.

operator =

Copy all of the properties and children from a different object into this one. If you want to instead share storage, use the cello::Object::wrap() method instead. Given the above equivalency test operators:

MyCelloObject original;
MyCelloObject wrapped { original };
// constructing wrapped like this ensures that they share storage. 
jassert (original == wrapped);

MyCelloObject other;
other = original;
// assignment copies properties, not same storage. 
jassert (other != original);

other.wrap (original);
// wrapping uses the same ValueTree internally. 
jassert (other == original);

operator juce::ValueTree

Return the ValueTree used by this object. This operator lets you seamlessly use an Object any place that an API function is expecting a ValueTree.

operator []

Retrieve a child from the Object by its index, as a ValueTree. If the requested index value is out of range, will return an invalid ValueTree.

Working with Children

ValueTrees can contain other ValueTrees as children, and it’s important to keep in mind that there are two different modes for this containment:

  • Heterogeneous The parent tree is a data structure that contains other (tree) data structures. Access the children by specifying their type. The children are stored in a list, but the sequence is not significant.
  • Homogeneous The parent tree contains a list of child trees, typically but not necessarily of the same type. Access the children by their index or iterating through them.

There’s no mechanism to enforce this distinction—if a list of different types makes sense in your application, there’s a little more logic you’ll need to write, but that’s all.

Adding Child Objects

void append (Object* object); adds the child to the end of this object’s child list.

void insert (Object* object, int index); adds the child at a specific index in the list; if index is out of range (less than zero or greater than the current number of children), the child will be appended to the list.

If an onChildAdded callback has been registered with any Objects listening to this ValueTree, they will be called.

Moving Child Objects

To change the position of a child within the list, call move with the child’s current index and its new index.

/**
 * @brief Change the position of one of this object's children
 *
 * @param fromIndex
 * @param toIndex
 */
void move (int fromIndex, int toIndex);

If fromIndex is out of range, the function does nothing.
If toIndex is less than zero, will move the child to the end of the list.
Any children after toIndex will be moved to make room for this child.

If an onChildMoved callback has been registered with any Objects listening to this ValueTree, they will be called.

Deleting Child Objects

By Index

To remove a child given its index:

juce::ValueTree remove (int index);

…which will return the ValueTree that was removed. If index was out of range, will return an invalid ValueTree.

By Object

If you have a pointer to a child cello::Object, call

Object* remove (Object* object);

…which will return a pointer to the Object if found, or nullptr if no such child exists.

For each of the above, if an onChildRemoved callback has been registered with any Objects listening to this ValueTree, they will be called.

Iterating and Accessing by Index

The Object class defines begin() and end() iterators (actually, we just expose the iterators that an Object’s ValueTree already exposes), so you can pass them to any algorithm that can work with them.

You may also (as shown above) access individual child trees using operator [], which will return the ValueTree at the requested index, which will be an invalid tree if the index is out of range.

Searching

Updating and Inserting

Sorting

Undo/Redo Management

A tedious (and therefore frequently error-prone) part of working with the ValueTree API is making sure that each call that modifies a tree and that should be undoable includes a pointer to a juce::UndoManager object that will maintain the undo/redo stack. There are a few obvious approaches to this, including passing that pointer around any time you pass a ValueTree into a function, or creating some singleton UndoManager that’s visible anywhere in your app, or replacing the direct use of ValueTrees with some new object that contains both a ValueTree and the UndoManager that should be used with it and suddenly the reason that so many people have started working on some variation of this “ValueTrees, but easier to use” project becomes clearer.

In Cello, passing an Object a (non-owning) pointer to an UndoManager will make all operations that mutate the wrapped ValueTree use that UndoManager, and will also use the same UndoManager for mutating operations on all Objects that are children of that object. Set it once and forget about it.

Since I usually create a top-level Object at the Application level that’s created immediately on app launch and remains alive until app shutdown, the pattern is to declare an UndoManager instance that has the same lifetime and pass it immediately to the root Object.

All of the operations that your code will want to perform using the UndoManager (undo, redo, clearUndoHistory, etc.) are exposed through any child object in the hierarchy.

Change Notifications

The real power of using ValueTrees is the ability to subscribe to a set of notification callbacks that are triggered by important changes being made to properties or the tree itself; Cello makes working with these change notifications significantly easier.

Property Change

My usual practice is to subscribe to Property Change notifications on individual Value objects, but you can also subscribe to them through an Object.

There are two subscription mechanisms in Object:

  1. void onPropertyChange (juce::Identifier id, PropertyUpdateFn callback); lets you specify any Identifier of a property that you wish to be notified of changes to. If you’re using raw ValueTree API calls elsewhere in your code, or using the setattr() mechanism described below, this Identifier does not need to be one that’s in use by a Value object. If you use the Identifier of the Objects type, you will receive a notification any time a property changes that does not have its own update callback registered for it.
  2. As shorthand for this generic “update me when anything in this Object changes”, you can instead use the function void onPropertyChange (PropertyUpdateFn callback).

I’ve found this generic callback to be helpful for cases like this: in an app, I have an Object that stores all of the settings that need to be persisted between runs of the application – window size and location, recently used files for the MRU list in the menu, etc. A single update callback fires whenever any of the settings change, which sets a boolean dirty flag. A timer that elapses every few seconds checks the flag, and when there’s been an update, we write the current settings to disk.

The signature of the PropertyUpdateFn function is std::function<void (const juce::Identifier&)>, where the argument will contain the Identifier of the property that changed.

Forced Updates

As with individual Value objects, you can set an entire Object so that update callbacks are sent whenever any of its properties are set, whether the property is being set to a different actual value or not.

You may also pass an Object to an instance of the ScopedForceUpdater class to limit the duration of this forced update state to the current scope.

Child Added / Moved / Removed

You can register callbacks (onChildAdded, onChildRemoved, onChildMoved) to be notified when a child tree is added, removed, or re-ordered within the parent Object that you’re watching. All three of these callbacks share the same signature:

using ChildUpdateFn = std::function<void (juce::ValueTree& child, int oldIndex, int newIndex)>;

The Object base class contains three public members of this type that you can assign your callback to (or set to nullptr to remove a callback):

    ChildUpdateFn onChildAdded;
    ChildUpdateFn onChildRemoved;
    ChildUpdateFn onChildMoved;

In the added/removed cases, only one index value is meaningful, so we will pass the callback a constant -1 to indicatate that the index value may be ignored:

Event oldIndex newIndex
Child Added -1 new index
Child Removed new index -1
Child Moved old index new index

Also, note that the callback will be passed a raw ValueTree as an argument containing the affected child. You’ll need to convert this to an Object-derived class yourself if you wish to use it as one.

Parent Changed

Register a callback with the signature

using SelfUpdateFn = std::function<void (void)>;

to the public member

SelfUpdateFn onParentChanged;

to be notified when the Object you’re watching has been added as a child to a new parent.

Tree Redirected

Register a callback with the signature

using SelfUpdateFn = std::function<void (void)>;

to the public member

SelfUpdateFn onTreeRedirected;

to be notified when the Object you’re watching has had its ValueTree replaced with another.

pythonesque’ Property Access

Not all data is or can be known at compile-time. the Object class includes a set of methods that end in -attr that are inspired by similar methods found in Python objects, and provide access to the full dynamic runtime ability of ValueTrees to add, remove, and explore available pieces of data available in an object while also providing a layer of Cello convenience on top (property update callbacks, undo/redo management, implicit passing through VariantConverters). Because these on-the-fly values don’t have Cello Value objects to represent them, there’s no provision for get/set validation functions.

hasattr()

   /**
    * @brief test the object to see if it has an attribute with this id.
    *
    * @param attr
    */
bool hasattr (const juce::Identifier& attr) const;

delattr()

   /**
    * @brief Remove the specified property from this object.
    * @param attr Identifier of the property to delete. 
    */
void delattr (const juce::Identifier& attr);

Delete a property from the underlying ValueTree given its Identifier. If there’s no such property does nothing.

Deleting a property that’s represented by a Value and then attempting to retrieve the data from that property will give you garbage. Don’t do that.

setattr()

   /**
    * @brief Set a new value for the specified attribute/property.
    * We return a reference to this object so that setattr calls
    * may be chained.
    * @tparam T
    * @param attr identifier of the property
    * @param attrVal new value to set. 
    * @return a reference to this Object.
    */
template <typename T> Object& setattr (const juce::Identifier& attr, const T& attrVal);

Sets the value of a property, whether it already exists or not. If this property is also represented by a Value object, this will not call its onSet() validation function if one is defined.

If a propertyChange callback has been registered for this identifier, it will be invoked when the value of the property changes.

getattr()

    /**
     * @brief Get a property value from this object, or default if it
     * doesn't have a property with that name.
     *
     * @tparam T
     * @param attr
     * @param defaultVal
     * @return T
     */
    template <typename T> T getattr (const juce::Identifier& attr, const T& defaultVal) const;

Retrieve a property value by its identifer, or a default value if no such property exists. As with setattr(), if the property is also represented by a Value object, its onGet() validator (if defined) will not be called.

Share via BlueskyLinkedInMastodonEmailRedditX


Reading Time

~13 min read

Published

Last Updated

Cello

Category

Cello

Tags

Find Me: