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::Object
s 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 orObject
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 Value
s 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 Value
s 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 Object
s 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 aChild
, 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 Object
s 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
Object
s 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 XMLFileFormat::binary
– use JUCE’s proprietary binary encodingFileFormat::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:
- clone the object
- make changes to each of the cloned child objects
- 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 Object
‘s 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 Object
s 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 Object
s 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 Object
s 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
:
void onPropertyChange (juce::Identifier id, PropertyUpdateFn callback);
lets you specify anyIdentifier
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 thesetattr()
mechanism described below, thisIdentifier
does not need to be one that’s in use by aValue
object. If you use theIdentifier
of theObject
‘s type, you will receive a notification any time a property changes that does not have its own update callback registered for it.- As shorthand for this generic “update me when anything in this
Object
changes”, you can instead use the functionvoid 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 VariantConverter
s). 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.