Skip to main content

tilo_app/framework/
dispatch.rs

1//! Submitting edits: every change a view makes is an action the host checks
2//! against permissions, applies, records for undo and history, and
3//! replicates.
4
5use std::cell::{Cell, RefCell};
6use std::collections::VecDeque;
7use std::rc::Rc;
8
9use tilo_actions::{
10    ActionAddress, ActionBinding, ActionContext, ActionError, ActionInvoker, ActionNeed,
11    ControlInvocation,
12};
13
14use super::controls::Actions;
15use tilo_resource::{Edit, Effect, Invoke, Resource};
16use ui_core::LocalState;
17use ui_core::automerge::ChangeHash;
18use ui_core::permissions::{ActionKind, LivePermissions, PermissionScope, TreePath, policy};
19
20/// What a view asks whether its participant may do.
21#[derive(Clone, Debug, PartialEq, Eq)]
22enum Asked {
23    Need(ActionNeed),
24    Govern,
25}
26use ui_core::widget::Edits;
27use ui_core::widget::controls::AttributedControlInvocation;
28
29/// Something a dispatcher submits and a button is bound to: a field edit
30/// made at a path (`p.favorite.set(true)`), a verb with its arguments, or an
31/// invocation built some other way. No app writes an action id or an
32/// argument map.
33pub trait Submit<R: Resource>: Sized {
34    /// The public id of the action this submits.
35    fn action_id(&self) -> &'static str;
36
37    /// The invocation of the host door this submits.
38    ///
39    /// # Errors
40    /// Returns why it cannot be submitted.
41    fn into_invoke(self) -> Result<Invoke<R>, String>;
42
43    /// The binding of a button that submits this when pressed, or `None`
44    /// (and a failure in tests) when the resource does not publish it.
45    fn control_binding(self, actions: &Actions<R>) -> Option<ActionBinding<()>> {
46        let id = self.action_id();
47        match self.into_invoke() {
48            Ok(invoke) => actions.control_invocation(invoke),
49            Err(error) => {
50                super::controls::unbound::<R>(&format!("`{id}`"), &error);
51                None
52            }
53        }
54    }
55}
56
57impl<R: Resource> Submit<R> for Invoke<R> {
58    fn action_id(&self) -> &'static str {
59        self.id
60    }
61
62    fn into_invoke(self) -> Result<Invoke<R>, String> {
63        match self.refusal() {
64            Some(refusal) => Err(refusal.to_owned()),
65            None => Ok(self),
66        }
67    }
68}
69
70impl<R: Resource> Submit<R> for Edit<R> {
71    fn action_id(&self) -> &'static str {
72        tilo_resource::FIELD_EDIT
73    }
74
75    fn into_invoke(self) -> Result<Invoke<R>, String> {
76        Invoke::edit(&self).map_err(|error| error.to_string())
77    }
78}
79
80/// Submits a view's edits. Cloneable; clones share one queue.
81///
82/// Two doors, one path. [`submit`](Self::submit) and [`apply`](Self::apply)
83/// queue an invocation the host resolves after the event, exactly as a
84/// control bound to an action would. [`apply_now`](Self::apply_now) applies
85/// one typed action immediately, inside the event or the settle pass
86/// ([`Widget::settle`](ui_core::widget::Widget::settle)), for an editor that must
87/// see the result of its own edit before the next keystroke (the paragraph a
88/// split created, the cell a table insert made). Both check the
89/// participant's permission, apply through the replayable executor with
90/// attribution, and are undone per author.
91///
92/// # A drag as one undo step
93///
94/// A slider or a drag handle a surface draws itself applies each value it
95/// passes through with [`apply_now`](Self::apply_now), so the document — and
96/// every collaborator — follows the drag live. The resource declares that
97/// one value continues the one before it ([`Resource::coalesces`]: the same
98/// field of the same item), which makes the whole drag one step, and the
99/// surface calls [`settle`](Self::settle) when the pointer is let go, so the
100/// next drag is a step of its own:
101///
102/// ```ignore
103/// fn coalesces(previous: &Action, next: &Action) -> bool {
104///     matches!((previous, next), (Action::SetLevel(..), Action::SetLevel(..)))
105/// }
106///
107/// // In the surface's `handle_event`:
108/// WindowEvent::CursorMoved { .. } if self.dragging => {
109///     let _ = self.dispatch.apply_now(&mut ctx.edits(), &Action::SetLevel(level));
110/// }
111/// WindowEvent::MouseInput { state: ElementState::Released, .. } => {
112///     self.dragging = false;
113///     self.dispatch.settle();
114/// }
115/// ```
116///
117/// A toolkit slider bound with `ViewCx::control` instead invokes its action
118/// once per drag when built with `Slider::commits_on_release`.
119pub struct Dispatcher<R: Resource> {
120    /// Everything that does not name the resource's action type, compiled
121    /// once for every resource.
122    core: DispatchCore,
123    /// The invocations of the open group, and how many groups are open.
124    group: Rc<RefCell<(usize, Vec<Invoke<R>>)>>,
125    /// What applying an action needs: the resource's own
126    /// [`Resource::permission`], or for a child its parent's action's.
127    permission_of: PermissionOf<R>,
128    /// Applies one immediate action: the resource's own, or for a child
129    /// resource its parent's action carrying it.
130    submit_now: SubmitNow<R>,
131    /// Applies a group of the resource's own actions now as one action;
132    /// `None` for a dispatcher of a nested or other record.
133    group_now: Option<GroupNow<R>>,
134    /// The last immediate action and its serial, to tell whether the next
135    /// continues it.
136    last: Rc<RefCell<Option<Serialed<R>>>>,
137}
138
139/// What a dispatcher keeps that does not name the resource's action type:
140/// who edits, the door's queue, permissions, refusals and the undo-step
141/// bookkeeping.
142#[derive(Clone)]
143pub(crate) struct DispatchCore {
144    app_id: &'static str,
145    resource_id: Rc<str>,
146    /// [`Resource::KIND`] and [`Resource::LABEL`] of the resource.
147    kind: &'static str,
148    label: &'static str,
149    /// The readable name published by the resource host, shared with surfaces.
150    resource_label: LocalState<String>,
151    /// Who edits through this dispatcher; `None` in a view of a past version.
152    me: Option<Rc<str>>,
153    /// A dispatcher lent to an admitted effect keeps its original custody.
154    /// Clones cannot turn later work into the host participant's invocation.
155    /// Share the immutable scope rather than embedding its owner/lease in every
156    /// view dispatcher: copying the full scope also inflates construction stacks.
157    custody: Option<Rc<ui_core::invocation_scope::ResourceInvocationScope>>,
158    /// Each queued invocation with the effect invocation it continues,
159    /// when an effect's handler or completion queued it.
160    queue: Rc<RefCell<VecDeque<AttributedControlInvocation>>>,
161    read_only: LocalState<bool>,
162    /// The participant's permissions as the host keeps them, for an action
163    /// that needs less than writing (a reply a commenter may add) or more.
164    /// Absent outside a host: a past version, which nobody edits, or a
165    /// test's detached dispatcher, which may do what writing allows.
166    permissions: Rc<RefCell<Option<LivePermissions>>>,
167    permission_states: PermissionStates,
168    refused: LocalState<Option<Refused>>,
169    /// The last submission of this view the door applied.
170    landed: LocalState<Option<Landed>>,
171    /// What the effects that queued submissions here wait on: how many are
172    /// still to land, per effect invocation.
173    waits: Rc<RefCell<Waits>>,
174    /// Why the record does not read as the resource, while it does not:
175    /// nothing is submitted against a state nobody can see.
176    unreadable: Rc<RefCell<Option<String>>>,
177    host: Rc<RefCell<ui_core::action_host::ActionHost>>,
178    /// Counts the immediate actions of the resource and its children, so a
179    /// dispatcher can tell that nothing came between its last action and
180    /// its next.
181    serial: Rc<Cell<u64>>,
182    /// Every immediate action since the last undo boundary continued the one
183    /// before it ([`Resource::coalesces`]): one step, still open.
184    continuing: Rc<Cell<bool>>,
185    /// An immediate action was applied since the last boundary was taken.
186    applied: Rc<Cell<bool>>,
187    /// The boundary last taken opened a step that stays open.
188    opened: Rc<Cell<bool>>,
189    /// The view shows a past version in History, not the live resource.
190    past: bool,
191    /// The last immediate action may be continued by a later one
192    /// ([`Resource::continuable`]).
193    continuable: Rc<Cell<bool>>,
194    /// The step the open undo step's changes record, decided before each
195    /// change commits, so a coalesced step stays one step after a restart.
196    gesture: Rc<Cell<Option<tilo_actions::ActionId>>>,
197}
198
199/// What applying one action needs.
200type PermissionOf<R> = Rc<dyn Fn(&<R as Resource>::Action) -> ActionNeed>;
201
202/// One observable per requested permission, shared by this resource's views.
203type PermissionStates = Rc<RefCell<Vec<(Asked, LocalState<bool>)>>>;
204
205/// An immediate action and the serial it was applied at.
206type Serialed<R> = (u64, <R as Resource>::Action);
207
208/// Applies a group of the resource's actions now, as one action.
209type GroupNow<R> = Rc<
210    dyn Fn(
211        &mut ui_core::action_host::ActionHost,
212        &mut Edits<'_>,
213        &tilo_resource::ActionGroup<<R as Resource>::Action>,
214        ActionContext,
215    ) -> Result<(), ActionError>,
216>;
217
218/// Applies one typed action to the document a pass may change, through
219/// the host.
220type SubmitNow<R> = Rc<
221    dyn Fn(
222        &mut ui_core::action_host::ActionHost,
223        &mut Edits<'_>,
224        &<R as Resource>::Action,
225        ActionContext,
226    ) -> Result<(), ActionError>,
227>;
228
229impl<R: Resource> Clone for Dispatcher<R> {
230    fn clone(&self) -> Self {
231        Self {
232            core: self.core.clone(),
233            group: Rc::clone(&self.group),
234            permission_of: Rc::clone(&self.permission_of),
235            submit_now: Rc::clone(&self.submit_now),
236            group_now: self.group_now.clone(),
237            last: Rc::clone(&self.last),
238        }
239    }
240}
241
242impl DispatchCore {
243    fn new(
244        app_id: &'static str,
245        resource_id: &str,
246        (kind, label): (&'static str, &'static str),
247        read_only: bool,
248        me: Option<&str>,
249    ) -> Self {
250        Self {
251            app_id,
252            resource_id: Rc::from(resource_id),
253            kind,
254            label,
255            resource_label: LocalState::new(label.to_owned()),
256            me: me.map(Rc::from),
257            custody: None,
258            queue: Rc::default(),
259            read_only: LocalState::new(read_only),
260            permissions: Rc::default(),
261            permission_states: Rc::default(),
262            refused: LocalState::new(None),
263            landed: LocalState::new(None),
264            waits: Rc::default(),
265            unreadable: Rc::default(),
266            host: Rc::default(),
267            serial: Rc::default(),
268            continuing: Rc::default(),
269            applied: Rc::default(),
270            opened: Rc::default(),
271            past: false,
272            continuable: Rc::default(),
273            gesture: Rc::default(),
274        }
275    }
276
277    /// The core of the dispatcher of another record than this resource's,
278    /// published at `id` ([`Dispatcher::for_record`]).
279    fn for_record(
280        &self,
281        id: &str,
282        (kind, label): (&'static str, &'static str),
283        read_only: bool,
284    ) -> Self {
285        Self {
286            app_id: self.app_id,
287            me: self.me.clone(),
288            custody: self.custody.clone(),
289            resource_id: Rc::from(id),
290            kind,
291            label,
292            resource_label: LocalState::new(label.to_owned()),
293            queue: Rc::clone(&self.queue),
294            read_only: LocalState::new(read_only || self.past),
295            permissions: Rc::default(),
296            permission_states: Rc::default(),
297            refused: self.refused.clone(),
298            landed: self.landed.clone(),
299            waits: Rc::clone(&self.waits),
300            unreadable: Rc::default(),
301            host: Rc::clone(&self.host),
302            serial: Rc::default(),
303            continuing: Rc::default(),
304            applied: Rc::default(),
305            opened: Rc::default(),
306            past: self.past,
307            continuable: Rc::default(),
308            gesture: Rc::default(),
309        }
310    }
311
312    /// The core of the dispatcher of a resource nested in this one at `id`
313    /// ([`Dispatcher::child`]): it shares this one's permissions and undo
314    /// steps.
315    fn child(&self, id: &str, (kind, label): (&'static str, &'static str)) -> Self {
316        Self {
317            resource_id: Rc::from(id),
318            kind,
319            label,
320            resource_label: LocalState::new(label.to_owned()),
321            ..self.clone()
322        }
323    }
324
325    fn queue_invocation(
326        &self,
327        address: ActionAddress,
328        arguments: serde_json::Map<String, serde_json::Value>,
329    ) {
330        let on_behalf_of = match &self.custody {
331            Some(custody) => {
332                if let Err(error) = custody.check() {
333                    self.refuse(&address.action, error);
334                    return;
335                }
336                Some(custody.invocation())
337            }
338            None => super::effect::acting_for(),
339        };
340        if let Some(invocation) = on_behalf_of {
341            self.waits.borrow_mut().submitted(invocation);
342        }
343        self.queue
344            .borrow_mut()
345            .push_back((ControlInvocation { address, arguments }, on_behalf_of));
346    }
347
348    pub(crate) fn note_continuation(
349        &self,
350        invocation: tilo_actions::InvocationId,
351        action: &str,
352        result: &Result<ui_core::host_operation::ResourceOperationStart, tilo_actions::DoorError>,
353    ) {
354        let landed = match result {
355            Ok(ui_core::host_operation::ResourceOperationStart::Completed(output)) => {
356                Ok(Landed::of(action, output))
357            }
358            // Handed on to the broker: it applies there, answering nothing here.
359            Ok(ui_core::host_operation::ResourceOperationStart::Accepted) => Ok(Landed {
360                action: action.to_owned(),
361                answer: None,
362                created: Vec::new(),
363            }),
364            Err(error) => Err(error.to_string()),
365        };
366        self.waits.borrow_mut().landed(invocation, landed);
367    }
368
369    fn past(&mut self) {
370        self.past = true;
371        self.read_only.set(true);
372        self.permission_states = Rc::default();
373    }
374
375    pub(crate) fn follow(&self, permissions: LivePermissions) {
376        *self.permissions.borrow_mut() = Some(permissions);
377        self.refresh_permission_states();
378    }
379
380    fn asked_state(&self, asked: Asked) -> LocalState<bool> {
381        let mut states = self.permission_states.borrow_mut();
382        if let Some((_, state)) = states.iter().find(|(held, _)| *held == asked) {
383            return state.clone();
384        }
385        let state = LocalState::new(self.may_asked(&asked));
386        states.push((asked, state.clone()));
387        state
388    }
389
390    pub(crate) fn refresh_permission_states(&self) {
391        for (asked, state) in self.permission_states.borrow().iter() {
392            let allowed = self.may_asked(asked);
393            if state.get() != allowed {
394                state.set(allowed);
395            }
396        }
397    }
398
399    fn may_asked(&self, asked: &Asked) -> bool {
400        match asked {
401            Asked::Need(need) => self.may(need),
402            Asked::Govern => self.may_govern(),
403        }
404    }
405
406    fn may(&self, need: &ActionNeed) -> bool {
407        let mut node = TreePath::root().app(self.app_id);
408        if let Some(field) = &need.field {
409            node = node.field(field);
410        }
411        self.may_scope(&PermissionScope::Action {
412            node,
413            kind: need.kind,
414        })
415    }
416
417    fn may_govern(&self) -> bool {
418        self.may_scope(&PermissionScope::governance(
419            policy::roles::governance::SETTINGS,
420        ))
421    }
422
423    /// Whether the participant may invite people to the room.
424    pub(crate) fn may_invite(&self) -> bool {
425        self.may_scope(&PermissionScope::governance(
426            policy::roles::governance::INVITE,
427        ))
428    }
429
430    fn may_scope(&self, scope: &PermissionScope) -> bool {
431        match self.permissions.borrow().as_ref() {
432            Some(permissions) => permissions.check(scope),
433            // A detached dispatcher may what writing allows; a past version
434            // nothing.
435            None => !self.read_only.get(),
436        }
437    }
438
439    #[cfg(any(test, feature = "test-support"))]
440    fn refresh_read_only(&self) -> bool {
441        let writes = self.permissions.borrow().as_ref().map(|permissions| {
442            permissions.check(&PermissionScope::app(self.app_id, ActionKind::Undoable))
443        });
444        match writes {
445            Some(writes) => self.set_read_only(!writes),
446            None => false,
447        }
448    }
449
450    pub(crate) fn set_read_only(&self, read_only: bool) -> bool {
451        if self.read_only.get() == read_only {
452            return false;
453        }
454        self.read_only.set(read_only);
455        true
456    }
457
458    fn view_only(&self) -> String {
459        format!("this {} is open to view only", self.label.to_lowercase())
460    }
461
462    /// Why an edit that is `need` was refused.
463    fn not_allowed(&self, need: &ActionNeed) -> String {
464        match (&need.field, need.kind) {
465            (Some(field), _) if field == policy::roles::COMMENTS_FIELD => {
466                format!("you may not comment on this {}", self.label.to_lowercase())
467            }
468            (None, ActionKind::Undoable) => self.view_only(),
469            _ => format!("you may not do this to this {}", self.label.to_lowercase()),
470        }
471    }
472
473    /// Why nothing can be submitted now, when the record is unreadable.
474    fn unreadable(&self) -> Option<String> {
475        self.unreadable.borrow().clone()
476    }
477
478    pub(crate) fn refuse(&self, action: &str, reason: String) {
479        self.refused.set(Some(Refused {
480            action: action.to_owned(),
481            reason,
482        }));
483    }
484
485    /// Why an invocation `id` submitted now is refused before its
486    /// permission is asked: the record is unreadable (unless it is an
487    /// effect) or the effect lending this dispatcher lost its custody.
488    fn refusal(&self, id: &str, effect: bool) -> bool {
489        if let Some(reason) = self.unreadable()
490            && !effect
491        {
492            self.refuse(id, reason);
493            return true;
494        }
495        if let Some(custody) = &self.custody
496            && let Err(error) = custody.check()
497        {
498            self.refuse(id, error);
499            return true;
500        }
501        false
502    }
503
504    /// Why nothing is applied now: an effect's dispatcher submits through
505    /// the queue, and an unreadable record takes nothing.
506    fn applies_now(&self) -> Result<(), ActionError> {
507        if self.custody.is_some() {
508            return Err(ActionError::Refused(
509                "caller-scoped effects submit document changes through queued actions".into(),
510            ));
511        }
512        if let Some(reason) = self.unreadable() {
513            return Err(ActionError::Refused(reason));
514        }
515        Ok(())
516    }
517
518    /// The context of an action of this resource, attributed to whoever the
519    /// edit is applied for.
520    fn context(&self) -> ActionContext {
521        ActionContext::for_resource(
522            self.resource_id.as_ref(),
523            self.kind,
524            self.resource_label.get(),
525        )
526    }
527
528    /// `context` named for this resource and its editor, in the step it
529    /// opens or `continues` when it is a `step`.
530    fn step_context(&self, context: ActionContext, step: bool, continues: bool) -> ActionContext {
531        // History names the resource and who edited it even when the
532        // surface's context does not: whoever the edit is applied for (an
533        // agent at its own caret, a collaborator a scenario stages), else
534        // this window's person.
535        let mut named = self.context();
536        if let Some(invoker) = ui_core::invocation_scope::current_invoker()
537            .or_else(|| self.me.as_ref().map(|me| ActionInvoker::actor(me.as_ref())))
538        {
539            named = named.with_invoker(invoker);
540        }
541        let mut context = context.enriched_by(&named);
542        // An action that continues the open step joins it; any other opens
543        // its own.
544        if step {
545            let gesture = self.gesture.get().filter(|_| continues).unwrap_or_default();
546            self.gesture.set(Some(gesture));
547            context = context.in_gesture(Some(gesture));
548        }
549        context
550    }
551
552    /// Note an immediate action that is a step and was applied: whether it
553    /// `continues` the open one and is `continuable` itself. Its serial.
554    fn stepped(&self, continues: bool, continuable: bool) -> u64 {
555        let serial = self.serial.get().wrapping_add(1);
556        self.serial.set(serial);
557        self.continuable.set(continuable);
558        // The event continues the open step only if every action it applied
559        // did.
560        let first_of_event = !self.applied.replace(true);
561        self.continuing
562            .set(continues && (first_of_event || self.continuing.get()));
563        serial
564    }
565
566    /// Note a group applied now: a step of its own, which nothing
567    /// continues.
568    fn grouped(&self) {
569        self.serial.set(self.serial.get().wrapping_add(1));
570        self.applied.set(true);
571        self.continuing.set(false);
572    }
573
574    pub(crate) fn take_committed(&self) -> Option<Vec<ChangeHash>> {
575        let bracket = self.host.borrow_mut().take_committed();
576        let continuing = self.continuing.replace(false);
577        self.opened
578            .set(bracket.is_some() && !continuing && self.continuable.get());
579        self.applied.set(false);
580        if continuing { None } else { bracket }
581    }
582}
583
584/// What a root reads and drives of its dispatcher, without its type.
585impl DispatchCore {
586    /// Who edits through this dispatcher; `None` in a view of a past version.
587    pub(crate) fn me(&self) -> Option<&str> {
588        self.me.as_deref()
589    }
590
591    pub(crate) fn note_landed(&self, action: &str, output: &tilo_actions::ActionOutput) {
592        self.landed.set(Some(Landed::of(action, output)));
593    }
594
595    /// Say why the record does not read as the resource (`None` once it
596    /// does again); until then every submission is refused with it.
597    pub(crate) fn set_unreadable(&self, reason: Option<String>) {
598        *self.unreadable.borrow_mut() = reason;
599    }
600
601    /// The next queued invocation, with the effect invocation it continues.
602    pub(crate) fn take_attributed(&self) -> Option<AttributedControlInvocation> {
603        self.queue.borrow_mut().pop_front()
604    }
605
606    pub(crate) fn has_pending(&self) -> bool {
607        !self.queue.borrow().is_empty()
608    }
609
610    /// The step the last boundary opened stays open for the actions that
611    /// continue it ([`Resource::coalesces`]).
612    pub(crate) fn keeps_step_open(&self) -> bool {
613        self.opened.get()
614    }
615
616    pub(crate) fn resource_id(&self) -> &str {
617        &self.resource_id
618    }
619
620    pub(crate) fn app_id(&self) -> &'static str {
621        self.app_id
622    }
623}
624
625impl<R: Resource> Dispatcher<R> {
626    /// What does not name the resource type.
627    pub(crate) fn core(&self) -> &DispatchCore {
628        &self.core
629    }
630
631    pub(crate) fn for_custody(
632        &self,
633        custody: ui_core::invocation_scope::ResourceInvocationScope,
634    ) -> Self {
635        let mut dispatcher = self.clone();
636        dispatcher.core.custody = Some(Rc::new(custody));
637        // An effect's group cannot absorb the person's unrelated input.
638        dispatcher.group = Rc::default();
639        dispatcher
640    }
641
642    /// What the effect `invocation` submitted here, once it all landed: the
643    /// last submission's [`Landed`], or why one was refused. Nothing arrives
644    /// when it submitted nothing.
645    pub(crate) fn wait_landed(
646        &self,
647        invocation: tilo_actions::InvocationId,
648    ) -> Option<std::sync::mpsc::Receiver<Result<Landed, String>>> {
649        self.core.waits.borrow_mut().wait(invocation)
650    }
651
652    pub(crate) fn new(
653        app_id: &'static str,
654        resource_id: &str,
655        read_only: bool,
656        me: Option<&str>,
657    ) -> Self {
658        Self {
659            core: DispatchCore::new(app_id, resource_id, (R::KIND, R::LABEL), read_only, me),
660            group: Rc::default(),
661            permission_of: Rc::new(tilo_resource::need_of::<R>),
662            submit_now: Rc::new(|host, edits, action, context| {
663                edits.apply(host, R::ACTION_TYPE, action, context)
664            }),
665            group_now: Some(Rc::new(|host, edits, group, context| {
666                edits.apply(
667                    host,
668                    &tilo_resource::group::group_type::<R>(),
669                    group,
670                    context,
671                )
672            })),
673            last: Rc::default(),
674        }
675    }
676
677    /// A dispatcher for a surface a test builds outside any host, editing as
678    /// `me`: what the surface applies now ([`Self::apply_now`]) goes into the
679    /// document the test hands it, as under a host. Queued edits
680    /// ([`Self::apply`], [`Self::submit`]) wait for a host that never takes
681    /// them; a test that needs those runs the app
682    /// ([`TestApp`](super::testing::HeadlessApp)).
683    #[cfg(any(test, feature = "test-support"))]
684    #[must_use]
685    pub fn detached(app_id: &'static str, me: &str) -> Self {
686        Self::new(app_id, R::KIND, false, Some(me))
687    }
688
689    /// What a [`detached`](Self::detached) dispatcher handed to the door
690    /// and nothing took yet — a destructive verb or a removal waiting for
691    /// the person's confirmation — as the actions the door would apply once
692    /// they agreed. Taking them empties the queue.
693    ///
694    /// # Errors
695    /// Returns why a queued invocation does not build.
696    #[cfg(any(test, feature = "test-support"))]
697    pub fn take_asked(&self) -> Result<Vec<R::Action>, String> {
698        self.core
699            .queue
700            .borrow_mut()
701            .drain(..)
702            .map(|(invocation, _)| {
703                tilo_resource::build_action::<R>(
704                    &invocation.address.action,
705                    &invocation.arguments,
706                    tilo_resource::origin_now_ms(),
707                )
708            })
709            .collect()
710    }
711
712    /// Who edits through this dispatcher: the participant this view is
713    /// for, the same id an action they invoke reads as its author
714    /// ([`ActionCx::author`](tilo_resource::ActionCx::author)). `None` in a
715    /// view of a past version, which nobody edits.
716    pub fn me(&self) -> Option<&str> {
717        self.core.me.as_deref()
718    }
719
720    /// A dispatcher for a past version in History: nothing it is given is
721    /// ever applied.
722    pub(crate) fn past(mut self) -> Self {
723        self.core.past();
724        self
725    }
726
727    /// Whether the view shows a past version in History mode, drawn from a
728    /// frozen document. Such a view must start no work: no worker, no
729    /// network, no files. A surface that would fetch or watch something
730    /// presents what the document holds instead.
731    pub fn is_past(&self) -> bool {
732        self.core.past
733    }
734
735    /// Whether edits from this participant will be refused, as of now: it
736    /// follows role changes while the app runs. Surfaces use it to present
737    /// themselves read-only instead of letting an edit bounce.
738    pub fn read_only(&self) -> bool {
739        self.core.read_only.get()
740    }
741
742    /// [`Self::read_only`] as observable state, for a widget that shows or
743    /// disables something while the participant may only view.
744    pub fn read_only_state(&self) -> LocalState<bool> {
745        self.core.read_only.clone()
746    }
747
748    /// Whether the host currently allows what `need` is, as observable view
749    /// state. It follows changes even when writing remains allowed and the
750    /// resource document does not change. Bind it to a conditional pane or
751    /// label; action invocation still checks the host's current authority.
752    pub fn permission_state(&self, need: ActionNeed) -> LocalState<bool> {
753        self.core.asked_state(Asked::Need(need))
754    }
755
756    /// Whether the participant may govern the room (invite, grant, change
757    /// its settings), as observable view state: for an owner's pane.
758    pub fn govern_state(&self) -> LocalState<bool> {
759        self.core.asked_state(Asked::Govern)
760    }
761
762    /// The same dispatcher deciding by `permissions`, for a test of a
763    /// surface whose participant may only comment, or only view.
764    #[cfg(any(test, feature = "test-support"))]
765    #[must_use]
766    pub fn with_permissions(self, permissions: LivePermissions) -> Self {
767        self.core.follow(permissions);
768        self.core.refresh_read_only();
769        self
770    }
771
772    /// Whether the participant may, as of now, do what `need` is: an
773    /// undoable change for most edits, one of the comments for a reply.
774    pub fn may(&self, need: &ActionNeed) -> bool {
775        self.core.may(need)
776    }
777
778    /// Whether the participant may, as of now, change the room's settings.
779    pub fn may_govern(&self) -> bool {
780        self.core.may_govern()
781    }
782
783    /// Whether the participant may apply `action`
784    /// ([`tilo_resource::need_of`]).
785    pub fn may_apply(&self, action: &R::Action) -> bool {
786        self.core.may(&(self.permission_of)(action))
787    }
788
789    /// The last edit or effect submitted here that was refused — denied
790    /// permission, unavailable, failed — and why, for a view to show. An
791    /// edit waiting for the user's confirmation is not refused.
792    pub fn refused(&self) -> LocalState<Option<Refused>> {
793        self.core.refused.clone()
794    }
795
796    /// The last submission of this view the door applied, with what it
797    /// answered: the ids a "new" verb minted ([`Landed::minted`]), for a
798    /// surface that opens what it just created.
799    pub fn landed(&self) -> LocalState<Option<Landed>> {
800        self.core.landed.clone()
801    }
802
803    /// Why `action` may not be applied by this participant.
804    fn not_allowed(&self, action: &R::Action) -> ActionError {
805        ActionError::Refused(self.core.not_allowed(&(self.permission_of)(action)))
806    }
807
808    /// Invoke one of the app's effects (`dispatcher.effect(&Backups::BackUp)`)
809    /// through the same door as a button bound to it: permission, the
810    /// effect's availability and a destructive effect's confirmation are
811    /// checked, then its handler runs. A participant who may only view still
812    /// invokes the effects declared `kind = "read"`.
813    pub fn effect<E: Effect>(&self, effect: &E) {
814        self.core.queue_invocation(
815            ActionAddress::new(self.core.resource_id.as_ref(), effect.id()),
816            effect.arguments(),
817        );
818    }
819
820    /// Invoke `effect` as the live row `row` offers it
821    /// ([`LiveRow::effect`](super::LiveRow::effect)): under the row's
822    /// contract, so a confirmation the row asks for (typing the name of the
823    /// device it erases) is asked here too.
824    pub fn effect_on<E: Effect>(&self, row: &str, effect: &E) {
825        self.core
826            .queue_invocation(ActionAddress::new(row, effect.id()), serde_json::Map::new());
827    }
828
829    /// Submit a field edit (`dispatcher.submit(p.favorite.set(true))`) or a
830    /// verb (`dispatcher.submit(Snippets::DELETE.with(id))`) through the
831    /// door, after the event, as a control bound to it would.
832    ///
833    /// An effect's invocation ([`Invoke::effect`]) reaches its handler as
834    /// [`Self::effect`] does.
835    pub fn submit(&self, submit: impl Submit<R>) {
836        let id = submit.action_id();
837        match submit.into_invoke() {
838            Ok(invoke) => self.submit_invoke(invoke),
839            Err(error) => {
840                super::controls::unbound::<R>(&format!("`{id}`"), &error);
841                self.core.refuse(id, error);
842            }
843        }
844    }
845
846    fn submit_invoke(&self, invoke: Invoke<R>) {
847        if self.core.refusal(invoke.id, invoke.is_effect()) {
848            return;
849        }
850        if invoke.is_effect() {
851            self.queue(invoke);
852            return;
853        }
854        if !self.may_invoke(&invoke) {
855            self.core.refuse(invoke.id, self.core.view_only());
856            return;
857        }
858        {
859            let mut group = self.group.borrow_mut();
860            if group.0 > 0 {
861                group.1.push(invoke);
862                return;
863            }
864        }
865        self.queue(invoke);
866    }
867
868    fn queue(&self, invoke: Invoke<R>) {
869        self.core.queue_invocation(
870            ActionAddress::new(self.core.resource_id.as_ref(), invoke.id),
871            invoke.arguments,
872        );
873    }
874
875    /// Whether the participant may take `invoke`: what its declaration, or
876    /// the typed value it applies, needs. One this resource does not declare
877    /// (an edit bound to a surface's view state) is left to the host, which
878    /// checks what its published action needs.
879    fn may_invoke(&self, invoke: &Invoke<R>) -> bool {
880        if invoke.id == tilo_resource::APPLY || invoke.id == tilo_resource::FIELD_EDIT {
881            return match invoke.build(0) {
882                Ok(action) => self.may_apply(&action),
883                Err(_) => !self.read_only(),
884            };
885        }
886        may_declared(
887            &self.core,
888            &tilo_resource::vocabulary::<R>().declarations(),
889            invoke.id,
890            invoke.is_group(),
891        )
892    }
893
894    /// Open the platform search with `request`, narrowed to this app or one
895    /// part of it (`SearchRequest::within`). Searching is a view, so a
896    /// participant who may only view can search too.
897    pub fn open_search(&self, request: &ui_core::search::SearchRequest) {
898        self.core.queue.borrow_mut().push_back((
899            ui_core::search::open_search_invocation(request),
900            super::effect::acting_for(),
901        ));
902    }
903
904    /// Ask for the link to `resource` (this resource or one nested in it,
905    /// such as an item) as a control bound to its `share-link` would: the
906    /// framework copies it to the clipboard and reports it in
907    /// [`Self::landed`], or why there is none in [`Self::refused`]. A link
908    /// grants nothing, so a participant who may only view asks too.
909    pub fn share_link(&self, resource: &str) {
910        self.core.queue_invocation(
911            ActionAddress::new(resource, tilo_resource::share::SHARE_LINK),
912            serde_json::Map::new(),
913        );
914    }
915
916    /// Invoke an action published on a nested resource, such as one item of
917    /// a collection.
918    pub(crate) fn invoke_at(
919        &self,
920        address: ActionAddress,
921        arguments: serde_json::Map<String, serde_json::Value>,
922    ) {
923        if self.read_only() {
924            return;
925        }
926        self.core.queue_invocation(address, arguments);
927    }
928
929    /// Submit one typed action value — an edit with no named declaration,
930    /// such as a drag's final geometry. The door applies it after the
931    /// event; if it refuses it, [`Self::refused`] says why.
932    pub fn apply(&self, action: &R::Action) {
933        match Invoke::<R>::apply(action) {
934            Ok(invoke) => self.submit_invoke(invoke),
935            Err(error) => self.core.refuse(tilo_resource::APPLY, error.to_string()),
936        }
937    }
938
939    /// Submit every edit `edits` makes through this dispatcher (or a clone
940    /// of it) as one action: one change, one version in history, one undo
941    /// step. For a gesture that is several edits — split a linked clip pair,
942    /// delete a selection — which the user takes back with one Ctrl+Z.
943    ///
944    /// One action is one undo step by default; a group is the explicit
945    /// exception. Groups nest: the outermost one submits. A group of one
946    /// edit submits that edit unchanged.
947    pub fn group<T>(&self, edits: impl FnOnce() -> T) -> T {
948        let _group = self.begin_group();
949        edits()
950    }
951
952    /// Open a group that submits when the returned guard drops: the
953    /// [`group`](Self::group) for code whose edits are spread over a scope,
954    /// such as one input event of a custom surface.
955    #[must_use = "the group submits when this guard drops"]
956    pub fn begin_group(&self) -> DispatchGroup<R> {
957        let mut group = self.group.borrow_mut();
958        group.0 = group.0.saturating_add(1);
959        drop(group);
960        DispatchGroup {
961            dispatcher: self.clone(),
962        }
963    }
964
965    fn end_group(&self) {
966        let members = {
967            let mut group = self.group.borrow_mut();
968            group.0 = group.0.saturating_sub(1);
969            if group.0 > 0 {
970                return;
971            }
972            std::mem::take(&mut group.1)
973        };
974        let mut members = members.into_iter();
975        match (members.next(), members.next()) {
976            (None, _) => {}
977            (Some(only), None) => self.queue(only),
978            (Some(first), Some(second)) => {
979                self.queue(Invoke::group([first, second].into_iter().chain(members)));
980            }
981        }
982    }
983
984    /// Apply a field edit (`p.title.splice(..)`) or a verb to the document
985    /// `edits` lends, now, as [`Self::apply_now`] applies a typed action: for
986    /// an editor that must see its own edit before the next keystroke.
987    ///
988    /// One that needs confirming (a destructive verb, a removal) is not
989    /// applied now: it goes to the host's door, which asks first, and
990    /// applies once the person agreed, as [`Self::submit`] does.
991    ///
992    /// # Errors
993    /// Returns why the submission does not build, or as [`Self::apply_now`].
994    pub fn submit_now(
995        &self,
996        edits: &mut Edits<'_>,
997        submit: impl Submit<R>,
998    ) -> Result<(), ActionError> {
999        let invoke = submit.into_invoke().map_err(ActionError::Refused)?;
1000        if invoke.is_group() {
1001            return self.group_now(edits, &invoke);
1002        }
1003        let action = invoke
1004            .build(tilo_resource::origin_now_ms())
1005            .map_err(ActionError::Refused)?;
1006        if asks(&action) {
1007            return self.ask_first(invoke, &action);
1008        }
1009        self.apply_now(edits, &action)
1010    }
1011
1012    /// Hand an action that needs confirming to the host's door, which asks
1013    /// before it applies: nothing applied now skips the person's consent.
1014    fn ask_first(&self, invoke: Invoke<R>, action: &R::Action) -> Result<(), ActionError> {
1015        if let Some(reason) = self.core.unreadable() {
1016            return Err(ActionError::Refused(reason));
1017        }
1018        if !self.may_apply(action) {
1019            return Err(self.not_allowed(action));
1020        }
1021        self.submit_invoke(invoke);
1022        Ok(())
1023    }
1024
1025    /// [`Self::submit_now`] of a group (`Invoke::group(edits)`): its members
1026    /// applied as one action, one change and one entry in history, all or
1027    /// nothing.
1028    fn group_now(&self, edits: &mut Edits<'_>, invoke: &Invoke<R>) -> Result<(), ActionError> {
1029        let Some(group_now) = &self.group_now else {
1030            return Err(ActionError::Refused(
1031                "this record's edits are applied one at a time".into(),
1032            ));
1033        };
1034        self.core.applies_now()?;
1035        let group = tilo_resource::group::build_group::<R>(
1036            &invoke.arguments,
1037            tilo_resource::origin_now_ms(),
1038        )
1039        .map_err(ActionError::Refused)?;
1040        if let Some(refused) = group.actions.iter().find(|action| !self.may_apply(action)) {
1041            return Err(self.not_allowed(refused));
1042        }
1043        // A group holding a removal asks first, as the queued door does.
1044        if group.actions.iter().any(asks) {
1045            self.submit_invoke(invoke.clone());
1046            return Ok(());
1047        }
1048        let mut context = self.core.context();
1049        if let Some(me) = &self.core.me {
1050            context = context.with_invoker(ActionInvoker::actor(me.as_ref()));
1051        }
1052        group_now(&mut self.core.host.borrow_mut(), edits, &group, context)?;
1053        // A group is a step of its own: nothing continues it.
1054        *self.last.borrow_mut() = None;
1055        self.core.grouped();
1056        Ok(())
1057    }
1058
1059    /// Apply one typed action to the document `edits` lends — an event's,
1060    /// or a settle pass's — now, attributed to the participant, with history
1061    /// recording what the action did.
1062    /// One event's actions are one undo step, and an action that continues
1063    /// the previous one ([`Resource::coalesces`], the next letter of a word)
1064    /// joins its step. An action that needs confirming (a destructive verb,
1065    /// a removal) is not applied now: it is submitted to the host's door,
1066    /// which asks the person first ([`Self::apply`]).
1067    ///
1068    /// # Errors
1069    /// Returns [`ActionError::Refused`] for a participant who may only view,
1070    /// and the action's own refusal; nothing is written either way.
1071    pub fn apply_now(&self, edits: &mut Edits<'_>, action: &R::Action) -> Result<(), ActionError> {
1072        self.apply_now_as(edits, action, self.core.context(), false)
1073    }
1074
1075    /// [`Self::apply_now`] of an action the person already confirmed, as
1076    /// the host applies it once they agreed: what a test resolving the
1077    /// queue as confirmed applies.
1078    ///
1079    /// # Errors
1080    /// As [`apply_now`](Self::apply_now).
1081    pub fn apply_confirmed(
1082        &self,
1083        edits: &mut Edits<'_>,
1084        action: &R::Action,
1085    ) -> Result<(), ActionError> {
1086        self.apply_now_as(edits, action, self.core.context(), true)
1087    }
1088
1089    fn apply_now_as(
1090        &self,
1091        edits: &mut Edits<'_>,
1092        action: &R::Action,
1093        context: ActionContext,
1094        confirmed: bool,
1095    ) -> Result<(), ActionError> {
1096        self.core.applies_now()?;
1097        if !self.may_apply(action) {
1098            return Err(self.not_allowed(action));
1099        }
1100        // Applying now never skips the person's consent: an action that
1101        // needs confirming goes to the door, which asks first.
1102        if asks(action) && !confirmed {
1103            self.apply(action);
1104            return Ok(());
1105        }
1106        // Content from outside is nobody's step: it neither continues the
1107        // open one nor ends it.
1108        let step = tilo_actions::Action::change_kind(action).is_step();
1109        // Only an action right after this dispatcher's last one, with nothing
1110        // in between, can continue it.
1111        let serial = self.core.serial.get();
1112        let continues = step
1113            && self
1114                .last
1115                .borrow()
1116                .as_ref()
1117                .is_some_and(|(at, previous)| *at == serial && R::coalesces(previous, action));
1118        let context = self.core.step_context(context, step, continues);
1119        (self.submit_now)(&mut self.core.host.borrow_mut(), edits, action, context)?;
1120        if !step {
1121            return Ok(());
1122        }
1123        let serial = self.core.stepped(continues, R::continuable(action));
1124        *self.last.borrow_mut() = Some((serial, action.clone()));
1125        Ok(())
1126    }
1127
1128    /// The dispatcher of another record than this resource's — the
1129    /// person's own record, a settings section — published at `id`: its
1130    /// queued invocations go through the same door as this resource's, and
1131    /// what it applies now `apply` applies to that record. The room's roles
1132    /// do not reach it; only its person changes it.
1133    pub(crate) fn for_record<C: Resource>(
1134        &self,
1135        id: &str,
1136        read_only: bool,
1137        apply: impl Fn(&C::Action) -> Result<(), ActionError> + 'static,
1138    ) -> Dispatcher<C> {
1139        Dispatcher {
1140            core: self.core.for_record(id, (C::KIND, C::LABEL), read_only),
1141            group: Rc::default(),
1142            permission_of: Rc::new(|_: &C::Action| ActionNeed::of(tilo_actions::ActionKind::Read)),
1143            submit_now: Rc::new(move |_, _, action: &C::Action, _| apply(action)),
1144            group_now: None,
1145            last: Rc::default(),
1146        }
1147    }
1148
1149    /// The dispatcher of a resource nested in this one at `id` — a note's
1150    /// rich-text body, a draft's SQL — whose every edit is this resource's
1151    /// action `wrap(edit)`.
1152    ///
1153    /// The child is edited like a root: its named actions,
1154    /// [`apply`](Self::apply), [`group`](Self::group) and
1155    /// [`apply_now`](Self::apply_now) take `C`'s own actions, coalesce into
1156    /// undo steps by `C`'s [`Resource::coalesces`], and history names them
1157    /// after `C`'s labels. Underneath, each applies as this resource's
1158    /// action: the same permission check, history, undo and replay. Queued
1159    /// invocations are addressed to the child resource `id`, so this
1160    /// resource publishes the child at the same id with the same `wrap`
1161    /// ([`tilo_resource::publish_child`] in [`Resource::children`]), which
1162    /// is also what lets the palette, agents and scripts reach it.
1163    #[must_use]
1164    pub fn child<C: Resource>(
1165        &self,
1166        id: &str,
1167        wrap: impl Fn(C::Action) -> R::Action + 'static,
1168    ) -> Dispatcher<C> {
1169        let parent = Rc::clone(&self.submit_now);
1170        let parent_permission = Rc::clone(&self.permission_of);
1171        let wrap = Rc::new(wrap);
1172        let wrap_for_permission = Rc::clone(&wrap);
1173        Dispatcher {
1174            core: self.core.child(id, (C::KIND, C::LABEL)),
1175            group: Rc::default(),
1176            permission_of: Rc::new(move |action: &C::Action| {
1177                parent_permission(&wrap_for_permission(action.clone()))
1178            }),
1179            // History names the child and its edit; what applies is the
1180            // parent's action carrying it.
1181            group_now: None,
1182            submit_now: Rc::new(move |host, edits, action: &C::Action, context| {
1183                parent(host, edits, &wrap(action.clone()), context)
1184            }),
1185            last: Rc::default(),
1186        }
1187    }
1188
1189    /// End the gesture the last immediate actions belonged to: the pointer
1190    /// was let go of a slider or a drag handle. The next action starts a
1191    /// step of its own even when it would continue the last one
1192    /// ([`Resource::coalesces`]), so two drags of one slider are two undo
1193    /// steps while every value one drag passes through is one.
1194    pub fn settle(&self) {
1195        self.last.borrow_mut().take();
1196    }
1197
1198    pub(crate) fn name_resource(&self, label: &str) {
1199        self.core.resource_label.set(label.to_owned());
1200    }
1201
1202    /// The id of the app whose resource this is, which presence keys name
1203    /// ([`ResourceSelection::presence`](super::ResourceSelection::presence)).
1204    pub fn app_id(&self) -> &'static str {
1205        self.core.app_id
1206    }
1207}
1208
1209/// Whether the participant `core` edits for may take the action `id` the
1210/// resource's `declarations` may declare: what its declaration needs; a
1211/// group of edits what writing does; anything else what reading does.
1212fn may_declared(
1213    core: &DispatchCore,
1214    declarations: &[tilo_resource::Declaration],
1215    id: &str,
1216    group: bool,
1217) -> bool {
1218    match declarations.iter().find(|decl| decl.id == id) {
1219        Some(decl) => core.may(&ActionNeed {
1220            kind: decl.kind,
1221            field: decl.field.map(str::to_owned),
1222            privileged: false,
1223        }),
1224        None if group => !core.read_only.get(),
1225        None => core.may(&ActionNeed::of(ActionKind::Read)),
1226    }
1227}
1228
1229/// An edit or effect the door refused ([`Dispatcher::refused`]).
1230#[derive(Clone, Debug, PartialEq, Eq)]
1231pub struct Refused {
1232    /// The id of the refused action or effect.
1233    pub action: String,
1234    /// Why, for a person.
1235    pub reason: String,
1236}
1237
1238/// A submission the door applied ([`Dispatcher::landed`]).
1239#[derive(Clone, Debug, PartialEq)]
1240pub struct Landed {
1241    /// The id of the applied action.
1242    pub action: String,
1243    /// What it answered, if anything.
1244    pub answer: Option<serde_json::Value>,
1245    /// The resources it created (`<kind>/<field>/<id>` for an item).
1246    pub created: Vec<String>,
1247}
1248
1249impl Landed {
1250    fn of(action: &str, output: &tilo_actions::ActionOutput) -> Self {
1251        Self {
1252            action: action.to_owned(),
1253            answer: output.value.clone(),
1254            created: output.resources.clone(),
1255        }
1256    }
1257
1258    /// The ids the action minted, in the order it minted them: the item a
1259    /// "new" verb created first.
1260    #[must_use]
1261    pub fn minted(&self) -> Vec<tilo_resource::Id> {
1262        self.created
1263            .iter()
1264            .filter_map(|resource| resource.rsplit('/').next())
1265            .map(tilo_resource::Id::from_raw)
1266            .collect()
1267    }
1268}
1269
1270/// Whether `action` needs the person's confirmation before it applies.
1271fn asks<A: tilo_actions::Action>(action: &A) -> bool {
1272    tilo_actions::Action::risk(action).requires_confirmation()
1273}
1274
1275/// The submissions each effect queued that have not landed yet, and the
1276/// effects waiting for theirs.
1277#[derive(Default)]
1278struct Waits(std::collections::HashMap<tilo_actions::InvocationId, Wait>);
1279
1280#[derive(Default)]
1281struct Wait {
1282    /// Queued, not yet answered by the door.
1283    outstanding: usize,
1284    /// The last that landed, or the first refusal.
1285    result: Option<Result<Landed, String>>,
1286    waiter: Option<std::sync::mpsc::Sender<Result<Landed, String>>>,
1287}
1288
1289impl Waits {
1290    fn submitted(&mut self, invocation: tilo_actions::InvocationId) {
1291        let wait = self.0.entry(invocation).or_default();
1292        wait.outstanding = wait.outstanding.saturating_add(1);
1293    }
1294
1295    fn landed(&mut self, invocation: tilo_actions::InvocationId, landed: Result<Landed, String>) {
1296        let Some(wait) = self.0.get_mut(&invocation) else {
1297            return;
1298        };
1299        wait.outstanding = wait.outstanding.saturating_sub(1);
1300        // The first refusal stands: the effect failed with it.
1301        if !matches!(wait.result, Some(Err(_))) {
1302            wait.result = Some(landed);
1303        }
1304        self.settle(invocation);
1305    }
1306
1307    fn wait(
1308        &mut self,
1309        invocation: tilo_actions::InvocationId,
1310    ) -> Option<std::sync::mpsc::Receiver<Result<Landed, String>>> {
1311        let wait = self.0.get_mut(&invocation)?;
1312        let (sender, receiver) = std::sync::mpsc::channel();
1313        wait.waiter = Some(sender);
1314        self.settle(invocation);
1315        Some(receiver)
1316    }
1317
1318    /// Answer the waiting effect once nothing it submitted is outstanding;
1319    /// forget an effect nobody waits on then.
1320    fn settle(&mut self, invocation: tilo_actions::InvocationId) {
1321        let Some(wait) = self.0.get_mut(&invocation) else {
1322            return;
1323        };
1324        if wait.outstanding > 0 {
1325            return;
1326        }
1327        if let Some(waiter) = wait.waiter.take()
1328            && let Some(result) = wait.result.take()
1329        {
1330            let _ = waiter.send(result);
1331        }
1332        self.0.remove(&invocation);
1333    }
1334}
1335
1336/// An open [`Dispatcher::begin_group`]: every edit submitted until it drops
1337/// becomes one action.
1338pub struct DispatchGroup<R: Resource> {
1339    dispatcher: Dispatcher<R>,
1340}
1341
1342impl<R: Resource> Drop for DispatchGroup<R> {
1343    fn drop(&mut self) {
1344        self.dispatcher.end_group();
1345    }
1346}