Intents and the outbox
How screens and views ask for changes, and how a named request becomes a typed action.
Nothing that runs during a frame can change your model directly. Views, nodes and Escape handlers can only ask. They ask by pushing actions onto an outbox, which the next tick drains in order and hands to the one place that applies them.
The outbox
use carte::Outbox;
let mut out: Outbox<Action> = Outbox::new();
out.push(Action::Save);
out.push(Action::Close);
Outbox<A> is a queue of your action type, in the order things were asked for. It is not a channel. Actions are pushed during a frame and drained at a known point in that same frame, inside Store::tick, so nothing crosses frames and nothing needs synchronising. One queue, drained once per frame, means two parts of your app can never each drain it and drop what the other wanted.
| Method | Does |
|---|---|
push(action) | Ask for something. Order is kept |
drain() | Take everything, leaving it empty. Store::tick calls this |
peek() | Look without draining, for tests |
len(), is_empty() | Count |
Node methods that run during a frame (update, escape, child_popped) receive the outbox and only & your model. That is what makes "a screen can only ask" a rule the compiler checks.
Typed actions from your UI
If your UI is Rust, push your action enum directly, as the Quick start does. That is all most apps need.
Named raises
Sometimes the request arrives by name rather than as a Rust value: from a UI language, a script, a plugin, or anything else that can only say "rename" and pass a few arguments. Carte's intents turn such a raise into a typed action, checked against what the scope says it accepts.
A scope publishes the intents it accepts the same way it publishes the fields it provides: as a Rust type, here an enum implementing Intents.
use carte::{Accepted, Intents, Parameter, Raise, RaiseArg, Refused, Vocabulary};
#[derive(Debug)]
enum NoteIntent {
Save,
Rename { title: String },
}
impl Intents for NoteIntent {
fn vocabulary() -> Vocabulary {
Vocabulary::new(vec![
Accepted::new("save", vec![]),
Accepted::new("rename", vec![Parameter::of::<String>("title")]),
])
}
fn accept(raise: &Raise) -> Result<Self, Refused> {
match raise.name() {
"save" => {
raise.parameters("save", 0)?;
Ok(NoteIntent::Save)
}
"rename" => {
let mut p = raise.parameters("rename", 1)?;
Ok(NoteIntent::Rename { title: p.take::<String>("title")? })
}
other => Err(Refused::NoSuchIntent { intent: other.to_string() }),
}
}
}
No derive yet. A derive writes
vocabularyandacceptfrom the enum, so the two cannot disagree. Like theSchemaderive, it lives in a separate, unpublished crate. Until it moves into Carte you write both by hand, as above, and you are responsible for keeping them in step.
The Raise API is shaped so a decoder cannot go wrong in the usual ways:
- A raise offers only its
name()until you name the intent. Its arguments open withparameters(intent, arity), which checks the count first. You cannot branch on an argument to decide which intent it is. Args::take::<T>(name)reads the next argument as the parameter's type. There is no index, so you cannot read one argument twice.Tmust implementArgument:String,bool, and the integer and float types. An integer argument widens into a float. A float never narrows into an integer.
A raise carries what a click knew: an id, a count, a flag. Anything richer is a fact, and facts travel the other way, through the store.
Accepting and raising
store.accepts::<NoteIntent>(EDITOR)?;
let raise = Raise::new("rename", vec![RaiseArg::Str("Groceries".into())]);
store.raise::<NoteIntent, NoteIntent>(EDITOR, &raise, &mut out)?;
Store::accepts publishes the vocabulary for a scope. One scope has one vocabulary (StoreError::AlreadyAccepting), and a vocabulary with a name declared twice is refused (StoreError::DuplicateIntent). Providing fields and accepting intents are independent: a scope may do either or both.
Store::raise::<I, A> checks the raise against the scope's vocabulary, decodes it with I::accept, converts it with A::from, and pushes it onto the outbox. If your action enum is your intent enum, A is I. If it is wider, write one From<NoteIntent> for Action impl. A refusal is a RaiseError whose scope() and refusal() say where and why.
Because the result goes on the outbox, it is applied in the next tick before producers run, so its effect shows in the same frame.
RaiseArg is the argument type: Str, Int, Float, Bool, or Opaque for something a host cannot act on, such as a closure.
Checking a vocabulary
Store::intents(scope) returns a scope's Vocabulary. Vocabulary::check(&declared) compares it with what a UI document says it raises, given as a list of Declared { name, parameters }, and returns every Drift:
| Drift | Grade |
|---|---|
Unaccepted | Refuses: the document raises an intent the scope does not accept |
Arity | Refuses: wrong number of arguments |
Parameter | Refuses: an argument of the wrong shape, named |
Unraised | Reports: the scope accepts an intent nothing raises |
Drift::refuses() is the line between the grades. For checking many documents against many scopes, see Validation.
Next
Validation: checking what views read and raise against what the store publishes.