State
This lesson is also a bit long. The important parts are the state lock, signals, stores and bindings. You can come back to the other parts after finishing the rest of the lessons.
Remark: Some of the terminology of the state system comes from Group theory (e.g. Word, GroupAction).
State Lock
Most native UI libraries have some operations that can only be performed on the main thread. Unfortunately, this is often unchecked completely or checked at runtime. Quarve uses Rust's ownership mechanics to check most of this at compile time.
The idea is that there is a global state lock that only one thread at a time can
hold. Any thread which wants to update or read from the state must acquire the
state lock (often abbreviated 'slock'). To prove that the caller of a certain
function must be holding this lock, you can simply add as an argument
s: Slock
, which is a marker that can only be acquired if you are in fact the
holder.
There are two types of slocks.
Slock<MainThreadMarker>
which is abbreviatedMSlock
.Slock<AnyThreadMarker>
which is abbreviatedSlock
.
Note that you can freely convert slocks of the first kind to the second kind
using .to_general_slock
, which is occasionally necessary. However, converting
the second kind to the first kind fails if you're not on the main thread.
Consequently, many methods in the Quarve library take as argument
either Slock<impl ThreadMarker>
, denoting only the state lock is necessary,
or MSlock>
, denoting the state lock is necessary and this function
must be called on the main thread.
Advanced: For simple applications, you likely only pass the slock marker
and delegate actually acquiring the lock to the caller of your function
(which will typically be quarve).
However, to explicitly acquire the state locker yourself (as is necessary
for worker threads), you can use the slock_owner
function
From here, you can get the actual state lock markers using the marker
function.
Remark: Conventional wisdom argues against having a global state lock as it may reduce parallelism. However, we believe that in the vast majority of cases only the main thread will hold the lock and worker threads only need to hold it for a very short time when they perform the state transformation. Also, the state lcok greatly reduces race conditions as well. Finally, we avoid the cost of constantly acquiring and releasing mutexes. Hence, we believe that in many cases the tradeoff is worth it.
Signals
Signals are simply values that change over time. Moreover, receivers
can get notified after the value is updated. Note that Signal
is a trait, and its value takes the associated type Target.
For instance, quarve provides clock_signal
function that is simply
a signal that continuously increases with time. Here's an example of
how we can apply a listener.
#![allow(unused)] fn main() { fn run(s: MSlock) { let sig = clock_signal(s); sig.listen(|val, s| { println("Time is {}", val); true }, s); // you can also query the current value of a signal // using the borrow method // note that this requires the state lock as well let curr = *sig.borrow(s); } }
Notice that signal listeners are given the state lock as a parameter,
and you can only add listeners if you have the state lock.
Listeners also should return a boolean value denoting whether they
want to continue listening or should be dropped.
In the above example, we want to listen forever so we always
return true
. In many cases, you return false once a weak
reference can no longer be upgraded.
By default, listen
gives a notification for all possible updates,
even when the 'new' value is the same as the old one.
If you would only like to get notifications for true changes, you can use
diff_listen
instead (this requires the target to implement PartialEq
).
Store
The heart of state is a Store
. Conceptually, a store simply
stores a particular value and notifies to its observers whenever it changes.
In this aspect, it's a signal. However, unlike a signal, a Store
can also
be changed arbitrarily by users.
In the simplest case, this change is done by manually overwriting the current value.
#![allow(unused)] fn main() { let store = Store::new(0); // notice the usage of the state lock // Here, we apply the SetAction::Set action to modify the current value store.apply(Set(4), s); // you can acquire a signal from a store explicitly // sig: impl Signal<Target=i32> + Clone let sig = store.signal(); // or you can add listeners directly to the store store.listen(|val, s| { ... true }, s); }
The advantage of having the action be an actual struct passed in (as opposed to e.g. some function on the object) is that now observers can see the exact transformation that was applied, which can be surprisingly useful.
For many types, such as integers and floats, the 'action' of how you change the value is simply setting it. However, this is not always the case. For instance, the action for a vector is a sequence of inserts or removals.
#![allow(unused)] fn main() { let store = Store::new(vec![Store::new(false)]); // for store of vectors, // it may not be obvious why we need the inner store // (in addition to the outer one) // you can think about it as the top level store lets us observe // if elements are added or deleted, but doesn't know if they're modified // the individual sub stores allow observes to see if any particular // element of the vector is modified store.apply([ VecActionBasis::Insert(Store::new(false), 0), VecActionBasis::Remove(1) ], s); }
Again, the advantage is that now an observer is not only notified that the vector will change, but they can see exactly how it will be changed by looking at the action. This can, for example, figure out how to efficiently undo the action or possibly mirror the action onto another vector.
Binding
In particular, in addition to being a signal, stores also implement
the Binding
trait. The Binding
trait is subtrait of Signal
,
further specifying that you must be able to change the current
value using an action (as was seen above), and add an action_listener
.
An action listener gets notified every time a store is about to change (in contrast, regular listeners are notified afterwards). It is given the current value, the action, and the state lock.
#![allow(unused)] fn main() { let store = Store::new(vec![Store::new(false)]); // see below for why the different syntax wrt to signal // b: impl Binding<Filterless<Vec<bool>> + Clone let b = store.binding(); // here we call action listen on the binding // but you could've just called it on the store itself b.action_listen(|val, action, s| { /* act appropriately */ // again, return a value based on whether we want to continue // listening true }, s); }
Finally, we note that Store
implements Binding
but does not
implement Clone
. If you would like a cloneable binding,
explicitly call the binding
method on the store.
(likewise, for a cloneable signal, use the signal
method).
Also note that Stores are internally arc-ed. In particular,
even if the original Store
is dropped but one of the bindings
is still alive, you can still use the bindings as they hold strong references.
You can instead call weak_binding
to only hold a weak reference.
Note: There is a slight asymmetry between impl Signal<Target=T>
and impl Binding<Filterless<T>>
.
The filterless part is basically for something
that didn't really end up getting used but is still a part of quarve.
It may be used in the future, though.
Store Container
In any actual UI application, the main state is not simply a single
store, but rather a collection of them. We would like to organize such stores
into containers. The quarve_derive
crate provides a utility to automatically
derive StoreContainer.
#![allow(unused)] fn main() { #[derive(StoreContainer)] struct ApplicationState // non stores should be ignored #[quarve(ignore)] app_name: String, count: Store<usize>, shopping_cart: Store<Vec<Item>>, // store containers can hold other store containers // (assume that UserState is some other StoreContainer) user: UserState } }
In addition to grouping together state, StoreContainers provide the the functionality of adding a listener to see when any contained store changes. As we'll soon see, StoreContainers also are used for adding undo/redo support.
#![allow(unused)] fn main() { let a = ApplicationState::new(/* snip */); // note that only one subtree general listener // can ever be present at any given time a.subtree_general_listener(|s| { // note that we are not explicitly told which // sub container is changed true }, s); }
Quarve also provides StoreContainerSource
and StoreContainerView
to add arc funtionality to store containers, if needed.
Undo
Quarve makes it easy to add undo support. Place all stores that
should be undoable under some StoreContainer
. Then, simply
create an UndoManager
and call mount_undo_manager
on
the target IVP. Whenever the IVP is mounted on the view
hierarchy (i.e. visible), you will be able to undo any action.
#![allow(unused)] fn main() { fn root(&self, env: &<Env as Environment>::Const, s: MSlock) -> impl ViewProvider<Env, DownContext=()> { let a = ApplicationState::new(/* snip */); // specify the store container let u = UndoManager::new(&a, s); ivp .mount_undo_manager(u) .into_view_provider(env, s) } }
TODO (advanced) mention undo grouping, undo bucket and history_elide
Derived Store
TODO add more details here However, sometimes we want to explicitly exclude a Store from undo operations. This usually happens because a Store is not actually independent of other stores, but is instead a function of them (i.e. its value is 'derived' from other stores). Concretely, let's suppose that we have two stores. One is a counter, and another is a double counter that always (toy example, you can imagine that actual conversion to be much more complex).
#![allow(unused)] fn main() { let a = Store::new(0); let b = Store::new(0); // always update b let binding = b.binding(); a.listen(move |val, s| { binding.apply(Set(2 * val), s); true }, s); }
In such a case, let's suppose somewhere that a
is updated
to 1
. Following this, b
will be updated to 2
.
When we request an undo, we will revert the most recent action,
so b
will go back to 0
. Then, we will set back a
to 0
.
Since a
was changed, all listeners will be invoked,
so b
will again be set to 0
. This last operation is redundant,
and we could've simply gotten away with never undoing
the action for b in the first place. Hence, it makes more sense
to mark b
as a DerivedStore that should not partake
in undo operations.
You can imagine this becomes much more problematic when b
is
trying to mirror the actions of a
(that aren't simply SetActions,
think of a vector).
In this case, applying the inverse action twice will lead
to incorrect behavior!
Stateful
How does quarve know which action type shoul be used for a given store?
The answer is that any item used in the store must implement
the Stateful
trait. Part of the Stateful
trait is
setting the associated type to the natural action.
Hence, if you want to create a store of some custom type,
you must implement Stateful
for the your custom type.
For many cases, you can get away by setting the action
to be SetAction
. If necessary, you can instead create
your own action and implement the GroupAction
trait
for it.
* Technically, it need not be a 100% proper group action, but it's nearly there.
IntoAction
Sometimes, we may want to perform a modification that isn't naturally thought of as the standard action for some Store. For example, let's suppose we have a counter. It's quite annoying to use a set action to increment since we must first borrow the value.
Hence, we note that Binding::apply
actually accepts
a value that implements IntoAction
, rather than solely
the standard action of the Store
. Consequently, we can create
an IncrementAction
struct that implements IntoAction
and
be converted into a SetAction
. Crucially, note that in the below
signature, we are allowed to look at the target value when performing
the conversion so that we may simply return SetAction::Set(1 + *target)
.
#![allow(unused)] fn main() { fn into_action(self, target: &T) -> A; }
In fact, NumericAction::Incr
exists in the quarve library
and does exactly this.
You can feel free to create your own too though, as you see fit.
Another use case is to simply give syntatic sugar for actions
that are cumbersome to type. For instance,
[VecActionBasis; N]
implements IntoAction
so that
you can type [VecActionBasis::insert(...)]
instead of
Word::new(vec![VecActionBasis::insert(...)])
.