Skip to main content

tilo_app/framework/
controls.rs

1//! Binding any toolkit control to a resource action.
2//!
3//! [`ViewCx`](super::ViewCx) has shorthands for the common controls
4//! (`input`, `checkbox`, `button`). Every other action-bound toolkit control —
5//! a slider, a number field, a dropdown, a toggle, a button a virtual list
6//! builds for a row long after the view was built — goes through the same two
7//! pieces: an [`ActionControl`] (the control accepts an action binding) and
8//! [`Actions`] (the resource's published actions, kept by the view).
9
10use std::cell::RefCell;
11use std::marker::PhantomData;
12use std::rc::Rc;
13
14use std::cell::Cell;
15
16use tilo_actions::ActionBinding;
17use tilo_resource::{ActionKey, Binds, Edit, Effect, Invoke, Path, Resource, TryMapped};
18use ui_core::widget::{
19    Button, Checkbox, Dropdown, EditableLabel, EmptyState, NumberInput, Slider, TextInput, Toggle,
20};
21
22/// A toolkit control whose committed value `I` can invoke an action.
23pub trait ActionControl<I>: Sized {
24    /// Invoke `binding` with the control's committed value.
25    #[must_use]
26    fn bind_action(self, binding: ActionBinding<I>) -> Self;
27
28    /// Present the control disabled: its action is not available.
29    #[must_use]
30    fn unavailable(self) -> Self;
31}
32
33macro_rules! action_control {
34    ($($control:ty => $input:ty, |$disabled:ident| $disable:expr;)*) => {$(
35        impl ActionControl<$input> for $control {
36            fn bind_action(self, binding: ActionBinding<$input>) -> Self {
37                self.on_action(binding)
38            }
39
40            fn unavailable(self) -> Self {
41                let $disabled = self;
42                $disable
43            }
44        }
45    )*};
46}
47
48action_control! {
49    TextInput => String, |control| control.disabled(true);
50    Checkbox => bool, |control| control.with_enabled(false);
51    Toggle => bool, |control| control.with_enabled(false);
52    Slider => f64, |control| control.with_enabled(false);
53    NumberInput => f64, |control| control.with_enabled(false);
54    Dropdown => String, |control| control.with_enabled(false);
55    ui_core::widget::ToggleGroup => usize, |control| control.with_enabled(false);
56    ui_core::widget::PictureChoice => usize, |control| control.with_enabled(false);
57    // Unavailable, a swatch grid is left unbound, which chooses nothing.
58    ui_core::widget::ColorSwatchPicker => usize, |control| control;
59    ui_core::KeyboardMapSelection => String, |control| control.with_enabled(false);
60    // Unavailable, a recorder records nothing it could commit.
61    ui_core::widget::ShortcutRecorder => String, |control| control;
62    Button => (), |control| control.with_enabled(false);
63    // Unavailable, a chip is left unbound, which makes it inert.
64    ui_core::widget::Chip => (), |control| control;
65    // A row that does something when chosen (a service to link).
66    ui_core::widget::ListRow => (), |control| control.with_enabled(false);
67    // Unavailable, a card is left unbound, which makes it inert.
68    ui_core::widget::MediaCard => (), |control| control;
69    EmptyState => (), |control| control.with_enabled(false);
70    // Unavailable, an inline rename stays a label that is never committed.
71    EditableLabel => String, |control| control;
72}
73
74thread_local! {
75    /// Whether a control bound to something the resource does not publish
76    /// is a failure rather than a disabled control: in tests and checks.
77    pub(super) static STRICT: Cell<bool> = const { Cell::new(false) };
78    /// Such controls, while a surface report collects them.
79    pub(super) static UNBOUND: std::cell::RefCell<Option<Vec<String>>> =
80        const { std::cell::RefCell::new(None) };
81    /// The surfaces of the other records the views open (the person's own),
82    /// while a surface report collects them.
83    pub(super) static OPENED: std::cell::RefCell<Option<Vec<tilo_resource::Surface>>> =
84        const { std::cell::RefCell::new(None) };
85}
86
87/// A view opened a settings section of `P`, which lives in the person's own
88/// scope: a surface report lists its surface too, marked as the person's.
89pub(crate) fn opened_section<P: Resource>() {
90    record_opened::<P>(true);
91}
92
93/// A view or the window opened a record of `P` beside the app's own: a
94/// surface report lists its surface too, so every member decides a change to
95/// it against it as against the app's own record. Whether it is the
96/// person's — kept in their store rather than in a workspace's — is the
97/// app's declaration's to say
98/// ([`ResourceApp::PERSON_RECORDS`](super::ResourceApp::PERSON_RECORDS)), not
99/// the open's.
100pub(crate) fn opened_record<P: Resource>() {
101    record_opened::<P>(false);
102}
103
104fn record_opened<P: Resource>(person: bool) {
105    OPENED.with(|opened| {
106        if let Some(opened) = opened.borrow_mut().as_mut()
107            && !opened.iter().any(|surface| surface.kind == P::KIND)
108        {
109            opened.push(tilo_resource::Surface {
110                person,
111                ..P::surface()
112            });
113        }
114    });
115}
116
117/// A control could not be bound to `what` because of `why`.
118///
119/// # Panics
120/// Panics where bindings are strict (`testing::strict_bindings`).
121pub(crate) fn unbound<R: Resource>(what: &str, why: &str) {
122    unbound_of(R::KIND, what, why);
123}
124
125/// [`unbound`] for a resource of kind `kind`.
126///
127/// # Panics
128/// Panics where bindings are strict (`testing::strict_bindings`).
129pub(crate) fn unbound_of(kind: &str, what: &str, why: &str) {
130    let collected = UNBOUND.with(|unbound| {
131        unbound.borrow_mut().as_mut().map(|unbound| {
132            unbound.push(format!("a control of `{kind}` is bound to {what}: {why}"));
133        })
134    });
135    if collected.is_some() {
136        return;
137    }
138    assert!(
139        !STRICT.with(Cell::get),
140        "a control of `{kind}` is bound to {what}, which it cannot be: {why}"
141    );
142    tracing::warn!(kind, binding = what, reason = why, "control not bound");
143}
144
145/// What a control's committed value `I` invokes its action with.
146type Arguments<I> =
147    Box<dyn Fn(I) -> Result<serde_json::Map<String, serde_json::Value>, String> + Send + Sync>;
148
149/// The published actions of one resource, whatever its type: every binding
150/// is made here, so a resource type adds only the thin typed entry points of
151/// [`Actions`].
152#[derive(Clone)]
153pub(crate) struct ActionTable {
154    /// The app whose resource this is, for the keys of its actions.
155    app: &'static str,
156    kind: &'static str,
157    resource: Rc<RefCell<Published>>,
158    /// Another record than the app's own, published at this id, whose
159    /// actions a live row may offer; `None` for the app's own resource.
160    record: Option<Rc<str>>,
161    /// The provider keeping that record, which runs its actions
162    /// ([`Records::kept_by`](super::Records::kept_by)).
163    kept_by: Option<&'static str>,
164}
165
166/// What bound controls look up: a publication shared with the root that
167/// built it, and the few actions the views bound over it since, which win
168/// over the shared ones of the same id.
169struct Published {
170    base: Rc<tilo_actions::ResourceSpec>,
171    overlay: Vec<tilo_actions::ActionSpec>,
172}
173
174impl Published {
175    /// The published spec of the action `key` names, or why this publication
176    /// offers none: one of another kind is another record's, and one of
177    /// another app than `key` names is reached through the app publishing it.
178    fn find(&self, key: &ActionKey) -> Result<&tilo_actions::ActionSpec, String> {
179        if self.base.kind != key.kind {
180            return Err(format!("the resource is a `{}`", self.base.kind));
181        }
182        if let Some(app) = self.base.app().filter(|app| *app != key.app) {
183            return Err(format!("it publishes `{app}`'s actions"));
184        }
185        self.overlay
186            .iter()
187            .find(|spec| spec.id == key.action)
188            .or_else(|| self.base.actions.iter().find(|spec| spec.id == key.action))
189            .ok_or_else(|| "the resource does not publish it".to_owned())
190    }
191}
192
193impl ActionTable {
194    fn new(
195        app: &'static str,
196        kind: &'static str,
197        published: Rc<tilo_actions::ResourceSpec>,
198    ) -> Self {
199        Self {
200            app,
201            kind,
202            resource: Rc::new(RefCell::new(Published {
203                base: published,
204                overlay: Vec::new(),
205            })),
206            record: None,
207            kept_by: None,
208        }
209    }
210
211    /// Route the actions to the record `record`, where it may be changed,
212    /// kept by the provider `kept_by` when another provider runs them.
213    fn route(&mut self, record: &str, editable: bool, kept_by: Option<&'static str>) {
214        self.record = editable.then(|| Rc::from(record));
215        self.kept_by = kept_by;
216    }
217
218    /// Follow the canonical publication of this same hosted resource.
219    pub(crate) fn update(&self, published: tilo_actions::ResourceSpec) {
220        self.update_shared(Rc::new(published), Vec::new());
221    }
222
223    /// Follow `base`, a publication shared with whoever built it, with
224    /// `overlay`'s actions in place of its own of the same id: nothing of
225    /// `base` is copied, and the last one is only let go.
226    pub(crate) fn update_shared(
227        &self,
228        base: Rc<tilo_actions::ResourceSpec>,
229        overlay: Vec<tilo_actions::ActionSpec>,
230    ) {
231        let mut current = self.resource.borrow_mut();
232        // A handle belongs to one root; a replacement gets its own handle.
233        if current.base.id == base.id && current.base.kind == base.kind {
234            *current = Published { base, overlay };
235        } else {
236            tracing::error!("action publication changed resource identity");
237        }
238    }
239
240    fn key(&self, id: &str) -> ActionKey {
241        ActionKey::new(self.app, self.kind, id)
242    }
243
244    /// The published spec of the action `key` names, read as a control opened
245    /// in the app `opened_in` names it: a window drawing another app's record
246    /// binds it under the app id it publishes that record by, which
247    /// `fallback` supplies.
248    fn spec_of(&self, key: &ActionKey, fallback: &str) -> Result<tilo_actions::ActionSpec, String> {
249        let published = self.resource.borrow();
250        match published.find(key) {
251            Err(why) if key.app == self.app && key.kind == self.kind => published
252                .find(&ActionKey::new(
253                    fallback,
254                    key.kind.as_str(),
255                    key.action.as_str(),
256                ))
257                .cloned()
258                .map_err(|_| why),
259            found => found.cloned(),
260        }
261    }
262
263    /// The binding of action `id` for a control with input `I`, or why
264    /// there is none.
265    fn binding<I: 'static>(
266        &self,
267        key: ActionKey,
268        arguments: Arguments<I>,
269    ) -> Result<ActionBinding<I>, (ActionKey, String)> {
270        self.binding_in(key, self.app, arguments)
271    }
272
273    /// [`Self::binding`], `key`'s app id read in `opened_in`'s place when the
274    /// table was opened for another ([`Self::control_binding_in`]).
275    fn binding_in<I: 'static>(
276        &self,
277        key: ActionKey,
278        opened_in: &str,
279        arguments: Arguments<I>,
280    ) -> Result<ActionBinding<I>, (ActionKey, String)> {
281        let spec = self
282            .spec_of(&key, opened_in)
283            .map_err(|why| (key.clone(), why))?;
284        let published = self.exporting(spec.clone());
285        ActionBinding::fallible(&published, &spec, arguments)
286            .map_err(|error| (key, error.to_string()))
287    }
288
289    /// The binding of one fully specified invocation of action `id`, or why
290    /// there is none: `key`'s app id read in `opened_in`'s place as
291    /// [`Self::binding_in`] reads it, so a button and a typed control bind
292    /// the same published action.
293    fn invocation(
294        &self,
295        key: ActionKey,
296        opened_in: &str,
297        refusal: Option<&str>,
298        arguments: serde_json::Map<String, serde_json::Value>,
299    ) -> Result<ActionBinding<()>, (ActionKey, String)> {
300        if let Some(refusal) = refusal {
301            return Err((key, refusal.to_owned()));
302        }
303        let spec = self
304            .spec_of(&key, opened_in)
305            .map_err(|why| (key.clone(), why))?;
306        let published = self.exporting(spec.clone());
307        ActionBinding::static_json(&published, &spec, arguments)
308            .map_err(|error| (key, error.to_string()))
309    }
310
311    /// [`Self::binding`] for a control being built, where an action the
312    /// resource does not publish fails tests and `tilo-app check`: `key`'s
313    /// app id read in `opened_in`'s place where a control built from a typed
314    /// reference names the app by
315    /// [`Resource::APP_ID`](tilo_resource::Resource::APP_ID) and this table
316    /// was opened for another app: a window drawing another app's record
317    /// binds it under the id that record is published by.
318    fn control_binding_in<I: 'static>(
319        &self,
320        key: ActionKey,
321        opened_in: &str,
322        arguments: Arguments<I>,
323    ) -> Option<ActionBinding<I>> {
324        self.binding_in(key, opened_in, arguments)
325            .map_err(|(key, why)| unbound_of(self.kind, &format!("`{key}`"), &why))
326            .ok()
327    }
328
329    /// The binding of the field edit described as `what` for a control with
330    /// input `I`, each committed input becoming the edit `edit` makes;
331    /// `None`, and a failure in tests, when the resource has no field edits
332    /// (`edits`) or the field is read-only.
333    fn edit_binding<I: 'static, T: Resource>(
334        &self,
335        what: &str,
336        edits: bool,
337        readonly: bool,
338        edit: Box<dyn Fn(I) -> Result<serde_json::Value, String> + Send + Sync>,
339    ) -> Option<ActionBinding<I>> {
340        if !edits {
341            unbound_of(self.kind, what, "it has no field edits");
342            return None;
343        }
344        if readonly {
345            unbound_of(
346                self.kind,
347                what,
348                "the field is read-only; only verbs change it",
349            );
350            return None;
351        }
352        // A field edit is the record's own action, under whatever app the
353        // control that binds it names it by
354        // ([`Self::control_binding_in`]).
355        let spec = match self.spec_of(&self.key(tilo_resource::FIELD_EDIT), T::APP_ID) {
356            Ok(spec) => spec,
357            Err(why) => {
358                unbound_of(self.kind, what, &why);
359                return None;
360            }
361        };
362        let published = self.exporting(spec.clone());
363        let arguments: Arguments<I> = Box::new(move |input| {
364            let edit = edit(input)?;
365            Ok(serde_json::Map::from_iter([(
366                tilo_resource::EDIT_PARAM.to_owned(),
367                edit,
368            )]))
369        });
370        ActionBinding::fallible(&published, &spec, arguments)
371            .map_err(|error| unbound_of(self.kind, what, &error.to_string()))
372            .ok()
373    }
374
375    /// The binding of setting the field at `segments` (described as `what`)
376    /// for a control with input `I`, each committed input becoming the value
377    /// `value` makes of it.
378    fn set_binding<I: 'static, T: Resource>(
379        &self,
380        what: &str,
381        edits: bool,
382        readonly: bool,
383        segments: &[tilo_resource::Segment],
384        value: Box<dyn Fn(I) -> Result<serde_json::Value, String> + Send + Sync>,
385    ) -> Option<ActionBinding<I>> {
386        let path = serde_json::to_value(segments).unwrap_or_default();
387        self.edit_binding::<I, T>(
388            what,
389            edits,
390            readonly,
391            Box::new(move |input| {
392                let value = value(input)?;
393                let mut edit = serde_json::to_value(tilo_resource::EditOp::Set { value })
394                    .map_err(|error| error.to_string())?;
395                if let serde_json::Value::Object(edit) = &mut edit {
396                    edit.insert("path".to_owned(), path.clone());
397                }
398                Ok(edit)
399            }),
400        )
401    }
402
403    /// The resource as a binding names it, exporting `spec` alone: one of
404    /// its published actions, or an effect, which the handler a view or
405    /// surface registers publishes beside them.
406    fn exporting(&self, spec: tilo_actions::ActionSpec) -> tilo_actions::ResourceSpec {
407        let published = &self.resource.borrow().base;
408        tilo_actions::ResourceSpec {
409            id: published.id.clone(),
410            semantic_identity: published.semantic_identity.clone(),
411            app_id: published.app_id.clone(),
412            kind: published.kind.clone(),
413            label: published.label.clone(),
414            presentation: None,
415            children: Vec::new(),
416            actions: vec![spec],
417            views: Vec::new(),
418            readers: published.readers,
419            resource_type: published.resource_type.clone(),
420        }
421    }
422
423    /// The binding of the effect `spec` (effect `id`) for a control with
424    /// input `I`.
425    fn effect_binding<I: 'static>(
426        &self,
427        key: &ActionKey,
428        spec: Option<tilo_actions::ActionSpec>,
429        arguments: Arguments<I>,
430    ) -> Option<ActionBinding<I>> {
431        let (resource, spec) = self.effect_publication(key, spec)?;
432        ActionBinding::fallible(&resource, &spec, arguments)
433            .map_err(|error| unbound_of(&key.kind, &format!("`{key}`"), &error.to_string()))
434            .ok()
435    }
436
437    /// The binding of one fully specified effect, for a button.
438    fn effect_invocation(
439        &self,
440        key: &ActionKey,
441        spec: Option<tilo_actions::ActionSpec>,
442        arguments: serde_json::Map<String, serde_json::Value>,
443    ) -> Option<ActionBinding<()>> {
444        let (resource, spec) = self.effect_publication(key, spec)?;
445        ActionBinding::static_json(&resource, &spec, arguments)
446            .map_err(|error| unbound_of(&key.kind, &format!("`{key}`"), &error.to_string()))
447            .ok()
448    }
449
450    /// The resource and spec an effect control is bound to, or `None` after
451    /// reporting why the view declared no such effect.
452    fn effect_publication(
453        &self,
454        key: &ActionKey,
455        spec: Option<tilo_actions::ActionSpec>,
456    ) -> Option<(tilo_actions::ResourceSpec, tilo_actions::ActionSpec)> {
457        let spec = match spec {
458            Some(spec) if spec.id == key.action => spec,
459            Some(_) | None => {
460                unbound_of(
461                    &key.kind,
462                    &format!("`{key}`"),
463                    "the view declares no such effect",
464                );
465                return None;
466            }
467        };
468        let resource = self.exporting(spec.clone());
469        Some((resource, spec))
470    }
471}
472
473/// The actions a resource publishes, for binding controls that are built
474/// outside the view function: rows a table, list or tree creates on demand.
475/// Cheap to clone; clones share the published descriptor.
476pub struct Actions<R: Resource> {
477    table: ActionTable,
478    marker: PhantomData<fn() -> R>,
479}
480
481impl<R: Resource> Clone for Actions<R> {
482    fn clone(&self) -> Self {
483        Self {
484            table: self.table.clone(),
485            marker: PhantomData,
486        }
487    }
488}
489
490impl<R: Resource> Actions<R> {
491    pub(crate) fn new(app: &'static str, published: Rc<tilo_actions::ResourceSpec>) -> Self {
492        Self {
493            table: ActionTable::new(app, R::KIND, published),
494            marker: PhantomData,
495        }
496    }
497
498    /// These actions as another record's, published at `record`: a live row
499    /// may offer them ([`Self::operation`]) where it may change it.
500    pub(crate) fn routed_to(
501        mut self,
502        record: &str,
503        editable: bool,
504        kept_by: Option<&'static str>,
505    ) -> Self {
506        self.table.route(record, editable, kept_by);
507        self
508    }
509
510    /// `decl` — a bound field edit ([`tilo_resource::ActionDecl::edit`],
511    /// `edit_asking`) or verb of another record — as an operation a live
512    /// row offers ([`LiveRow::operation`](super::LiveRow::operation)),
513    /// carried out on that record as its person (*this* app's notification
514    /// rule on the app's row). `None` for the app's own resource, whose row
515    /// edits are [`Publishes::row_edits`](super::Publishes::row_edits),
516    /// and where the record is read only here.
517    #[must_use]
518    pub fn operation(
519        &self,
520        decl: &tilo_resource::ActionDecl<R>,
521    ) -> Option<tilo_actions::ActionSpec> {
522        let record = self.table.record.as_deref()?;
523        let spec = super::record::route_spec(tilo_resource::declaration_spec(decl), record);
524        Some(match self.table.kept_by {
525            // The provider keeping the record runs the action this one
526            // invokes of it.
527            Some(provider) => spec.run_as(tilo_actions::RunBy {
528                provider: provider.to_owned(),
529                resource: Some(record.to_owned()),
530                invokes: AsRef::<tilo_resource::Declaration>::as_ref(decl)
531                    .invokes
532                    .clone(),
533            }),
534            None => spec,
535        })
536    }
537
538    /// The resource's own actions as published now: what a query view's
539    /// "In this app" group searches, beside the app's effects.
540    #[must_use]
541    pub fn published(&self) -> Vec<tilo_actions::ActionSpec> {
542        let published = self.table.resource.borrow();
543        let mut specs = published.overlay.clone();
544        specs.extend(
545            published
546                .base
547                .actions
548                .iter()
549                .filter(|spec| !published.overlay.iter().any(|over| over.id == spec.id))
550                .cloned(),
551        );
552        specs
553    }
554
555    /// The binding of a button that runs `spec`, one of the resource's
556    /// published actions or one of its effects, with no arguments; `None`
557    /// where it asks for some.
558    #[must_use]
559    pub fn spec_binding(&self, spec: &tilo_actions::ActionSpec) -> Option<ActionBinding<()>> {
560        let resource = self.table.exporting(spec.clone());
561        let spec = resource.actions.first()?.clone();
562        ActionBinding::static_json(&resource, &spec, serde_json::Map::new())
563            .map_err(|error| tracing::warn!(%error, action = %spec.id, "control not bound"))
564            .ok()
565    }
566
567    /// The binding of a switch that runs `spec`, with no arguments, whichever
568    /// way it is flipped: the action itself flips what it controls.
569    #[must_use]
570    pub fn spec_switch_binding(
571        &self,
572        spec: &tilo_actions::ActionSpec,
573    ) -> Option<ActionBinding<bool>> {
574        let resource = self.table.exporting(spec.clone());
575        let spec = resource.actions.first()?.clone();
576        let arguments: Arguments<bool> = Box::new(|_| Ok(serde_json::Map::new()));
577        ActionBinding::fallible(&resource, &spec, arguments)
578            .map_err(|error| tracing::warn!(%error, action = %spec.id, "control not bound"))
579            .ok()
580    }
581
582    /// The id the resource is published at.
583    pub(crate) fn resource_id(&self) -> String {
584        self.table.resource.borrow().base.id.0.clone()
585    }
586
587    /// The table these actions bind from, which follows the publication
588    /// whatever the resource's type.
589    pub(crate) fn table(&self) -> ActionTable {
590        self.table.clone()
591    }
592
593    /// The binding of `action` for a control with input `I`, or `None` while
594    /// the resource does not publish it (an edit bound to view state that
595    /// is not there now).
596    pub fn binding<I: 'static>(&self, action: impl Binds<R, I>) -> Option<ActionBinding<I>> {
597        let key = action.action_key();
598        self.table
599            .binding(key, Box::new(move |input| action.arguments(input)))
600            .ok()
601    }
602
603    /// The binding of one fully specified invocation, for a button, or
604    /// `None` while the resource does not publish it.
605    pub fn invocation(&self, invoke: Invoke<R>) -> Option<ActionBinding<()>> {
606        let refusal = invoke.refusal().map(str::to_owned);
607        self.table
608            .invocation(
609                self.table.key(invoke.id),
610                R::APP_ID,
611                refusal.as_deref(),
612                invoke.arguments,
613            )
614            .ok()
615    }
616
617    /// [`Self::binding`] for a control being built: an action the resource
618    /// does not publish fails tests and `tilo-app check`.
619    pub(crate) fn control_binding<I: 'static>(
620        &self,
621        action: impl Binds<R, I>,
622    ) -> Option<ActionBinding<I>> {
623        let key = action.action_key();
624        self.table.control_binding_in(
625            key,
626            R::APP_ID,
627            Box::new(move |input| action.arguments(input)),
628        )
629    }
630
631    /// [`Self::invocation`] for a control being built.
632    pub(crate) fn control_invocation(&self, invoke: Invoke<R>) -> Option<ActionBinding<()>> {
633        let refusal = invoke.refusal().map(str::to_owned);
634        let key = self.table.key(invoke.id);
635        self.table
636            .invocation(key, R::APP_ID, refusal.as_deref(), invoke.arguments)
637            .map_err(|(key, why)| unbound_of(R::KIND, &format!("`{key}`"), &why))
638            .ok()
639    }
640
641    /// The binding of a button that invokes `R`'s action `id` with the
642    /// arguments `arguments` makes when it is pressed (a form's values).
643    pub(crate) fn deferred_binding(
644        &self,
645        key: ActionKey,
646        arguments: impl Fn() -> Result<serde_json::Map<String, serde_json::Value>, String>
647        + Send
648        + Sync
649        + 'static,
650    ) -> Option<ActionBinding<()>> {
651        self.table
652            .control_binding_in(key, R::APP_ID, Box::new(move |()| arguments()))
653    }
654
655    /// The binding of the field at `path` for a control with input `V`:
656    /// each committed value becomes the field edit `edit(path, value)`.
657    /// `None`, and a failure in tests, when the resource has no field edits
658    /// or the field is `#[model(readonly)]`.
659    pub fn edit_binding<V: 'static>(
660        &self,
661        path: Path<R, V>,
662        edit: impl Fn(&Path<R, V>, V) -> Edit<R> + Send + Sync + 'static,
663    ) -> Option<ActionBinding<V>> {
664        self.edit_binding_with(path, move |path, value| Ok(edit(path, value)))
665    }
666
667    /// [`Self::edit_binding`] for a control whose input `I` is not the
668    /// field's value: a dropdown of "125%" for a scale of 1.25, a slider's
669    /// position on a log scale. `edit` maps each committed input to the
670    /// field edit, or refuses it, which refuses the input like a value the
671    /// field cannot take.
672    pub fn edit_binding_with<V: 'static, I: 'static>(
673        &self,
674        path: Path<R, V>,
675        edit: impl Fn(&Path<R, V>, I) -> Result<Edit<R>, String> + Send + Sync + 'static,
676    ) -> Option<ActionBinding<I>> {
677        let what = format!("the field {path:?}");
678        let readonly = R::shape().is_some_and(|shape| shape.readonly_at(path.segments()));
679        self.table.edit_binding::<I, R>(
680            &what,
681            R::EDITS,
682            readonly,
683            Box::new(move |input| {
684                let edit = edit(&path, input)?;
685                serde_json::to_value(&edit).map_err(|error| error.to_string())
686            }),
687        )
688    }
689
690    /// The binding of the field at `path` for a control with input `I`:
691    /// each committed input sets the field to what `value` makes of it, or is
692    /// refused where `value` refuses it. What a settings row binds; the same
693    /// edit as [`Self::edit_binding_with`] and [`Path::set`].
694    pub fn set_binding<V, I>(
695        &self,
696        path: Path<R, V>,
697        value: impl Fn(I) -> Result<V, String> + Send + Sync + 'static,
698    ) -> Option<ActionBinding<I>>
699    where
700        V: tilo_resource::Settable + 'static,
701        I: 'static,
702    {
703        let what = format!("the field {path:?}");
704        let readonly = R::shape().is_some_and(|shape| shape.readonly_at(path.segments()));
705        let segments = path.into_segments();
706        self.table
707            .set_binding::<I, R>(&what, R::EDITS, readonly, &segments, json_of(value))
708    }
709
710    /// [`Self::set_binding`] of a control whose input is the field's value.
711    pub fn field_binding<V>(&self, path: Path<R, V>) -> Option<ActionBinding<V>>
712    where
713        V: tilo_resource::Settable + 'static,
714    {
715        self.set_binding(path, same::<V>)
716    }
717
718    /// `control`, bound to `action`. An action the resource does not publish
719    /// leaves it disabled, and fails tests and `tilo-app check`.
720    pub fn bind<I: 'static, C: ActionControl<I>>(&self, control: C, action: impl Binds<R, I>) -> C {
721        match self.control_binding(action) {
722            Some(binding) => control.bind_action(binding),
723            None => control.unavailable(),
724        }
725    }
726
727    /// `control` (a button), bound to one fully specified invocation.
728    pub fn bind_invocation<C: ActionControl<()>>(&self, control: C, invoke: Invoke<R>) -> C {
729        match self.control_invocation(invoke) {
730            Some(binding) => control.bind_action(binding),
731            None => control.unavailable(),
732        }
733    }
734
735    /// `button`, bound to submit what `draft` holds through `action`, which
736    /// parses it ([`ActionRef::try_map`](tilo_resource::ActionRef::try_map)):
737    /// disabled while the draft does not parse, and the draft cleared once
738    /// submitted. The row that creates an item from a title typed beside
739    /// its button; [`Self::submit_draft`] makes Enter in the field do the
740    /// same.
741    pub fn bind_draft<A, T, F>(
742        &self,
743        button: Button,
744        draft: &ui_core::LocalState<String>,
745        action: TryMapped<R, A, F>,
746    ) -> Button
747    where
748        A: serde::Serialize + 'static,
749        T: Into<A>,
750        F: Fn(String) -> Option<T> + Send + Sync + 'static,
751    {
752        self.bind_draft_clearing(button, draft, &[], action)
753    }
754
755    /// [`Self::bind_draft`] for a draft with further fields `parse` reads
756    /// beside it (a person's role beside their name): they are cleared with
757    /// it once submitted.
758    pub fn bind_draft_clearing<A, T, F>(
759        &self,
760        button: Button,
761        draft: &ui_core::LocalState<String>,
762        others: &[&ui_core::LocalState<String>],
763        action: TryMapped<R, A, F>,
764    ) -> Button
765    where
766        A: serde::Serialize + 'static,
767        T: Into<A>,
768        F: Fn(String) -> Option<T> + Send + Sync + 'static,
769    {
770        let (key, parse) = draft_arguments(action);
771        let mut drafts = vec![draft.clone()];
772        drafts.extend(others.iter().map(|other| (*other).clone()));
773        self.bind_parsed_draft(button, key, parse, drafts)
774    }
775
776    /// The type-erased half of [`Self::bind_draft_clearing`]: one copy for
777    /// every action a draft is bound to.
778    fn bind_parsed_draft(
779        &self,
780        button: Button,
781        key: ActionKey,
782        parse: DraftArguments,
783        drafts: Vec<ui_core::LocalState<String>>,
784    ) -> Button {
785        let Some(draft) = drafts.first().cloned() else {
786            return button.unavailable();
787        };
788        let ready = {
789            let parse = std::sync::Arc::clone(&parse);
790            move |text: &String| parse(text.clone()).is_ok()
791        };
792        let submitted = draft.clone();
793        let submit = move |()| {
794            let arguments = parse(submitted.get())?;
795            for draft in &drafts {
796                draft.set(String::new());
797            }
798            Ok(arguments)
799        };
800        match self
801            .table
802            .control_binding_in(key, R::APP_ID, Box::new(submit))
803        {
804            Some(binding) => button.bind_action(binding).enabled_while(&draft, ready),
805            None => button.unavailable(),
806        }
807    }
808
809    /// `field` (the text field `draft` follows), with Enter submitting what
810    /// it holds through `action`, as [`Self::bind_draft`]'s button does:
811    /// Enter on text that does not parse does nothing, and a submission
812    /// clears the draft.
813    pub fn submit_draft<A, T, F>(
814        &self,
815        field: TextInput,
816        draft: &ui_core::LocalState<String>,
817        action: TryMapped<R, A, F>,
818    ) -> TextInput
819    where
820        A: serde::Serialize + 'static,
821        T: Into<A>,
822        F: Fn(String) -> Option<T> + Send + Sync + 'static,
823    {
824        let (key, parse) = draft_arguments(action);
825        self.submit_parsed_draft(field, key, parse, draft.clone())
826    }
827
828    /// The type-erased half of [`Self::submit_draft`].
829    fn submit_parsed_draft(
830        &self,
831        field: TextInput,
832        key: ActionKey,
833        parse: DraftArguments,
834        draft: ui_core::LocalState<String>,
835    ) -> TextInput {
836        let submit = move |text: String| {
837            let arguments = parse(text)?;
838            draft.set(String::new());
839            Ok(arguments)
840        };
841        match self
842            .table
843            .control_binding_in(key, R::APP_ID, Box::new(submit))
844        {
845            Some(binding) => field.on_submit_action(binding),
846            None => field,
847        }
848    }
849
850    /// The binding of effect `effect` for a control with input `I`
851    /// (`Backups::RESTORE` for a dropdown of snapshots).
852    pub fn effect_binding<E: Effect, I: 'static>(
853        &self,
854        effect: impl Binds<E, I>,
855    ) -> Option<ActionBinding<I>> {
856        let key = effect_key::<E, I>(&effect);
857        self.table.effect_binding(
858            &key,
859            E::spec(&key.action),
860            Box::new(move |input| effect.arguments(input)),
861        )
862    }
863
864    /// The binding of one fully specified effect, for a button.
865    pub fn effect<E: Effect>(&self, effect: &E) -> Option<ActionBinding<()>> {
866        self.table
867            .effect_invocation(&effect.key(), E::spec(effect.id()), effect.arguments())
868    }
869
870    pub fn bind_effect<E: Effect, I: 'static, C: ActionControl<I>>(
871        &self,
872        control: C,
873        effect: impl Binds<E, I>,
874    ) -> C {
875        match self.effect_binding(effect) {
876            Some(binding) => control.bind_action(binding),
877            None => control.unavailable(),
878        }
879    }
880
881    /// `control` (a button), bound to one fully specified effect.
882    pub fn bind_effect_invocation<E: Effect, C: ActionControl<()>>(
883        &self,
884        control: C,
885        effect: &E,
886    ) -> C {
887        match self.effect(effect) {
888            Some(binding) => control.bind_action(binding),
889            None => control.unavailable(),
890        }
891    }
892}
893
894/// What a draft's text makes for the action it is bound to, or why it makes
895/// nothing: shared by the control's readiness and its submission.
896type DraftArguments = std::sync::Arc<
897    dyn Fn(String) -> Result<serde_json::Map<String, serde_json::Value>, String> + Send + Sync,
898>;
899
900/// `action`'s key and its parse of a draft's text, erased so the controls
901/// bound to drafts share one copy of their code.
902fn draft_arguments<R, A, T, F>(action: TryMapped<R, A, F>) -> (ActionKey, DraftArguments)
903where
904    R: Resource,
905    A: serde::Serialize + 'static,
906    T: Into<A>,
907    F: Fn(String) -> Option<T> + Send + Sync + 'static,
908{
909    let key = Binds::<R, String>::action_key(&action);
910    (key, std::sync::Arc::new(move |text| action.arguments(text)))
911}
912
913/// The key the effect a control is bound to is offered under: the one its
914/// reference names, and for a vocabulary's [`Effect`] alone, the one
915/// [`Effect::key`] reads.
916fn effect_key<E: Effect, I>(effect: &impl Binds<E, I>) -> ActionKey {
917    ActionKey::new(E::APP_ID, E::OPERATION, effect.action_id())
918}
919
920fn json_of<V: tilo_resource::Model, I>(
921    value: impl Fn(I) -> Result<V, String> + Send + Sync + 'static,
922) -> Box<dyn Fn(I) -> Result<serde_json::Value, String> + Send + Sync> {
923    Box::new(move |input| value(input).map(|value| value.to_json()))
924}
925
926#[allow(
927    clippy::unnecessary_wraps,
928    reason = "the shape every value conversion has"
929)]
930fn same<V>(value: V) -> Result<V, String> {
931    Ok(value)
932}