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
5#[cfg(test)]
6#[path = "dispatch_tests.rs"]
7mod tests;
8
9use std::cell::{Cell, RefCell};
10use std::collections::VecDeque;
11use std::rc::Rc;
12
13use tilo_actions::{
14 ActionAddress, ActionBinding, ActionContext, ActionError, ActionInvoker, ActionNeed,
15 ControlInvocation,
16};
17
18use super::controls::Actions;
19use tilo_resource::{Edit, Effect, Invoke, Resource};
20use ui_core::LocalState;
21use ui_core::automerge::ChangeHash;
22use ui_core::permissions::{ActionKind, LivePermissions, PermissionScope, TreePath, policy};
23
24/// What a view asks whether its participant may do.
25#[derive(Clone, Debug, PartialEq, Eq)]
26enum Asked {
27 Need(ActionNeed),
28 Govern,
29 /// Whether what the workspace holds has arrived, so a choice made from the
30 /// record (a free seat) is made from what everyone sees.
31 Arrived,
32}
33use ui_core::widget::Edits;
34use ui_core::widget::controls::{AttributedControlInvocation, Origin};
35
36/// What reacting needs: an undoable change below the reactions field,
37/// wherever it is.
38fn reaction_need() -> ActionNeed {
39 ActionNeed::reactions()
40}
41
42/// Whether the field edit in `arguments` reacts or takes a reaction back.
43fn reacts(arguments: &serde_json::Map<String, serde_json::Value>) -> bool {
44 arguments
45 .get(tilo_resource::EDIT_PARAM)
46 .and_then(|edit| edit.get("op"))
47 .and_then(serde_json::Value::as_str)
48 .is_some_and(|op| op == "react" || op == "unreact")
49}
50
51/// A queued invocation and who asked for it.
52type Queued = (AttributedControlInvocation, Origin);
53
54/// Something a dispatcher submits and a button is bound to: a field edit
55/// made at a path (`p.favorite.set(true)`), a verb with its arguments, or an
56/// invocation built some other way. No app writes an action id or an
57/// argument map.
58pub trait Submit<R: Resource>: Sized {
59 /// The public id of the action this submits.
60 fn action_id(&self) -> &'static str;
61
62 /// The invocation of the host door this submits.
63 fn into_invoke(self) -> Result<Invoke<R>, String>;
64
65 /// The binding of a button that submits this when pressed, or `None`
66 /// (and a failure in tests) when the resource does not publish it.
67 fn control_binding(self, actions: &Actions<R>) -> Option<ActionBinding<()>> {
68 let id = self.action_id();
69 match self.into_invoke() {
70 Ok(invoke) => actions.control_invocation(invoke),
71 Err(error) => {
72 super::controls::unbound::<R>(&format!("`{id}`"), &error);
73 None
74 }
75 }
76 }
77}
78
79impl<R: Resource> Submit<R> for Invoke<R> {
80 fn action_id(&self) -> &'static str {
81 self.id
82 }
83
84 fn into_invoke(self) -> Result<Invoke<R>, String> {
85 match self.refusal() {
86 Some(refusal) => Err(refusal.to_owned()),
87 None => Ok(self),
88 }
89 }
90}
91
92impl<R: Resource> Submit<R> for Edit<R> {
93 fn action_id(&self) -> &'static str {
94 tilo_resource::FIELD_EDIT
95 }
96
97 fn into_invoke(self) -> Result<Invoke<R>, String> {
98 Invoke::edit(&self).map_err(|error| error.to_string())
99 }
100}
101
102/// Submits a view's edits. Cloneable; clones share one queue.
103///
104/// Two doors, one path. [`submit`](Self::submit) and [`apply`](Self::apply)
105/// queue an invocation the host resolves after the event, exactly as a
106/// control bound to an action would. [`apply_now`](Self::apply_now) applies
107/// one typed action immediately, inside the event or the settle pass
108/// ([`Widget::settle`](ui_core::widget::Widget::settle)), for an editor that must
109/// see the result of its own edit before the next keystroke (the paragraph a
110/// split created, the cell a table insert made). Both check the
111/// participant's permission, apply through the replayable executor with
112/// attribution, and are undone per author.
113///
114/// # A drag as one undo step
115///
116/// A slider or a drag handle a surface draws itself applies each value it
117/// passes through with [`apply_now`](Self::apply_now), so the document — and
118/// every collaborator — follows the drag live. The resource declares that
119/// one value continues the one before it ([`Resource::coalesces`]: the same
120/// field of the same item), which makes the whole drag one step, and the
121/// surface calls [`settle`](Self::settle) when the pointer is let go, so the
122/// next drag is a step of its own:
123///
124/// ```ignore
125/// fn coalesces(previous: &Action, next: &Action) -> bool {
126/// matches!((previous, next), (Action::SetLevel(..), Action::SetLevel(..)))
127/// }
128///
129/// // In the surface's `handle_event`:
130/// WindowEvent::CursorMoved { .. } if self.dragging => {
131/// let _ = self.dispatch.apply_now(&mut ctx.edits(), &Action::SetLevel(level));
132/// }
133/// WindowEvent::MouseInput { state: ElementState::Released, .. } => {
134/// self.dragging = false;
135/// self.dispatch.settle();
136/// }
137/// ```
138///
139/// A toolkit slider bound with `ViewCx::control` instead invokes its action
140/// once per drag when built with `Slider::commits_on_release`.
141pub struct Dispatcher<R: Resource> {
142 /// Everything that does not name the resource's action type, compiled
143 /// once for every resource.
144 core: DispatchCore,
145 /// The invocations of the open group, and how many groups are open.
146 group: Rc<RefCell<(usize, Vec<Invoke<R>>)>>,
147 /// What applying an action needs: the resource's own
148 /// [`Resource::permission`], or for a child its parent's action's.
149 permission_of: PermissionOf<R>,
150 /// Applies one immediate action: the resource's own, or for a child
151 /// resource its parent's action carrying it.
152 submit_now: SubmitNow<R>,
153 /// Applies a group of the resource's own actions now as one action;
154 /// `None` for a dispatcher of a nested or other record.
155 group_now: Option<GroupNow<R>>,
156 /// The last immediate action and its serial, to tell whether the next
157 /// continues it.
158 last: Rc<RefCell<Option<Serialed<R>>>>,
159}
160
161/// What a dispatcher keeps that does not name the resource's action type
162/// ([`DispatchState`]), dropped out of line: a dispatcher is dropped in code
163/// generic over its resource, and dropping each of these fields there would
164/// compile it into every app.
165pub(crate) struct DispatchCore(std::mem::ManuallyDrop<DispatchState>);
166
167impl Drop for DispatchCore {
168 #[inline(never)]
169 fn drop(&mut self) {
170 // SAFETY: the state is dropped here once, as its owner goes, and
171 // nothing reads it after.
172 unsafe { std::mem::ManuallyDrop::drop(&mut self.0) }
173 }
174}
175
176impl DispatchCore {
177 fn of(state: DispatchState) -> Self {
178 Self(std::mem::ManuallyDrop::new(state))
179 }
180}
181
182impl std::ops::Deref for DispatchCore {
183 type Target = DispatchState;
184
185 fn deref(&self) -> &DispatchState {
186 &self.0
187 }
188}
189
190impl std::ops::DerefMut for DispatchCore {
191 fn deref_mut(&mut self) -> &mut DispatchState {
192 &mut self.0
193 }
194}
195
196/// What a dispatcher keeps that does not name the resource's action type:
197/// who edits, the door's queue, permissions, refusals and the undo-step
198/// bookkeeping.
199pub(crate) struct DispatchState {
200 app_id: &'static str,
201 resource_id: Rc<str>,
202 /// The route of the record the resource is, as the workspace names its node
203 /// (empty for the app's own record of the workspace): an action on an item
204 /// of it is decided at that item's node.
205 record: Rc<str>,
206 /// [`Resource::KIND`] and [`Resource::LABEL`] of the resource.
207 kind: &'static str,
208 label: &'static str,
209 /// For a resource nested in another ([`Dispatcher::child`]), the kind and
210 /// label of the record that holds it. Its edits apply as that record's
211 /// field edits, so history and the change's claim name that record's
212 /// kind: the app's surface describes no other, and every receiver holds
213 /// (and the signer refuses) a change claiming the nested kind.
214 applied_as: Option<(&'static str, &'static str)>,
215 /// The readable name published by the resource host, shared with surfaces.
216 resource_label: LocalState<String>,
217 /// Who edits through this dispatcher; `None` in a view of a past version.
218 me: Option<Rc<str>>,
219 /// A dispatcher lent to an admitted effect keeps its original custody.
220 /// Clones cannot turn later work into the host participant's invocation.
221 /// Share the immutable scope rather than embedding its owner/lease in every
222 /// view dispatcher: copying the full scope also inflates construction stacks.
223 custody: Option<Rc<ui_core::invocation_scope::ResourceInvocationScope>>,
224 /// Each queued invocation with the effect invocation it continues,
225 /// when an effect's handler or completion queued it.
226 queue: Rc<RefCell<VecDeque<Queued>>>,
227 /// Who asks for what is queued now: the person, unless the app submits
228 /// it on its own ([`Dispatcher::submit_on_its_own`]).
229 asking: Rc<Cell<Origin>>,
230 /// The references the views opened ([`Dispatcher::open_reference`]),
231 /// on their way through the session's action door.
232 pub(crate) opener: super::reference::Opener,
233 read_only: LocalState<bool>,
234 /// The participant's permissions as the host keeps them, for an action
235 /// that needs less than writing (a reply a commenter may add) or more.
236 /// Absent outside a host: a past version, which nobody edits, or a
237 /// test's detached dispatcher, which may do what writing allows.
238 permissions: Rc<RefCell<Option<LivePermissions>>>,
239 permission_states: PermissionStates,
240 pub(crate) refused: LocalState<Option<Refused>>,
241 /// An edit applied now was refused: the views that may have previewed
242 /// it are presented the stored state again, as after a refusal at the
243 /// door.
244 represent: Rc<Cell<bool>>,
245 /// The last submission of this view the door applied.
246 landed: LocalState<Option<Landed>>,
247 /// What the views said they did ([`Dispatcher::say_when_kept`]), not yet
248 /// handed to the record to hold for the workspace.
249 notices: Rc<RefCell<Vec<String>>>,
250 /// The last of them the workspace kept, to say now ([`Dispatcher::said`]).
251 pub(crate) said: LocalState<Option<String>>,
252 /// What the effects that queued submissions here wait on: how many are
253 /// still to land, per effect invocation.
254 waits: Rc<RefCell<Waits>>,
255 /// Why the record does not read as the resource, while it does not:
256 /// nothing is submitted against a state nobody can see.
257 unreadable: Rc<RefCell<Option<String>>>,
258 host: Rc<RefCell<ui_core::local_dispatch::LocalDispatch>>,
259 /// Counts the immediate actions of the resource and its children, so a
260 /// dispatcher can tell that nothing came between its last action and
261 /// its next.
262 serial: Rc<Cell<u64>>,
263 /// Every immediate action since the last undo boundary continued the one
264 /// before it ([`Resource::coalesces`]): one step, still open.
265 continuing: Rc<Cell<bool>>,
266 /// An immediate action was applied since the last boundary was taken.
267 applied: Rc<Cell<bool>>,
268 /// The boundary last taken opened a step that stays open.
269 opened: Rc<Cell<bool>>,
270 /// The view shows a past version in History, not the live resource.
271 past: bool,
272 /// The last immediate action may be continued by a later one
273 /// ([`Resource::continuable`]).
274 continuable: Rc<Cell<bool>>,
275 /// The step the open undo step's changes record, decided before each
276 /// change commits, so a coalesced step stays one step after a restart.
277 gesture: Rc<Cell<Option<tilo_actions::ActionId>>>,
278 /// Where the resource's state comes from and where its actions go: the
279 /// document the host lends the pass, or the session over the app's one
280 /// connection ([`super::Source`]).
281 source: super::Source,
282}
283
284impl Clone for DispatchCore {
285 /// Out of line: a derived clone is inlined into every dispatcher's clone, which each app
286 /// compiles for its resource.
287 #[inline(never)]
288 fn clone(&self) -> Self {
289 Self(std::mem::ManuallyDrop::new(DispatchState {
290 app_id: self.app_id,
291 resource_id: self.resource_id.clone(),
292 record: self.record.clone(),
293 kind: self.kind,
294 label: self.label,
295 applied_as: self.applied_as,
296 resource_label: self.resource_label.clone(),
297 me: self.me.clone(),
298 custody: self.custody.clone(),
299 queue: self.queue.clone(),
300 asking: self.asking.clone(),
301 opener: self.opener.clone(),
302 read_only: self.read_only.clone(),
303 permissions: self.permissions.clone(),
304 permission_states: self.permission_states.clone(),
305 refused: self.refused.clone(),
306 represent: self.represent.clone(),
307 landed: self.landed.clone(),
308 notices: self.notices.clone(),
309 said: self.said.clone(),
310 waits: self.waits.clone(),
311 unreadable: self.unreadable.clone(),
312 host: self.host.clone(),
313 serial: self.serial.clone(),
314 continuing: self.continuing.clone(),
315 applied: self.applied.clone(),
316 opened: self.opened.clone(),
317 past: self.past,
318 continuable: self.continuable.clone(),
319 gesture: self.gesture.clone(),
320 source: self.source.clone(),
321 }))
322 }
323}
324
325/// What applying one action needs.
326type PermissionOf<R> = Rc<dyn Fn(&<R as Resource>::Action) -> ActionNeed>;
327
328/// One observable per requested permission, shared by this resource's views.
329type PermissionStates = Rc<RefCell<Vec<(Asked, LocalState<bool>)>>>;
330
331/// An immediate action and the serial it was applied at.
332type Serialed<R> = (u64, <R as Resource>::Action);
333
334/// Applies a group of the resource's actions now, as one action.
335type GroupNow<R> = Rc<
336 dyn Fn(
337 &mut ui_core::local_dispatch::LocalDispatch,
338 &mut Edits<'_>,
339 &tilo_resource::ActionGroup<<R as Resource>::Action>,
340 ActionContext,
341 ) -> Result<(), ActionError>,
342>;
343
344/// Applies one typed action to the document a pass may change, through
345/// the host.
346type SubmitNow<R> = Rc<
347 dyn Fn(
348 &mut ui_core::local_dispatch::LocalDispatch,
349 &mut Edits<'_>,
350 &<R as Resource>::Action,
351 ActionContext,
352 ) -> Result<(), ActionError>,
353>;
354
355impl<R: Resource> Clone for Dispatcher<R> {
356 fn clone(&self) -> Self {
357 Self {
358 core: self.core.clone(),
359 group: Rc::clone(&self.group),
360 permission_of: Rc::clone(&self.permission_of),
361 submit_now: Rc::clone(&self.submit_now),
362 group_now: self.group_now.clone(),
363 last: Rc::clone(&self.last),
364 }
365 }
366}
367
368impl DispatchCore {
369 fn new(
370 app_id: &'static str,
371 resource_id: &str,
372 (kind, label): (&'static str, &'static str),
373 read_only: bool,
374 me: Option<&str>,
375 ) -> Self {
376 Self(std::mem::ManuallyDrop::new(DispatchState {
377 app_id,
378 resource_id: Rc::from(resource_id),
379 record: Rc::from(""),
380 kind,
381 label,
382 resource_label: LocalState::new(label.to_owned()),
383 me: me.map(Rc::from),
384 custody: None,
385 queue: Rc::default(),
386 asking: Rc::default(),
387 opener: super::reference::Opener::default(),
388 read_only: LocalState::new(read_only),
389 permissions: Rc::default(),
390 permission_states: Rc::default(),
391 refused: LocalState::new(None),
392 represent: Rc::default(),
393 landed: LocalState::new(None),
394 notices: Rc::default(),
395 said: LocalState::new(None),
396 waits: Rc::default(),
397 unreadable: Rc::default(),
398 host: Rc::default(),
399 serial: Rc::default(),
400 continuing: Rc::default(),
401 applied: Rc::default(),
402 opened: Rc::default(),
403 past: false,
404 continuable: Rc::default(),
405 gesture: Rc::default(),
406 source: super::Source::Document,
407 applied_as: None,
408 }))
409 }
410
411 /// The core of the dispatcher of another record than this resource's,
412 /// published at `id` ([`Dispatcher::for_record`]).
413 fn for_record(
414 &self,
415 id: &str,
416 (kind, label): (&'static str, &'static str),
417 read_only: bool,
418 ) -> Self {
419 Self::of(DispatchState {
420 app_id: self.app_id,
421 me: self.me.clone(),
422 custody: self.custody.clone(),
423 resource_id: Rc::from(id),
424 record: Rc::from(""),
425 kind,
426 label,
427 resource_label: LocalState::new(label.to_owned()),
428 queue: Rc::clone(&self.queue),
429 asking: Rc::clone(&self.asking),
430 opener: self.opener.clone(),
431 read_only: LocalState::new(read_only || self.past),
432 permissions: Rc::default(),
433 permission_states: Rc::default(),
434 refused: self.refused.clone(),
435 represent: self.represent.clone(),
436 landed: self.landed.clone(),
437 notices: Rc::clone(&self.notices),
438 said: self.said.clone(),
439 waits: Rc::clone(&self.waits),
440 unreadable: Rc::default(),
441 host: Rc::clone(&self.host),
442 serial: Rc::default(),
443 continuing: Rc::default(),
444 applied: Rc::default(),
445 opened: Rc::default(),
446 past: self.past,
447 continuable: Rc::default(),
448 gesture: Rc::default(),
449 // Another record of an app in this process is the app's own,
450 // kept here: only the source of this one may be the session's.
451 source: super::Source::Document,
452 applied_as: None,
453 })
454 }
455
456 /// The core of the dispatcher of a resource nested in this one at `id`
457 /// ([`Dispatcher::child`]): it shares this one's permissions and undo
458 /// steps.
459 fn child(&self, id: &str, (kind, label): (&'static str, &'static str)) -> Self {
460 let mut child = self.clone();
461 child.resource_id = Rc::from(id);
462 child.kind = kind;
463 child.label = label;
464 child.applied_as = self.applied_as.or(Some((self.kind, self.label)));
465 child.resource_label = LocalState::new(label.to_owned());
466 child
467 }
468
469 fn queue_invocation(
470 &self,
471 address: ActionAddress,
472 arguments: serde_json::Map<String, serde_json::Value>,
473 ) {
474 let on_behalf_of = match &self.custody {
475 Some(custody) => {
476 if let Err(error) = custody.check() {
477 self.refuse(&address.action, error);
478 return;
479 }
480 Some(custody.invocation())
481 }
482 None => super::effect::acting_for(),
483 };
484 if let Some(invocation) = on_behalf_of {
485 self.waits.borrow_mut().submitted(invocation);
486 }
487 self.queue.borrow_mut().push_back((
488 (ControlInvocation { address, arguments }, on_behalf_of),
489 self.asking.get(),
490 ));
491 }
492
493 /// Whether the session keeps this resource's record, so submissions go
494 /// over the app's connection rather than into a document the host lent
495 /// ([`super::Source::Connection`]).
496 fn submits_over_connection(&self) -> bool {
497 self.source.is_connection()
498 }
499
500 /// Submit a control the app's view pressed, over the connection that
501 /// keeps the record, as the person's gesture (`one-app-model.md` §4).
502 pub(crate) fn submit_view_control(
503 &self,
504 address: &ActionAddress,
505 arguments: serde_json::Map<String, serde_json::Value>,
506 ) {
507 self.submit_remote(address, arguments, true);
508 }
509
510 /// Submit `address` with `arguments` over the connection, as the person's
511 /// gesture when `in_person`; the refusal is told in
512 /// [`Self::refuse`] as an edit applied now is.
513 fn submit_remote(
514 &self,
515 address: &ActionAddress,
516 arguments: serde_json::Map<String, serde_json::Value>,
517 in_person: bool,
518 ) {
519 let ask = super::Ask::new(
520 address.resource.clone(),
521 address.action.clone(),
522 arguments,
523 in_person,
524 );
525 match self
526 .source
527 .submit(&ask)
528 .unwrap_or_else(|| Err("this resource is kept here; it has no connection".to_owned()))
529 {
530 Ok(()) => {}
531 Err(reason) => self.refuse(&address.action, reason),
532 }
533 }
534
535 pub(crate) fn note_continuation(
536 &self,
537 invocation: tilo_actions::InvocationId,
538 action: &str,
539 result: &Result<ui_core::host_operation::ResourceOperationStart, tilo_actions::DoorError>,
540 ) {
541 let landed = match result {
542 Ok(ui_core::host_operation::ResourceOperationStart::Completed(output)) => {
543 Ok(Landed::of(action, output))
544 }
545 // Handed on to the broker: it applies there, answering nothing here.
546 Ok(ui_core::host_operation::ResourceOperationStart::Accepted) => Ok(Landed {
547 action: action.to_owned(),
548 answer: None,
549 created: Vec::new(),
550 }),
551 Err(error) => Err(error.to_string()),
552 };
553 self.waits.borrow_mut().landed(invocation, landed);
554 }
555
556 /// This core, lent to an admitted effect under `custody`.
557 #[inline(never)]
558 fn for_custody(&self, custody: ui_core::invocation_scope::ResourceInvocationScope) -> Self {
559 let mut core = self.clone();
560 core.custody = Some(Rc::new(custody));
561 core
562 }
563
564 fn past(&mut self) {
565 self.past = true;
566 self.read_only.set(true);
567 self.permission_states = Rc::default();
568 }
569
570 pub(crate) fn follow(&self, permissions: LivePermissions) {
571 *self.permissions.borrow_mut() = Some(permissions);
572 self.refresh_permission_states();
573 }
574
575 fn asked_state(&self, asked: Asked) -> LocalState<bool> {
576 let mut states = self.permission_states.borrow_mut();
577 if let Some((_, state)) = states.iter().find(|(held, _)| *held == asked) {
578 return state.clone();
579 }
580 let state = LocalState::new(self.may_asked(&asked));
581 states.push((asked, state.clone()));
582 state
583 }
584
585 pub(crate) fn refresh_permission_states(&self) {
586 for (asked, state) in self.permission_states.borrow().iter() {
587 let allowed = self.may_asked(asked);
588 if state.get() != allowed {
589 state.set(allowed);
590 }
591 }
592 }
593
594 fn may_asked(&self, asked: &Asked) -> bool {
595 match asked {
596 Asked::Need(need) => self.may(need),
597 Asked::Govern => self.may_govern(),
598 Asked::Arrived => !ui_core::workspace_sync::before_first_sync(),
599 }
600 }
601
602 fn may(&self, need: &ActionNeed) -> bool {
603 let mut node = self.node();
604 if let Some(field) = &need.field {
605 node = node.field(field);
606 }
607 self.may_scope(&PermissionScope::Action {
608 node,
609 kind: need.kind,
610 })
611 }
612
613 /// The node below the workspace where the workspace decides this resource's
614 /// changes, as far as a decision here can name it: an item of the
615 /// record for a resource nested in one at `<kind>/<field>/<item>` (a
616 /// note's body), so what the workspace grants on that item alone (an item
617 /// shared to edit) lets it be changed here too; else the app's.
618 fn node(&self) -> TreePath {
619 let app = TreePath::root().app(self.app_id);
620 if self.applied_as.is_none() {
621 return app;
622 }
623 match self
624 .resource_id
625 .rsplit_once('/')
626 .and_then(|(collection, item)| Some((collection.split_once('/')?, item)))
627 {
628 Some(((kind, field), item))
629 if !kind.is_empty()
630 && !field.is_empty()
631 && !field.contains('/')
632 && !item.is_empty() =>
633 {
634 app.kind(kind).resource(&*self.record).item(item)
635 }
636 _ => app,
637 }
638 }
639
640 /// Whether the participant may react here: never in a past version,
641 /// else as the workspace grants reactions, which the Commenter role does as
642 /// it grants comments.
643 pub(crate) fn may_react(&self) -> bool {
644 !self.past && self.may(&reaction_need())
645 }
646
647 /// Whether the participant may read what this window holds: one who may
648 /// not is found nothing of it.
649 pub(crate) fn may_read(&self) -> bool {
650 self.may(&ActionNeed::of(ActionKind::Read))
651 }
652
653 /// Whether the workspace this record is in has anyone besides this window's
654 /// participant, its owner or a member, here now or not: what it holds is
655 /// shared with them.
656 pub(crate) fn workspace_has_others(&self) -> bool {
657 self.permissions
658 .borrow()
659 .as_ref()
660 .is_some_and(ui_core::permissions::LivePermissions::workspace_has_others)
661 }
662
663 /// React (or take a reaction back) at `path`, a [`Reactions`](tilo_resource::Reactions)
664 /// field of the resource, as the person this window is for: the field
665 /// edit `op` through the door, as [`Dispatcher::submit`] submits one,
666 /// for code that does not know the resource's type (the window's bar).
667 pub(crate) fn submit_reaction(
668 &self,
669 path: Vec<tilo_resource::Segment>,
670 op: tilo_resource::EditOp,
671 ) {
672 let id = tilo_resource::FIELD_EDIT;
673 if self.refusal(id, false) {
674 return;
675 }
676 if !self.may_react() {
677 self.refuse(id, self.not_allowed(&reaction_need()));
678 return;
679 }
680 let edit = Edit::<()>::new(path, op);
681 match tilo_resource::__private::json_via_bytes(&edit) {
682 Ok(edit) => self.queue_invocation(
683 ActionAddress::new(self.resource_id.as_ref(), id),
684 serde_json::Map::from_iter([(tilo_resource::EDIT_PARAM.to_owned(), edit)]),
685 ),
686 Err(error) => self.refuse(id, error.to_string()),
687 }
688 }
689
690 fn may_govern(&self) -> bool {
691 self.may_scope(&PermissionScope::governance(
692 policy::roles::governance::SETTINGS,
693 ))
694 }
695
696 /// Whether the participant may invite people to the workspace.
697 pub(crate) fn may_invite(&self) -> bool {
698 self.may_scope(&PermissionScope::governance(
699 policy::roles::governance::INVITE,
700 ))
701 }
702
703 fn may_scope(&self, scope: &PermissionScope) -> bool {
704 match self.permissions.borrow().as_ref() {
705 Some(permissions) => permissions.check(scope),
706 // A detached dispatcher may what writing allows; a past version
707 // nothing.
708 None => !self.read_only.get(),
709 }
710 }
711
712 #[cfg(any(test, feature = "test-support"))]
713 fn refresh_read_only(&self) -> bool {
714 let writes = self.permissions.borrow().as_ref().map(|permissions| {
715 permissions.check(&PermissionScope::app(self.app_id, ActionKind::Undoable))
716 });
717 match writes {
718 Some(writes) => self.set_read_only(!writes),
719 None => false,
720 }
721 }
722
723 pub(crate) fn set_read_only(&self, read_only: bool) -> bool {
724 if self.read_only.get() == read_only {
725 return false;
726 }
727 self.read_only.set(read_only);
728 true
729 }
730
731 fn view_only(&self) -> String {
732 format!("this {} is open to view only", self.label.to_lowercase())
733 }
734
735 /// Why an edit that is `need` was refused.
736 fn not_allowed(&self, need: &ActionNeed) -> String {
737 match (&need.field, need.kind) {
738 (Some(field), _) if field == policy::roles::COMMENTS_FIELD => {
739 format!("you may not comment on this {}", self.label.to_lowercase())
740 }
741 (Some(field), _) if field == policy::roles::REACTIONS_FIELD => {
742 format!("you may not react to this {}", self.label.to_lowercase())
743 }
744 (None, ActionKind::Undoable) => self.view_only(),
745 _ => format!("you may not do this to this {}", self.label.to_lowercase()),
746 }
747 }
748
749 /// Why nothing can be submitted now, when the record is unreadable.
750 fn unreadable(&self) -> Option<String> {
751 self.unreadable.borrow().clone()
752 }
753
754 pub(crate) fn refuse(&self, action: &str, reason: String) {
755 self.refused.set(Some(Refused {
756 action: action.to_owned(),
757 item: None,
758 reason,
759 by_workspace: false,
760 }));
761 }
762
763 /// What the views said they did since last asked, for the record to
764 /// hold until the workspace keeps it.
765 pub(crate) fn take_notices(&self) -> Vec<String> {
766 std::mem::take(&mut *self.notices.borrow_mut())
767 }
768
769 /// The workspace kept what `text` says was done.
770 pub(crate) fn kept(&self, text: &str) {
771 self.said.set(Some(text.to_owned()));
772 }
773
774 /// The workspace refused what `refusal` names after this device applied it;
775 /// the record withdrew it.
776 pub(crate) fn refuse_by_workspace(&self, refusal: &ui_core::record::RecordRefusal) {
777 self.refused.set(Some(Refused {
778 action: refusal.action.clone(),
779 item: refusal.item.clone(),
780 reason: refusal.reason.clone(),
781 by_workspace: true,
782 }));
783 }
784
785 /// Why an invocation `id` submitted now is refused before its
786 /// permission is asked: the record is unreadable (unless it is an
787 /// effect) or the effect lending this dispatcher lost its custody.
788 fn refusal(&self, id: &str, effect: bool) -> bool {
789 if let Some(reason) = self.unreadable()
790 && !effect
791 {
792 self.refuse(id, reason);
793 return true;
794 }
795 if let Some(custody) = &self.custody
796 && let Err(error) = custody.check()
797 {
798 self.refuse(id, error);
799 return true;
800 }
801 false
802 }
803
804 /// Why nothing is applied now: an effect's dispatcher submits through
805 /// the queue, and an unreadable record takes nothing.
806 fn applies_now(&self) -> Result<(), ActionError> {
807 if self.custody.is_some() {
808 return Err(ActionError::Refused(
809 "caller-scoped effects submit document changes through queued actions".into(),
810 ));
811 }
812 if let Some(reason) = self.unreadable() {
813 return Err(ActionError::Refused(reason));
814 }
815 Ok(())
816 }
817
818 /// The context of an action of this resource, attributed to whoever the
819 /// edit is applied for, and owned by this dispatcher's app. Without the
820 /// app, the executor guessed one from the action type (`org.tilo.Chat` of
821 /// `chat.workspace`), and every receiver checking the change against the
822 /// record's app refused it as a verb that app does not publish.
823 fn context(&self) -> ActionContext {
824 let context = self.resource_context();
825 if self.app_id.is_empty() {
826 context
827 } else {
828 context.with_app(self.app_id, self.label)
829 }
830 }
831
832 fn resource_context(&self) -> ActionContext {
833 match self.applied_as {
834 // As `ActionSpec::history_context` names an action a nested
835 // resource re-encodes: the enclosing kind, the nested resource
836 // kept as a facet.
837 Some((kind, label)) => {
838 let mut context =
839 ActionContext::for_resource(self.resource_id.as_ref(), kind, label);
840 context.facets.push(tilo_actions::ActionFacet::new(
841 tilo_actions::NESTED_RESOURCE_FACET,
842 self.resource_label.get(),
843 ));
844 context
845 }
846 None => ActionContext::for_resource(
847 self.resource_id.as_ref(),
848 self.kind,
849 self.resource_label.get(),
850 ),
851 }
852 }
853
854 /// `context` named for this resource and its editor, in the step it
855 /// opens or `continues` when it is a `step`.
856 fn step_context(&self, context: ActionContext, step: bool, continues: bool) -> ActionContext {
857 // History names the resource and who edited it even when the
858 // surface's context does not: whoever the edit is applied for (an
859 // agent at its own caret, a collaborator a scenario stages), else
860 // this window's person.
861 let mut named = self.context();
862 if let Some(invoker) = ui_core::invocation_scope::current_invoker()
863 .or_else(|| self.me.as_ref().map(|me| ActionInvoker::actor(me.as_ref())))
864 {
865 named = named.with_invoker(invoker);
866 }
867 let mut context = context.enriched_by(&named);
868 // An action that continues the open step joins it; any other opens
869 // its own.
870 if step {
871 let gesture = self.gesture.get().filter(|_| continues).unwrap_or_default();
872 self.gesture.set(Some(gesture));
873 context = context.in_gesture(Some(gesture));
874 }
875 context
876 }
877
878 /// Note an immediate action that is a step and was applied: whether it
879 /// `continues` the open one and is `continuable` itself. Its serial.
880 fn stepped(&self, continues: bool, continuable: bool) -> u64 {
881 let serial = self.serial.get().wrapping_add(1);
882 self.serial.set(serial);
883 self.continuable.set(continuable);
884 // The event continues the open step only if every action it applied
885 // did.
886 let first_of_event = !self.applied.replace(true);
887 self.continuing
888 .set(continues && (first_of_event || self.continuing.get()));
889 serial
890 }
891
892 /// Note a group applied now: a step of its own, which nothing
893 /// continues.
894 fn grouped(&self) {
895 self.serial.set(self.serial.get().wrapping_add(1));
896 self.applied.set(true);
897 self.continuing.set(false);
898 }
899
900 pub(crate) fn take_committed(&self) -> Option<Vec<ChangeHash>> {
901 let bracket = self.host.borrow_mut().take_committed();
902 let continuing = self.continuing.replace(false);
903 self.opened
904 .set(bracket.is_some() && !continuing && self.continuable.get());
905 self.applied.set(false);
906 if continuing { None } else { bracket }
907 }
908}
909
910/// What a root reads and drives of its dispatcher, without its type.
911impl DispatchCore {
912 /// Who edits through this dispatcher; `None` in a view of a past version.
913 pub(crate) fn me(&self) -> Option<&str> {
914 self.me.as_deref()
915 }
916
917 /// Whether it is a view of a past version.
918 pub(crate) fn is_past(&self) -> bool {
919 self.past
920 }
921
922 pub(crate) fn note_landed(&self, action: &str, output: &tilo_actions::ActionOutput) {
923 self.landed.set(Some(Landed::of(action, output)));
924 }
925
926 /// Say why the record does not read as the resource (`None` once it
927 /// does again); until then every submission is refused with it.
928 pub(crate) fn set_unreadable(&self, reason: Option<String>) {
929 *self.unreadable.borrow_mut() = reason;
930 }
931
932 /// The next queued invocation, with the effect invocation it continues.
933 /// The host draining it hears who asked for it.
934 pub(crate) fn take_attributed(&self) -> Option<AttributedControlInvocation> {
935 let (taken, origin) = self.queue.borrow_mut().pop_front()?;
936 ui_core::widget::controls::note_drained_origin(origin);
937 Some(taken)
938 }
939
940 pub(crate) fn has_pending(&self) -> bool {
941 !self.queue.borrow().is_empty()
942 }
943
944 /// Whether an edit applied now was refused since the last call, so the
945 /// views are to be presented the stored state again.
946 pub(crate) fn take_represent(&self) -> bool {
947 self.represent.replace(false)
948 }
949
950 /// The step the last boundary opened stays open for the actions that
951 /// continue it ([`Resource::coalesces`]).
952 pub(crate) fn keeps_step_open(&self) -> bool {
953 self.opened.get()
954 }
955
956 pub(crate) fn resource_id(&self) -> &str {
957 &self.resource_id
958 }
959
960 pub(crate) fn app_id(&self) -> &'static str {
961 self.app_id
962 }
963
964 /// Where this dispatcher's resource's state comes from and where its
965 /// edits go ([`super::Source`]).
966 pub(crate) fn source(&self) -> &super::Source {
967 &self.source
968 }
969}
970
971impl<R: Resource> Dispatcher<R> {
972 /// What does not name the resource type.
973 pub(crate) fn core(&self) -> &DispatchCore {
974 &self.core
975 }
976
977 pub(crate) fn for_custody(
978 &self,
979 custody: ui_core::invocation_scope::ResourceInvocationScope,
980 ) -> Self {
981 Self {
982 core: self.core.for_custody(custody),
983 // An effect's group cannot absorb the person's unrelated input.
984 group: Rc::default(),
985 permission_of: Rc::clone(&self.permission_of),
986 submit_now: Rc::clone(&self.submit_now),
987 group_now: self.group_now.clone(),
988 last: Rc::clone(&self.last),
989 }
990 }
991
992 /// What the effect `invocation` submitted here, once it all landed: the
993 /// last submission's [`Landed`], or why one was refused. Nothing arrives
994 /// when it submitted nothing.
995 pub(crate) fn wait_landed(
996 &self,
997 invocation: tilo_actions::InvocationId,
998 ) -> Option<std::sync::mpsc::Receiver<Result<Landed, String>>> {
999 self.core.waits.borrow_mut().wait(invocation)
1000 }
1001
1002 pub(crate) fn new(
1003 app_id: &'static str,
1004 resource_id: &str,
1005 read_only: bool,
1006 me: Option<&str>,
1007 ) -> Self {
1008 Self {
1009 core: DispatchCore::new(app_id, resource_id, (R::KIND, R::LABEL), read_only, me),
1010 group: Rc::default(),
1011 permission_of: Rc::new(tilo_resource::need_of::<R>),
1012 submit_now: Rc::new(|host, edits, action, context| {
1013 edits.apply(host, R::ACTION_TYPE, action, context)
1014 }),
1015 group_now: Some(Rc::new(|host, edits, group, context| {
1016 edits.apply(
1017 host,
1018 &tilo_resource::group::group_type::<R>(),
1019 group,
1020 context,
1021 )
1022 })),
1023 last: Rc::default(),
1024 }
1025 }
1026
1027 /// A dispatcher for a surface a test builds outside any host, editing as
1028 /// `me`: what the surface applies now ([`Self::apply_now`]) goes into the
1029 /// document the test hands it, as under a host. Queued edits
1030 /// ([`Self::apply`], [`Self::submit`]) wait for a host that never takes
1031 /// them; a test that needs those runs the app
1032 /// ([`TestApp`](super::testing::HeadlessApp)).
1033 #[cfg(any(test, feature = "test-support"))]
1034 #[must_use]
1035 pub fn detached(app_id: &'static str, me: &str) -> Self {
1036 Self::new(app_id, R::KIND, false, Some(me))
1037 }
1038
1039 /// What a [`detached`](Self::detached) dispatcher handed to the door
1040 /// and nothing took yet — a destructive verb or a removal waiting for
1041 /// the person's confirmation — as the actions the door would apply once
1042 /// they agreed. Taking them empties the queue.
1043 #[cfg(any(test, feature = "test-support"))]
1044 pub fn take_asked(&self) -> Result<Vec<R::Action>, String> {
1045 self.core
1046 .queue
1047 .borrow_mut()
1048 .drain(..)
1049 .map(|((invocation, _), _)| {
1050 tilo_resource::build_action::<R>(
1051 &invocation.address.action,
1052 &invocation.arguments,
1053 tilo_resource::origin_now_ms(),
1054 )
1055 })
1056 .collect()
1057 }
1058
1059 /// Who edits through this dispatcher: the participant this view is
1060 /// for, the same id an action they invoke reads as its author
1061 /// ([`ActionCx::author`](tilo_resource::ActionCx::author)). `None` in a
1062 /// view of a past version, which nobody edits.
1063 pub fn me(&self) -> Option<&str> {
1064 self.core.me.as_deref()
1065 }
1066
1067 /// A dispatcher for a past version in History: nothing it is given is
1068 /// ever applied.
1069 pub(crate) fn past(mut self) -> Self {
1070 self.core.past();
1071 self
1072 }
1073
1074 /// The dispatcher of the record the workspace names by `route` (empty for
1075 /// the app's own record of the workspace).
1076 pub(crate) fn on_record(mut self, route: &str) -> Self {
1077 self.core.record = Rc::from(route);
1078 self
1079 }
1080
1081 /// Submissions go over `source`, the session's document host, rather
1082 /// than into the document a host lends the pass
1083 /// ([`super::Source::Connection`]).
1084 pub(crate) fn over_source(mut self, source: super::Source) -> Self {
1085 self.core.source = source;
1086 self
1087 }
1088
1089 /// Whether the view shows a past version in History mode, drawn from a
1090 /// frozen document. Such a view must start no work: no worker, no
1091 /// network, no files. A surface that would fetch or watch something
1092 /// presents what the document holds instead.
1093 pub fn is_past(&self) -> bool {
1094 self.core.past
1095 }
1096
1097 /// Whether edits from this participant will be refused, as of now: it
1098 /// follows role changes while the app runs. Surfaces use it to present
1099 /// themselves read-only instead of letting an edit bounce.
1100 pub fn read_only(&self) -> bool {
1101 self.core.read_only.get()
1102 }
1103
1104 /// [`Self::read_only`] as observable state, for a widget that shows or
1105 /// disables something while the participant may only view.
1106 pub fn read_only_state(&self) -> LocalState<bool> {
1107 self.core.read_only.clone()
1108 }
1109
1110 /// Whether the host currently allows what `need` is, as observable view
1111 /// state. It follows changes even when writing remains allowed and the
1112 /// resource document does not change. Bind it to a conditional pane or
1113 /// label; action invocation still checks the host's current authority.
1114 pub fn permission_state(&self, need: ActionNeed) -> LocalState<bool> {
1115 self.core.asked_state(Asked::Need(need))
1116 }
1117
1118 /// The participant's standing in the workspace as the host keeps it when
1119 /// called: who they are, the workspace's policy and what bounds them. For a
1120 /// runtime that decides its own invocations (a tilolang program's) by
1121 /// the policy the door decides by. `None` outside a host.
1122 pub fn standing_source(&self) -> impl Fn() -> Option<policy::Standing> + 'static {
1123 let permissions = Rc::clone(&self.core.permissions);
1124 move || {
1125 permissions
1126 .borrow()
1127 .as_ref()
1128 .map(|live| live.current().standing().clone())
1129 }
1130 }
1131
1132 /// Whether the participant may govern the workspace (invite, grant, change
1133 /// its settings), as observable view state: for an owner's pane.
1134 pub fn govern_state(&self) -> LocalState<bool> {
1135 self.core.asked_state(Asked::Govern)
1136 }
1137
1138 /// Whether what the workspace holds has arrived, as observable view state:
1139 /// false while the window's workspace has not finished its first sync with
1140 /// its host ([`ui_core::workspace_sync`]), when a record joined from it may
1141 /// still be missing the content its creator wrote. True outside a workspace
1142 /// that syncs with a host, and for a host's own view of a record, which
1143 /// holds what the workspace holds.
1144 pub fn arrived_state(&self) -> LocalState<bool> {
1145 self.core.asked_state(Asked::Arrived)
1146 }
1147
1148 /// The same dispatcher deciding by `permissions`, for a test of a
1149 /// surface whose participant may only comment, or only view.
1150 #[cfg(any(test, feature = "test-support"))]
1151 #[must_use]
1152 pub fn with_permissions(self, permissions: LivePermissions) -> Self {
1153 self.core.follow(permissions);
1154 self.core.refresh_read_only();
1155 self
1156 }
1157
1158 /// Whether the participant may, as of now, do what `need` is: an
1159 /// undoable change for most edits, one of the comments for a reply.
1160 pub fn may(&self, need: &ActionNeed) -> bool {
1161 self.core.may(need)
1162 }
1163
1164 /// Whether the participant may, as of now, change the workspace's settings.
1165 pub fn may_govern(&self) -> bool {
1166 self.core.may_govern()
1167 }
1168
1169 /// Whether the record is shared: someone else belongs to its workspace, or
1170 /// is in it with `people` now. Where it is, reactions are offered.
1171 pub fn is_shared(&self, people: &super::People) -> bool {
1172 self.core.workspace_has_others() || people.others_here()
1173 }
1174
1175 /// Whether the participant may apply `action`
1176 /// ([`tilo_resource::need_of`]).
1177 pub fn may_apply(&self, action: &R::Action) -> bool {
1178 self.core.may(&(self.permission_of)(action))
1179 }
1180
1181 /// The last edit or effect submitted here that was refused — denied
1182 /// permission, unavailable, failed — and why, for a view to show. An
1183 /// edit waiting for the user's confirmation is not refused.
1184 pub fn refused(&self) -> LocalState<Option<Refused>> {
1185 self.core.refused.clone()
1186 }
1187
1188 /// The last submission of this view the door applied, with what it
1189 /// answered: the ids a "new" verb minted ([`Landed::minted`]), for a
1190 /// surface that opens what it just created.
1191 pub fn landed(&self) -> LocalState<Option<Landed>> {
1192 self.core.landed.clone()
1193 }
1194
1195 /// Say `text` of what was just submitted here ("Added Buy milk") once
1196 /// it is kept: at once in a record nobody else decides, once the workspace
1197 /// accepts the change in a shared one, and never when the workspace refuses
1198 /// it ([`Self::refused`] says why instead). The text shows through
1199 /// [`Self::said`].
1200 pub fn say_when_kept(&self, text: impl Into<String>) {
1201 self.core.notices.borrow_mut().push(text.into());
1202 }
1203
1204 /// What [`Self::say_when_kept`] said that was kept, for the view to
1205 /// show and then clear.
1206 pub fn said(&self) -> LocalState<Option<String>> {
1207 self.core.said.clone()
1208 }
1209
1210 /// Why `action` may not be applied by this participant.
1211 fn not_allowed(&self, action: &R::Action) -> ActionError {
1212 ActionError::Refused(self.core.not_allowed(&(self.permission_of)(action)))
1213 }
1214
1215 /// Invoke one of the app's effects (`dispatcher.effect(&Backups::BackUp)`)
1216 /// through the same door as a button bound to it: permission, the
1217 /// effect's availability and a destructive effect's confirmation are
1218 /// checked, then its handler runs. A participant who may only view still
1219 /// invokes the effects declared `kind = "read"`.
1220 pub fn effect<E: Effect>(&self, effect: &E) {
1221 self.core.queue_invocation(
1222 ActionAddress::new(self.core.resource_id.as_ref(), effect.id()),
1223 effect.arguments(),
1224 );
1225 }
1226
1227 /// Whether the person at this device may do `effect` at once, as their
1228 /// own gesture on the surface: a shortcut, a pointer gesture, a row of
1229 /// the surface's own menu, whose arguments the gesture itself supplied
1230 /// (the clipboard they paste). Like a keystroke, such a gesture is its
1231 /// own confirmation, so a surface told `true` performs the effect
1232 /// itself. The confirmation a destructive declaration asks for stays
1233 /// with the callers who supply its arguments — the palette, a script,
1234 /// an agent — because automation is the same door: under any
1235 /// invocation (an invoked action, an effect's handler or completion, a
1236 /// dispatcher lent to one) this is `false`, and the surface submits
1237 /// the effect ([`Self::effect`]) for the host to check and confirm as
1238 /// for any caller. So it is in a past version, and when the
1239 /// participant's role does not grant what the declaration needs.
1240 pub fn is_own_gesture<E: Effect>(&self, effect: &E) -> bool {
1241 if !self.gesture_is_own() {
1242 return false;
1243 }
1244 let id = effect.id();
1245 E::declarations()
1246 .iter()
1247 .find(|decl| decl.id == id)
1248 .is_some_and(|decl| {
1249 self.may(&ActionNeed {
1250 kind: decl.action_kind,
1251 field: None,
1252 privileged: false,
1253 })
1254 })
1255 }
1256
1257 /// Whether what runs now is the person's own gesture at this device,
1258 /// not automation: no invocation, effect, custody or past version is
1259 /// acting.
1260 fn gesture_is_own(&self) -> bool {
1261 !(self.core.past
1262 || self.core.custody.is_some()
1263 || super::effect::acting_for().is_some()
1264 || ui_core::invocation_scope::current_invoker().is_some())
1265 }
1266
1267 /// Invoke `effect` as the live row `row` offers it
1268 /// ([`LiveRow::effect`](super::LiveRow::effect)): under the row's
1269 /// contract, so a confirmation the row asks for (typing the name of the
1270 /// device it erases) is asked here too.
1271 pub fn effect_on<E: Effect>(&self, row: &str, effect: &E) {
1272 self.core
1273 .queue_invocation(ActionAddress::new(row, effect.id()), serde_json::Map::new());
1274 }
1275
1276 /// Submit a field edit (`dispatcher.submit(p.favorite.set(true))`) or a
1277 /// verb (`dispatcher.submit(Snippets::DELETE.with(id))`) through the
1278 /// door, after the event, as a control bound to it would.
1279 ///
1280 /// An effect's invocation ([`Invoke::effect`]) reaches its handler as
1281 /// [`Self::effect`] does.
1282 pub fn submit(&self, submit: impl Submit<R>) {
1283 let id = submit.action_id();
1284 match submit.into_invoke() {
1285 Ok(invoke) => self.submit_invoke(invoke),
1286 Err(error) => {
1287 super::controls::unbound::<R>(&format!("`{id}`"), &error);
1288 self.core.refuse(id, error);
1289 }
1290 }
1291 }
1292
1293 /// [`submit`](Self::submit) what the app does on its own, for nobody's
1294 /// gesture: a read receipt, keeping a record in step with what is shown.
1295 /// It goes through the same door and permissions; a refusal is told in
1296 /// [`Self::refused`] and logged, but never said over the window as if
1297 /// the person had pressed something and been refused.
1298 pub fn submit_on_its_own(&self, submit: impl Submit<R>) {
1299 let before = self.core.asking.replace(Origin::App);
1300 self.submit(submit);
1301 self.core.asking.set(before);
1302 }
1303
1304 fn submit_invoke(&self, invoke: Invoke<R>) {
1305 if self.core.refusal(invoke.id, invoke.is_effect()) {
1306 return;
1307 }
1308 if invoke.is_effect() {
1309 self.queue(invoke);
1310 return;
1311 }
1312 if !self.may_invoke(&invoke) {
1313 self.core.refuse(invoke.id, self.core.view_only());
1314 return;
1315 }
1316 {
1317 let mut group = self.group.borrow_mut();
1318 if group.0 > 0 {
1319 group.1.push(invoke);
1320 return;
1321 }
1322 }
1323 self.queue(invoke);
1324 }
1325
1326 fn queue(&self, invoke: Invoke<R>) {
1327 if self.core.submits_over_connection() {
1328 // The session keeps the record: the action is the person's
1329 // gesture at this view, admitted and committed by its door
1330 // (`one-app-model.md` §4).
1331 self.core.submit_remote(
1332 &ActionAddress::new(self.core.resource_id.as_ref(), invoke.id),
1333 invoke.arguments,
1334 true,
1335 );
1336 return;
1337 }
1338 self.core.queue_invocation(
1339 ActionAddress::new(self.core.resource_id.as_ref(), invoke.id),
1340 invoke.arguments,
1341 );
1342 }
1343
1344 /// Whether the participant may take `invoke`: what its declaration, or
1345 /// the typed value it applies, needs. One this resource does not declare
1346 /// (an edit bound to a surface's view state) is left to the host, which
1347 /// checks what its published action needs. A surface asks before work
1348 /// that only feeds a write (an index it would keep with a row), so a
1349 /// participant who may not make the write is not sent it.
1350 pub fn may_invoke(&self, invoke: &Invoke<R>) -> bool {
1351 // A reaction is decided where the workspace places it, below the
1352 // reactions field, as the door decides it: someone who may only
1353 // comment may react too.
1354 if invoke.id == tilo_resource::FIELD_EDIT && reacts(&invoke.arguments) {
1355 return self.core.may_react();
1356 }
1357 if invoke.id == tilo_resource::APPLY || invoke.id == tilo_resource::FIELD_EDIT {
1358 return match invoke.build(0) {
1359 Ok(action) => self.may_apply(&action),
1360 Err(error) => {
1361 // What the invocation applies cannot be read here, so the
1362 // door decides what its published action needs.
1363 tracing::warn!(%error, action = %invoke.id, "an invocation's typed value could not be built; the door decides");
1364 !self.read_only()
1365 }
1366 };
1367 }
1368 may_declared(
1369 &self.core,
1370 &tilo_resource::vocabulary::<R>().declarations(),
1371 invoke.id,
1372 invoke.is_group(),
1373 )
1374 }
1375
1376 /// Open the platform search with `request`, narrowed to this app or one
1377 /// part of it (`SearchRequest::within`). Searching is a view, so a
1378 /// participant who may only view can search too.
1379 pub fn open_search(&self, request: &ui_core::search::SearchRequest) {
1380 self.core.queue.borrow_mut().push_back((
1381 (
1382 ui_core::search::open_search_invocation(request),
1383 super::effect::acting_for(),
1384 ),
1385 Origin::Person,
1386 ));
1387 }
1388
1389 /// Ask for the link to `resource` (this resource or one nested in it,
1390 /// such as an item) as a control bound to its `share-link` would: the
1391 /// framework copies it to the clipboard and reports it in
1392 /// [`Self::landed`], or why there is none in [`Self::refused`]. A link
1393 /// grants nothing, so a participant who may only view asks too.
1394 pub fn share_link(&self, resource: &str) {
1395 self.core.queue_invocation(
1396 ActionAddress::new(resource, tilo_resource::share::SHARE_LINK),
1397 serde_json::Map::new(),
1398 );
1399 }
1400
1401 /// The client of the session's action door the views' opens and
1402 /// reads go through.
1403 pub(crate) fn opener(&self) -> &super::reference::Opener {
1404 &self.core.opener
1405 }
1406
1407 /// Open what `href`, a `tilo://open#…` address a link in this view's
1408 /// text carries, names: the app that keeps that resource shows it,
1409 /// started first when no window does.
1410 pub fn open_link(&self, href: &str) -> Result<(), String> {
1411 super::reference::open_link(&self.core.opener, href)
1412 }
1413
1414 /// Open `reference`, which `app` keeps in its `record` (`None`: the
1415 /// app's own record of the workspace), where it lives: its app opens at
1416 /// it, launched first when no window shows it. The open is an action
1417 /// like any other, admitted as the person under their grants; a refusal
1418 /// is told in [`Self::refused`] as `open`.
1419 pub fn open_reference(
1420 &self,
1421 app: &str,
1422 record: Option<&str>,
1423 reference: &tilo_reference::ResourceRef,
1424 ) {
1425 if let Err(reason) = self.core.opener.open(app, record, reference) {
1426 self.core.refuse(super::reference::OPEN, reason);
1427 }
1428 }
1429
1430 /// Send `invoke`, an action of this resource, to the session's host
1431 /// instead of applying it here. The host decides it as the person's, as
1432 /// it decides any caller's, may first put it to the person on its own
1433 /// sheet (what a run asks is answered there, never by the app), and then
1434 /// directs it back to be applied. A refusal is told in
1435 /// [`Self::refused`].
1436 pub fn through_host(&self, invoke: &Invoke<R>) {
1437 if let Some(refusal) = invoke.refusal() {
1438 self.core.refuse(invoke.id, refusal.to_owned());
1439 return;
1440 }
1441 self.core.opener.invoke_own(
1442 ActionAddress::new(self.core.resource_id(), invoke.id),
1443 invoke.arguments.clone(),
1444 );
1445 }
1446
1447 /// Send `call`, another resource's published action, to the session's
1448 /// host, continuing `parent`, the invocation the host delivered here for
1449 /// someone: the host decides it as theirs. A past version calls nothing.
1450 pub(crate) fn call_published(
1451 &self,
1452 parent: Option<tilo_actions::InvocationId>,
1453 call: &super::Call,
1454 ) -> Result<ui_core::InvocationCall, String> {
1455 if self.core.past {
1456 return Err("a past version calls nothing".into());
1457 }
1458 if let Some(custody) = &self.core.custody {
1459 custody.check()?;
1460 }
1461 let (target, argument) = call.request()?;
1462 Ok(self.core.opener.call(parent, target, argument))
1463 }
1464
1465 /// Invoke an action published on a nested resource, such as one item of
1466 /// a collection.
1467 pub(crate) fn invoke_at(
1468 &self,
1469 address: ActionAddress,
1470 arguments: serde_json::Map<String, serde_json::Value>,
1471 ) {
1472 if self.read_only() {
1473 return;
1474 }
1475 if self.core.submits_over_connection() {
1476 self.core.submit_remote(&address, arguments, true);
1477 return;
1478 }
1479 self.core.queue_invocation(address, arguments);
1480 }
1481
1482 /// Run `invoke`, one of the actions `actions` publishes (another
1483 /// record's, such as a settings section), through the host as a control
1484 /// bound to it runs it: where another provider keeps the record, it runs
1485 /// there. From an effect's handler or completion it continues that
1486 /// effect, as its caller's. A refusal is told in [`Self::refused`].
1487 pub fn invoke_on<T: Resource>(&self, actions: &super::Actions<T>, invoke: Invoke<T>) {
1488 if let Some(refusal) = invoke.refusal() {
1489 self.core.refuse(invoke.id, refusal.to_owned());
1490 return;
1491 }
1492 self.invoke_at(
1493 ActionAddress::new(actions.resource_id(), invoke.id),
1494 invoke.arguments,
1495 );
1496 }
1497
1498 /// Submit one typed action value — an edit with no named declaration,
1499 /// such as a drag's final geometry. The door applies it after the
1500 /// event; if it refuses it, [`Self::refused`] says why.
1501 pub fn apply(&self, action: &R::Action) {
1502 match Invoke::<R>::apply(action) {
1503 Ok(invoke) => self.submit_invoke(invoke),
1504 Err(error) => self.core.refuse(tilo_resource::APPLY, error.to_string()),
1505 }
1506 }
1507
1508 /// Submit every edit `edits` makes through this dispatcher (or a clone
1509 /// of it) as one action: one change, one version in history, one undo
1510 /// step. For a gesture that is several edits — split a linked clip pair,
1511 /// delete a selection — which the user takes back with one Ctrl+Z.
1512 ///
1513 /// One action is one undo step by default; a group is the explicit
1514 /// exception. Groups nest: the outermost one submits. A group of one
1515 /// edit submits that edit unchanged.
1516 pub fn group<T>(&self, edits: impl FnOnce() -> T) -> T {
1517 let _group = self.begin_group();
1518 edits()
1519 }
1520
1521 /// Open a group that submits when the returned guard drops: the
1522 /// [`group`](Self::group) for code whose edits are spread over a scope,
1523 /// such as one input event of a custom surface.
1524 #[must_use = "the group submits when this guard drops"]
1525 pub fn begin_group(&self) -> DispatchGroup<R> {
1526 let mut group = self.group.borrow_mut();
1527 group.0 = group.0.saturating_add(1);
1528 drop(group);
1529 DispatchGroup {
1530 dispatcher: self.clone(),
1531 }
1532 }
1533
1534 fn end_group(&self) {
1535 let members = {
1536 let mut group = self.group.borrow_mut();
1537 group.0 = group.0.saturating_sub(1);
1538 if group.0 > 0 {
1539 return;
1540 }
1541 std::mem::take(&mut group.1)
1542 };
1543 let mut members = members.into_iter();
1544 match (members.next(), members.next()) {
1545 (None, _) => {}
1546 (Some(only), None) => self.queue(only),
1547 (Some(first), Some(second)) => {
1548 self.queue(Invoke::group([first, second].into_iter().chain(members)));
1549 }
1550 }
1551 }
1552
1553 /// Apply a field edit (`p.title.splice(..)`) or a verb to the document
1554 /// `edits` lends, now, as [`Self::apply_now`] applies a typed action: for
1555 /// an editor that must see its own edit before the next keystroke.
1556 ///
1557 /// One that needs confirming (a destructive verb, a removal) is not
1558 /// applied now: it goes to the host's door, which asks first, and
1559 /// applies once the person agreed, as [`Self::submit`] does.
1560 ///
1561 /// # Errors
1562 /// Returns why the submission does not build, or as [`Self::apply_now`].
1563 pub fn submit_now(
1564 &self,
1565 edits: &mut Edits<'_>,
1566 submit: impl Submit<R>,
1567 ) -> Result<(), ActionError> {
1568 let invoke = submit.into_invoke().map_err(ActionError::Refused)?;
1569 if invoke.is_group() {
1570 return self.group_now(edits, &invoke);
1571 }
1572 let _capture = self.capture_invocation(&invoke);
1573 let action = invoke
1574 .build(tilo_resource::origin_now_ms())
1575 .map_err(ActionError::Refused)?;
1576 if asks(&action) {
1577 return self.ask_first(invoke, &action);
1578 }
1579 self.apply_now(edits, &action)
1580 }
1581
1582 /// [`Self::submit_now`] of the person's own gesture on the surface: the
1583 /// button they pressed, the shortcut they typed, with arguments the
1584 /// gesture itself supplied. Like a keystroke, such a gesture is its own
1585 /// confirmation, so a destructive verb the surface offers with its own
1586 /// way back (a message held so Undo takes it back unsent) applies now.
1587 /// What has no way back (`#[action(irreversible)]`) is still asked first.
1588 /// The confirmation stays with the callers who supply the arguments
1589 /// themselves (the palette, a script, an agent): under any invocation,
1590 /// an effect's handler, custody or a past version this is
1591 /// [`Self::submit_now`], and the host asks as for any caller.
1592 ///
1593 /// # Errors
1594 /// As [`Self::submit_now`].
1595 pub fn submit_gesture(
1596 &self,
1597 edits: &mut Edits<'_>,
1598 submit: impl Submit<R>,
1599 ) -> Result<(), ActionError> {
1600 if !self.gesture_is_own() {
1601 return self.submit_now(edits, submit);
1602 }
1603 let invoke = submit.into_invoke().map_err(ActionError::Refused)?;
1604 if invoke.is_group() {
1605 return self.group_now(edits, &invoke);
1606 }
1607 let action = invoke
1608 .build(tilo_resource::origin_now_ms())
1609 .map_err(ActionError::Refused)?;
1610 // The gesture confirms only what has a way back: a verb that loses
1611 // something Undo cannot bring back is asked first, as at every door.
1612 if tilo_actions::Action::irreversible(&action) && asks(&action) {
1613 return self.ask_first(invoke, &action);
1614 }
1615 let applied = self.apply_now_as(edits, &action, self.core.context(), true);
1616 // A view that showed the edit before it was refused (a row already
1617 // gone) shows the stored state again.
1618 if applied.is_err() {
1619 self.core.represent.set(true);
1620 }
1621 applied
1622 }
1623
1624 /// [`Self::submit_gesture`] of a choice the person picked that is
1625 /// confirmed before it goes ahead, though Undo would take it back:
1626 /// Delete in a message's context menu. The host asks with the same
1627 /// question it asks for any confirmation; holding Shift skips it, and
1628 /// the choice applies at once as the person's gesture, if and only if
1629 /// Undo takes it back ([`ui_core::widget::shift_skips_confirmation`]).
1630 /// What Undo cannot take back is asked, Shift or not.
1631 ///
1632 /// A control the person presses is its own confirmation
1633 /// ([`Self::submit_gesture`]); this is for a choice made from a list of
1634 /// them, where a slip of the pointer picks the neighbour.
1635 ///
1636 /// # Errors
1637 /// As [`Self::submit_gesture`].
1638 pub fn submit_confirming(
1639 &self,
1640 edits: &mut Edits<'_>,
1641 submit: impl Submit<R>,
1642 ) -> Result<(), ActionError> {
1643 if !self.gesture_is_own() {
1644 return self.submit_now(edits, submit);
1645 }
1646 let invoke = submit.into_invoke().map_err(ActionError::Refused)?;
1647 if invoke.is_group() {
1648 return self.group_now(edits, &invoke);
1649 }
1650 let action = invoke
1651 .build(tilo_resource::origin_now_ms())
1652 .map_err(ActionError::Refused)?;
1653 if asks(&action) {
1654 // The contract asks already: the door's question, which Shift
1655 // skips there for what Undo takes back.
1656 return self.ask_first(invoke, &action);
1657 }
1658 let undoable = tilo_actions::undo_takes_back(
1659 tilo_actions::Action::action_kind(&action),
1660 tilo_actions::Action::change_kind(&action),
1661 tilo_actions::Action::irreversible(&action),
1662 );
1663 if ui_core::widget::shift_skips_confirmation(undoable) {
1664 return self.submit_gesture(edits, invoke);
1665 }
1666 if let Some(reason) = self.core.unreadable() {
1667 return Err(ActionError::Refused(reason));
1668 }
1669 if !self.may_apply(&action) {
1670 return Err(self.not_allowed(&action));
1671 }
1672 let before = self.core.asking.replace(Origin::PersonConfirming);
1673 self.submit_invoke(invoke);
1674 self.core.asking.set(before);
1675 Ok(())
1676 }
1677
1678 fn ask_first(&self, invoke: Invoke<R>, action: &R::Action) -> Result<(), ActionError> {
1679 if let Some(reason) = self.core.unreadable() {
1680 return Err(ActionError::Refused(reason));
1681 }
1682 if !self.may_apply(action) {
1683 return Err(self.not_allowed(action));
1684 }
1685 self.submit_invoke(invoke);
1686 Ok(())
1687 }
1688
1689 /// [`Self::submit_now`] of a group (`Invoke::group(edits)`): its members
1690 /// applied as one action, one change and one entry in history, all or
1691 /// nothing.
1692 fn group_now(&self, edits: &mut Edits<'_>, invoke: &Invoke<R>) -> Result<(), ActionError> {
1693 let _nonlocal = self.capture_nonlocal();
1694 let _capture = self.capture_invocation(invoke);
1695 let Some(group_now) = &self.group_now else {
1696 return Err(ActionError::Refused(
1697 "this record's edits are applied one at a time".into(),
1698 ));
1699 };
1700 self.core.applies_now()?;
1701 let group = tilo_resource::group::build_group::<R>(
1702 &invoke.arguments,
1703 tilo_resource::origin_now_ms(),
1704 )
1705 .map_err(ActionError::Refused)?;
1706 if let Some(refused) = group.actions.iter().find(|action| !self.may_apply(action)) {
1707 return Err(self.not_allowed(refused));
1708 }
1709 // A group holding a removal asks first, as the queued door does.
1710 if group.actions.iter().any(asks) {
1711 self.submit_invoke(invoke.clone());
1712 return Ok(());
1713 }
1714 let mut context = self.core.context();
1715 if let Some(me) = &self.core.me {
1716 context = context.with_invoker(ActionInvoker::actor(me.as_ref()));
1717 }
1718 group_now(&mut self.core.host.borrow_mut(), edits, &group, context)?;
1719 // A group is a step of its own: nothing continues it.
1720 *self.last.borrow_mut() = None;
1721 self.core.grouped();
1722 Ok(())
1723 }
1724
1725 /// Apply one typed action to the document `edits` lends — an event's,
1726 /// or a settle pass's — now, attributed to the participant, with history
1727 /// recording what the action did.
1728 /// One event's actions are one undo step, and an action that continues
1729 /// the previous one ([`Resource::coalesces`], the next letter of a word)
1730 /// joins its step. An action that needs confirming (a destructive verb,
1731 /// a removal) is not applied now: it is submitted to the host's door,
1732 /// which asks the person first ([`Self::apply`]).
1733 ///
1734 /// # Errors
1735 /// Returns [`ActionError::Refused`] for a participant who may only view,
1736 /// and the action's own refusal; nothing is written either way.
1737 pub fn apply_now(&self, edits: &mut Edits<'_>, action: &R::Action) -> Result<(), ActionError> {
1738 self.apply_now_as(edits, action, self.core.context(), false)
1739 }
1740
1741 /// [`Self::apply_now`] of an action the person already confirmed, as
1742 /// the host applies it once they agreed: what a test resolving the
1743 /// queue as confirmed applies.
1744 ///
1745 /// # Errors
1746 /// As [`apply_now`](Self::apply_now).
1747 pub fn apply_confirmed(
1748 &self,
1749 edits: &mut Edits<'_>,
1750 action: &R::Action,
1751 ) -> Result<(), ActionError> {
1752 self.apply_now_as(edits, action, self.core.context(), true)
1753 }
1754
1755 /// Apply `action` now to the document `edits` lends, for `invoker`: a
1756 /// step a door carries out for whoever invoked it that writes this
1757 /// record too (a container listing a new child), attributed to them.
1758 ///
1759 /// # Errors
1760 /// As [`apply_now`](Self::apply_now).
1761 pub(crate) fn apply_now_for(
1762 &self,
1763 edits: &mut Edits<'_>,
1764 action: &R::Action,
1765 invoker: &tilo_actions::ActionInvoker,
1766 ) -> Result<(), ActionError> {
1767 let context = self.core.context().with_invoker(invoker.clone());
1768 self.apply_now_as(edits, action, context, true)
1769 }
1770
1771 fn apply_now_as(
1772 &self,
1773 edits: &mut Edits<'_>,
1774 action: &R::Action,
1775 context: ActionContext,
1776 confirmed: bool,
1777 ) -> Result<(), ActionError> {
1778 let _nonlocal = self.capture_nonlocal();
1779 self.core.applies_now()?;
1780 if !self.may_apply(action) {
1781 return Err(self.not_allowed(action));
1782 }
1783 if self.core.submits_over_connection() {
1784 // Nothing to apply to here: the session keeps the record, so the
1785 // edit is the same typed action submitted over the connection
1786 // (`one-app-model.md` §4). The view shows its own result until
1787 // the session's reading follows it.
1788 let invoke = Invoke::<R>::apply(action).map_err(ActionError::Encode)?;
1789 self.core.submit_remote(
1790 &ActionAddress::new(self.core.resource_id.as_ref(), invoke.id),
1791 invoke.arguments,
1792 true,
1793 );
1794 return Ok(());
1795 }
1796 // Applying now never skips the person's consent: an action that
1797 // needs confirming goes to the door, which asks first.
1798 if asks(action) && !confirmed {
1799 self.apply(action);
1800 return Ok(());
1801 }
1802 // Content from outside is nobody's step: it neither continues the
1803 // open one nor ends it.
1804 let step = tilo_actions::Action::change_kind(action).is_step();
1805 // Only an action right after this dispatcher's last one, with nothing
1806 // in between, can continue it.
1807 let serial = self.core.serial.get();
1808 let continues = step
1809 && self
1810 .last
1811 .borrow()
1812 .as_ref()
1813 .is_some_and(|(at, previous)| *at == serial && R::coalesces(previous, action));
1814 let context = self.core.step_context(context, step, continues);
1815 let fallback = if !ui_core::local_document_action::input_is_active()
1816 || ui_core::local_document_action::has_invocation()
1817 {
1818 None
1819 } else {
1820 Some(Invoke::<R>::apply(action).map_err(ActionError::Encode)?)
1821 };
1822 let _capture = fallback
1823 .as_ref()
1824 .and_then(|invoke| self.capture_invocation(invoke));
1825 (self.submit_now)(&mut self.core.host.borrow_mut(), edits, action, context)?;
1826 if !step {
1827 return Ok(());
1828 }
1829 let serial = self.core.stepped(continues, R::continuable(action));
1830 *self.last.borrow_mut() = Some((serial, action.clone()));
1831 Ok(())
1832 }
1833
1834 /// The dispatcher of another record than this resource's — the
1835 /// person's own record, a settings section — published at `id`: its
1836 /// queued invocations go through the same door as this resource's, and
1837 /// what it applies now `apply` applies to that record. The workspace's roles
1838 /// do not reach it; only its person changes it.
1839 pub(crate) fn for_record<C: Resource>(
1840 &self,
1841 id: &str,
1842 read_only: bool,
1843 apply: impl Fn(&C::Action) -> Result<(), ActionError> + 'static,
1844 ) -> Dispatcher<C> {
1845 Dispatcher {
1846 core: self.core.for_record(id, (C::KIND, C::LABEL), read_only),
1847 group: Rc::default(),
1848 permission_of: Rc::new(|_: &C::Action| ActionNeed::of(tilo_actions::ActionKind::Read)),
1849 submit_now: Rc::new(move |_, _, action: &C::Action, _| apply(action)),
1850 group_now: None,
1851 last: Rc::default(),
1852 }
1853 }
1854
1855 /// The dispatcher of a record the app keeps beside its own in the workspace,
1856 /// published at `id` (the parts of a chain): its queued invocations are
1857 /// addressed to `id`, `apply` applies one now, and what an action needs
1858 /// is its own declared need, checked against the participant's roles in
1859 /// the workspace as this resource's actions are.
1860 pub(crate) fn for_workspace_record<C: Resource>(
1861 &self,
1862 id: &str,
1863 read_only: bool,
1864 apply: impl Fn(&C::Action) -> Result<(), ActionError> + 'static,
1865 ) -> Dispatcher<C> {
1866 let mut core = self.core.for_record(id, (C::KIND, C::LABEL), read_only);
1867 core.permissions = Rc::clone(&self.core.permissions);
1868 Dispatcher {
1869 core,
1870 group: Rc::default(),
1871 permission_of: Rc::new(tilo_resource::need_of::<C>),
1872 submit_now: Rc::new(move |_, _, action: &C::Action, _| apply(action)),
1873 group_now: None,
1874 last: Rc::default(),
1875 }
1876 }
1877
1878 /// The dispatcher of a resource nested in this one at `id` — a note's
1879 /// rich-text body, a draft's SQL — whose every edit is this resource's
1880 /// action `wrap(edit)`.
1881 ///
1882 /// The child is edited like a root: its named actions,
1883 /// [`apply`](Self::apply), [`group`](Self::group) and
1884 /// [`apply_now`](Self::apply_now) take `C`'s own actions, coalesce into
1885 /// undo steps by `C`'s [`Resource::coalesces`], and history names them
1886 /// after `C`'s labels. Underneath, each applies as this resource's
1887 /// action: the same permission check, history, undo and replay. Queued
1888 /// invocations are addressed to the child resource `id`, so this
1889 /// resource publishes the child at the same id with the same `wrap`
1890 /// ([`tilo_resource::publish_child`] in [`Resource::children`]), which
1891 /// is also what lets the palette, agents and scripts reach it.
1892 #[must_use]
1893 pub fn child<C: Resource>(
1894 &self,
1895 id: &str,
1896 wrap: impl Fn(C::Action) -> R::Action + 'static,
1897 ) -> Dispatcher<C> {
1898 let parent = Rc::clone(&self.submit_now);
1899 let parent_permission = Rc::clone(&self.permission_of);
1900 let wrap = Rc::new(wrap);
1901 let wrap_for_permission = Rc::clone(&wrap);
1902 Dispatcher {
1903 core: self.core.child(id, (C::KIND, C::LABEL)),
1904 group: Rc::default(),
1905 permission_of: Rc::new(move |action: &C::Action| {
1906 parent_permission(&wrap_for_permission(action.clone()))
1907 }),
1908 // History names the child and its edit; what applies is the
1909 // parent's action carrying it.
1910 group_now: None,
1911 submit_now: Rc::new(move |host, edits, action: &C::Action, context| {
1912 parent(host, edits, &wrap(action.clone()), context)
1913 }),
1914 last: Rc::default(),
1915 }
1916 }
1917
1918 fn capture_nonlocal(&self) -> Option<ui_core::local_document_action::Scope> {
1919 (self.core.custody.is_some() || super::effect::acting_for().is_some())
1920 .then(ui_core::local_document_action::suspend)
1921 }
1922
1923 fn capture_invocation(
1924 &self,
1925 invoke: &Invoke<R>,
1926 ) -> Option<ui_core::local_document_action::InvocationScope> {
1927 if !ui_core::local_document_action::input_is_active()
1928 || self.core.custody.is_some()
1929 || super::effect::acting_for().is_some()
1930 || ui_core::local_document_action::has_invocation()
1931 {
1932 return None;
1933 }
1934 Some(ui_core::local_document_action::with_invocation(
1935 ui_core::local_document_action::PublishedInvocation {
1936 resource: self.core.resource_id.to_string(),
1937 action: invoke.id.to_owned(),
1938 arguments: invoke.arguments.clone(),
1939 },
1940 ))
1941 }
1942
1943 /// End the gesture the last immediate actions belonged to: the pointer
1944 /// was let go of a slider or a drag handle. The next action starts a
1945 /// step of its own even when it would continue the last one
1946 /// ([`Resource::coalesces`]), so two drags of one slider are two undo
1947 /// steps while every value one drag passes through is one.
1948 pub fn settle(&self) {
1949 self.last.borrow_mut().take();
1950 }
1951
1952 pub(crate) fn name_resource(&self, label: &str) {
1953 self.core.resource_label.set(label.to_owned());
1954 }
1955
1956 /// The id of the app whose resource this is, which presence keys name
1957 /// ([`ResourceSelection::presence`](super::ResourceSelection::presence)).
1958 pub fn app_id(&self) -> &'static str {
1959 self.core.app_id
1960 }
1961}
1962
1963/// Whether the participant `core` edits for may take the action `id` the
1964/// resource's `declarations` may declare: what its declaration needs; a
1965/// group of edits what writing does; anything else what reading does.
1966fn may_declared(
1967 core: &DispatchCore,
1968 declarations: &[tilo_resource::Declaration],
1969 id: &str,
1970 group: bool,
1971) -> bool {
1972 match declarations.iter().find(|decl| decl.id == id) {
1973 Some(decl) => core.may(&ActionNeed {
1974 kind: decl.kind,
1975 field: decl.field.map(str::to_owned),
1976 privileged: false,
1977 }),
1978 None if group => !core.read_only.get(),
1979 None => core.may(&ActionNeed::of(ActionKind::Read)),
1980 }
1981}
1982
1983/// An edit or effect the door refused ([`Dispatcher::refused`]), or one the
1984/// workspace refused after this device applied it.
1985#[derive(Clone, Debug, PartialEq, Eq)]
1986pub struct Refused {
1987 /// The id of the refused action or effect: for one the workspace refused,
1988 /// the verb's key or the edited field's path.
1989 pub action: String,
1990 /// The item it acted on, when the workspace refused it and it named one.
1991 pub item: Option<String>,
1992 /// Why, for a person.
1993 pub reason: String,
1994 /// The workspace refused it: it was applied here, never reached anyone
1995 /// else, and was withdrawn again. Submitting it again is a new action.
1996 pub by_workspace: bool,
1997}
1998
1999/// A submission the door applied ([`Dispatcher::landed`]).
2000#[derive(Clone, Debug, PartialEq)]
2001pub struct Landed {
2002 /// The id of the applied action.
2003 pub action: String,
2004 /// What it answered, if anything.
2005 pub answer: Option<serde_json::Value>,
2006 /// The resources it created (`<kind>/<field>/<id>` for an item).
2007 pub created: Vec<String>,
2008}
2009
2010impl Landed {
2011 fn of(action: &str, output: &tilo_actions::ActionOutput) -> Self {
2012 Self {
2013 action: action.to_owned(),
2014 answer: output.value.clone(),
2015 created: output.resources.clone(),
2016 }
2017 }
2018
2019 /// The ids the action minted, in the order it minted them: the item a
2020 /// "new" verb created first.
2021 #[must_use]
2022 pub fn minted(&self) -> Vec<tilo_resource::Id> {
2023 self.created
2024 .iter()
2025 .filter_map(|resource| resource.rsplit('/').next())
2026 .map(tilo_resource::Id::from_raw)
2027 .collect()
2028 }
2029}
2030
2031/// Whether `action` needs the person's confirmation before it applies, by
2032/// the one rule every door asks (`tilo_actions::asks_first`): its risk asks
2033/// for it and Undo cannot take it back. A removal that only changes the
2034/// document runs at once; Undo is how the person takes it back. One that
2035/// loses something outside the document (`irreversible`) still asks.
2036fn asks<A: tilo_actions::Action>(action: &A) -> bool {
2037 tilo_actions::asks_first(
2038 tilo_actions::Action::risk(action),
2039 tilo_actions::Action::action_kind(action),
2040 tilo_actions::Action::change_kind(action),
2041 tilo_actions::Action::irreversible(action),
2042 )
2043}
2044
2045/// The submissions each effect queued that have not landed yet, and the
2046/// effects waiting for theirs.
2047#[derive(Default)]
2048struct Waits(std::collections::HashMap<tilo_actions::InvocationId, Wait>);
2049
2050#[derive(Default)]
2051struct Wait {
2052 /// Queued, not yet answered by the door.
2053 outstanding: usize,
2054 /// The last that landed, or the first refusal.
2055 result: Option<Result<Landed, String>>,
2056 waiter: Option<std::sync::mpsc::Sender<Result<Landed, String>>>,
2057}
2058
2059impl Waits {
2060 fn submitted(&mut self, invocation: tilo_actions::InvocationId) {
2061 let wait = self.0.entry(invocation).or_default();
2062 wait.outstanding = wait.outstanding.saturating_add(1);
2063 }
2064
2065 fn landed(&mut self, invocation: tilo_actions::InvocationId, landed: Result<Landed, String>) {
2066 let Some(wait) = self.0.get_mut(&invocation) else {
2067 return;
2068 };
2069 wait.outstanding = wait.outstanding.saturating_sub(1);
2070 // The first refusal stands: the effect failed with it.
2071 if !matches!(wait.result, Some(Err(_))) {
2072 wait.result = Some(landed);
2073 }
2074 self.settle(invocation);
2075 }
2076
2077 fn wait(
2078 &mut self,
2079 invocation: tilo_actions::InvocationId,
2080 ) -> Option<std::sync::mpsc::Receiver<Result<Landed, String>>> {
2081 let wait = self.0.get_mut(&invocation)?;
2082 let (sender, receiver) = std::sync::mpsc::channel();
2083 wait.waiter = Some(sender);
2084 self.settle(invocation);
2085 Some(receiver)
2086 }
2087
2088 /// Answer the waiting effect once nothing it submitted is outstanding;
2089 /// forget an effect nobody waits on then.
2090 fn settle(&mut self, invocation: tilo_actions::InvocationId) {
2091 let Some(wait) = self.0.get_mut(&invocation) else {
2092 return;
2093 };
2094 if wait.outstanding > 0 {
2095 return;
2096 }
2097 if let Some(waiter) = wait.waiter.take()
2098 && let Some(result) = wait.result.take()
2099 {
2100 let _ = waiter.send(result);
2101 }
2102 self.0.remove(&invocation);
2103 }
2104}
2105
2106/// An open [`Dispatcher::begin_group`]: every edit submitted until it drops
2107/// becomes one action.
2108pub struct DispatchGroup<R: Resource> {
2109 dispatcher: Dispatcher<R>,
2110}
2111
2112impl<R: Resource> Drop for DispatchGroup<R> {
2113 fn drop(&mut self) {
2114 self.dispatcher.end_group();
2115 }
2116}