Skip to content

Navigation

Kenneth Tilton edited this page Feb 1, 2024 · 8 revisions

Motivation

With just a single, solitary Matrix object, we can illustrate many features of Matrix (MX) programming. To review:

  • "formulaic" properties can be declared as HLL functions of any other properties;
  • "input" properties can be changed by imperative code;
  • other properties are effectively constants, with change prevented by the MX API;
  • any property can have a watch function, an onchange callback to manifest the property outside MX dataflow;
  • properties are defined "ad hoc", in prototype fashion; and
  • to support complex external control, an optional coordinating function can be dispatched after all watch functions have run, to process any artifacts of those functions.

That covers a lot, so much so that we think of such an object, not as inanimate data, but instead as a working model, and refer to it as a model. But a single model cannot express much of an app. In this section we look at how working applications arise from a collection of models, which we call a matrix.

Enter Matrix

ma·trix ˈmātriks noun an environment in which something else takes form. Origin: Latin, female animal used for breeding, parent plant, from matr-, mater

In our case, the "something else" taking form is our application, and in this section we look at Matrix the data structure, a directed tree:

  • all nodes in a Matrix are models;
  • every model, except the matrix root model, has a fixed :mx property;
  • the value of :mx in a given model is an incoming "edge" in the directed tree, a vector pair:
    • the "from" model, which can be considered the "owner";
    • the property in the "from" model that points to the given model, or to a collection that contains the given model; and
    • a special property is :kids, an ordered vector of zero or more children, given special support by MX for navigation and model lifecycle.

Model internals do not rely on the family mechanism; an ambitious developer can roll their own scheme for connecting models. For those so inclined, the recommended capabilities would be those offered by the Matrix family:

  • the children of a model should be potentially dynamic, as decided by a reactive formula;
  • formulaic properties of one model should be able to read properties of other models, and react to their changes;
  • specifically, a formulaic property should be able to react to changes in the children of a model;
  • initialization and finalization options should be available for models as they come and go;
  • when a family of models gets created, they should become active in an orderly fashion not requiring explicit coordination by the developer; this entails having models respond JIT to search and access by other models; and
  • the developer does have responsibility for avoiding cycles between formulaic properties, but any alternate API should detect these and throw an exception announcing "MXAPI_COMPUTE_CYCLE_DETECTED" or some such.

Again, that is quite a lot, but we can focus on just one bit.

"...formulaic properties of one model should be able to read properties of other models"

Take an example: a good U/X might have an icon widget show an empty trash can icon until an item in some list view is marked as "deleted", then swap in a full trash icon. And perhaps a counter text widget will indicate how many items are thus marked. No widget is an island. So how do we bridge them?

The standard solution is some variation on Facebook Flux, such as Redux, or Vuex, or MobX State Tree. These all gather data in a separate global store to which widgets subscribe, and on which they operate with pre-defined transactions. By contrast, in Matrix the collection of models serves as its own store: any widget can maintain ad hoc state properties and, where such properties are formulaic, they are free to read any other model.

So how does one widget track down another widget where some desired state is maintained? Enter navigation.

Navigation

Because Matrix models know their owners (the :mx property) a crucial navig utility can reach any other model to read one of its properties. Because Matrix property formulas know the anaphoric property me, akin to JS this or Smalltalk self, Matrix formulas can navigate from me to other models to use their properties for their calculations. Just for fun, we call this omniscience.

Likewise, event handlers and other imperative code, given access to the handler host me, can navigate to any other model in the app and mutate any of its input properties. We call this omnipotence. Together with omniscience, we have unconstrained state connectivity, without the manual wiring required by Flux.

Now let us look at navig, the foundation of most convenience navigation utilities.

Navigation utilities

navig is the workhorse of "blind" navigation from one model to another. It is used by formulas and event handlers to read or mutate arbitrary program state. We say "blind" because we do not specify a full search path; all we need provide is:

  • the starting point of the search;
  • optional search hints; and
  • a test function that indicates when a given visited model is the model sought.

A different function, fm_ancestor works similarly but travels only up the parent chain.

Drilling down into navig, we look first at navig-eq?, the function used to determine when we have a match. Then we look at fm-ancestor, since it is simpler and shares parameters with the hairier fm-navig.

navig_eq? match test function

Here is the code, condensed:

(defn- navig-eq? [seeking candidate]
  (cond
    (fn? seeking)
    (seeking candidate)
    ;-
    (keyword? seeking)
    (= seeking (:name @candidate))
    ;-
    :else
    (= seeking candidate)))

The simplest navigation target is the :name of a model, the most powerful is a callback. We ourselves never search for a model instance, but the option is there.

navig full matrix search

The navig signature:

(navig _seeking_ _starting-model_ & options)

...where options are alternating keys and their values. One example, in a handler to increment a counter:

(floating-action-button
  {:onPressed (dart-cb []
                (let [ctr (navig :the-counter me
                            :must? true :out true :inside? false)]
                  (mupdate! ctr :counter inc)))}
  (m/Icon m/Icons.add))

Parameter by parameter, with any default in parens:

  • seeking: required. A keyword name or a function taking one parameter, the node currently being tested;
  • starting-model: required. navig searches inside-out from this model;
  • :must? (true): should navig throw an exception if search fails?;
  • :me? (false): should starting-point be considered?;
  • :inside? (false): should structure under starting-point be considered?;
  • :out (true): should structure outside the sub-tree rooted by starting-point be considered? If this value is :asc-only, only the ascendants, the :mx chain, will be considered;
  • without-dependency? (true): when navig reads the :kids of other models, should reactive dependency be established?; and
  • dbg (nil): feature not yet implemented, but see below for the nav-dbg utility.

Another important value guiding navig is the properties to be investigated for the sought model, which in Flutter/MX defaults to:

(def ^:dynamic *nav-props*
  [:kids :home :body
   :persistentFooterButtons
   :floatingActionButton])

That can be overridden with the macro with-nav-props, eg:

(mget (with-nav-props [:app-bar]
         (fm* :msg-sender)) :msg-stream)

Search order

The navig search:

  • constrained by the arguments me?, inside?, and out;
  • proceeds depth-first, pre-order, left to right;
  • starting at any node;
  • continues recursively up the tree, skipping the start node;
  • until the search succeeds or is exhausted.

This GIF illustrates a search starting at the top of a tree:

Tree search depth-first, pre-order, left to right

When given a starting point inside a tree, the search simply continues recursively with the "owning" node of the starting node, skipping the start when encountered recursively. ie:

  • starting at 5, the sequence would be 5-6-7-8-1-2-3-4-9-10;
  • starting at 4, the sequence would be 4-3-2-1-5-6-7-8-9-10;
  • starting at 10, the sequence would be 10-9-1-2-3-4-5-6-7-8.

Note that the nested scoping means duplicate widget names are not a problem, since search starting anywhere in a repeating cluster will naturally find first the duplicate instance in the same cluster. With more sophisticated search, perhaps looking for the duplicate name in the prior or subsequent cluster, we use the most powerful version of navig in which a function decides the match, and can use state as needed to support the trickier requirement.

Navigation cycles

While omniscience is great, there is one hazard to avoid: cycles. Recall this feature of Matrix, which is vital to responsive interfaces:

the children of a model should be potentially dynamic, as decided by a reactive formula

And this:

when a family (in any sense) of models gets created, they should become active in an orderly fashion not requiring explicit coordination by the developer; this entails having models respond JIT to search and access by other models

That last may not be clear. Without getting too far into the weeds, when a new matrix of models is created:

  • we start with one model, which gets "awakened";
  • awakening means all its properties get awakened, meaning formulaic properties are evaluated on the fly;
  • if a formula navigates out to a different model, that model might not yet have been created, let alone awakened. But the navigation function will be reading the kids properties of existing models encountered, and these will be executed JIT to produce the sought model;
  • the sought model will not be immediately awakened, but the original formula can read the desired property of the sought model, and that can run JIT.

Surprisingly, this all works. Unless a cycle has been authored, by having a kids formula navigate to another model for a property requiring a second navigation through the model matrix, one critically which cannot succeed until the first navigation is complete.

We will expand on this with illustrative examples, but the tl;dr will be this:

  • try always to navigate "up" the MX tree from a formula's me value, avoiding the formula that created me;
  • if we do hit a cycle, move property sought higher in the tree to a common ancestor: searching up does not involve dynamic navigation, because parents are always known.

[To be continued]