Guards
A node's precondition, written once as a Rust function, with a refusal sentence every surface shows.
Some screens should only open when something is true: the trash when it has something in it, checkout when the cart is not empty. A guard is that precondition, registered with the node it protects. A node guards its own door.
Writing a guard
A guard is a plain function from the store to a result:
use carte::{Refusal, Scope, Store};
fn trash_has_something(store: &Store) -> Result<(), Refusal> {
match store.read::<Shared>(Scope::Process) {
Some(s) if !s.trashed.is_empty() => Ok(()),
_ => Err(Refusal::new("The trash is empty.")),
}
}
Its type is Guard, which is fn(&Store) -> Result<(), Refusal>. Register it in the table:
t.under("trash", "home")
.guard("trash", trash_has_something);
The refusal is a sentence
A Refusal carries one sentence, written once where the rule lives. Refusal::new takes anything that converts to a String, so a refusal can name the facts: "Needs two more players." Read it back with sentence(), or print it, since Refusal implements Display.
Every place that explains the refusal shows that same sentence. No view composes its own, so the reason on a greyed-out button can never disagree with the reason the screen did not open.
When guards run
In the walk. Before the walk steps onto a child, it asks that child's guard over the committed store. If the guard refuses, the walk stops at the parent. The parent keeps the frame, as if it had named no child. Entering is a request the guard rules on, never something that happens first and is checked after.
The router keeps the last refusal until the next walk:
if let Some((id, why)) = router.refused() {
// e.g. show a toast: "The trash is empty."
}
It is re-derived on every walk, never remembered. A refusal is a reading of the facts right now.
Ahead of time. Router::ask(id, store) runs the same guard without going anywhere. Use it for the button that greys itself out:
let door = v.router.ask("trash", v.store);
let button = ui.add_enabled(door.is_ok(), egui::Button::new(label));
let button = match &door {
Err(why) => button.on_disabled_hover_text(why.sentence()),
Ok(()) => button,
};
One function, one evaluation, two surfaces. An unguarded node always answers Ok(()).
Roots are never asked. Your root function put them on the path, and only it can take them off.
A refused claim is still a claim
A guard stops the walk. It does not change your model. If home_child is Some("trash") and the trash is empty, the path stops at home, but home is still claiming trash. The moment the guard passes, the trash opens, whether or not the user just asked for it.
Clear the claim yourself when that is not what you want. The notes example does this when the last trashed note is purged:
Action::Purge(i) => {
self.notes.remove(*i);
if self.notes.iter().all(|n| !n.trashed) && self.home_child == Some("trash") {
self.home_child = None;
}
}
Limits
- One guard per node. Registering a second one is a startup error,
TableError::Redeclared, even if it is the same function. Two places that both think they own a door is the bug. - A function pointer, not a closure. A guard cannot capture anything. That keeps the table
Cloneand registration free of allocation. If a guard needs configuration, put the configuration in the store, where the facts it rules on already live. - Store only. A guard sees the
Storeand nothing else, not your model. If a fact matters to a guard, a producer must put it in a scope. - No rule language. The table will never grow a predicate DSL. Anything you can write in Rust over the store, a guard can check.
Next
That is the routing half. The state half starts with Scopes and lifetimes.