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};
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::KeyboardMapSelection => String, |control| control.with_enabled(false);
56    Button => (), |control| control.with_enabled(false);
57    EmptyState => (), |control| control.with_enabled(false);
58    // Unavailable, an inline rename stays a label that is never committed.
59    EditableLabel => String, |control| control;
60}
61
62thread_local! {
63    /// Whether a control bound to something the resource does not publish
64    /// is a failure rather than a disabled control: in tests and checks.
65    pub(super) static STRICT: Cell<bool> = const { Cell::new(false) };
66    /// Such controls, while a surface report collects them.
67    pub(super) static UNBOUND: std::cell::RefCell<Option<Vec<String>>> =
68        const { std::cell::RefCell::new(None) };
69    /// The surfaces of the other records the views open (the person's own),
70    /// while a surface report collects them.
71    pub(super) static OPENED: std::cell::RefCell<Option<Vec<tilo_resource::Surface>>> =
72        const { std::cell::RefCell::new(None) };
73}
74
75/// A view opened a record of `P` beside the app's own, in the person's
76/// room: a surface report lists its surface too, marked as the person's.
77pub(crate) fn opened<P: Resource>() {
78    OPENED.with(|opened| {
79        if let Some(opened) = opened.borrow_mut().as_mut()
80            && !opened.iter().any(|surface| surface.kind == P::KIND)
81        {
82            opened.push(tilo_resource::Surface {
83                person: true,
84                ..P::surface()
85            });
86        }
87    });
88}
89
90/// A control could not be bound to `what` because of `why`.
91///
92/// # Panics
93/// Panics where bindings are strict (`testing::strict_bindings`).
94pub(crate) fn unbound<R: Resource>(what: &str, why: &str) {
95    unbound_of(R::KIND, what, why);
96}
97
98/// [`unbound`] for a resource of kind `kind`.
99///
100/// # Panics
101/// Panics where bindings are strict (`testing::strict_bindings`).
102pub(crate) fn unbound_of(kind: &str, what: &str, why: &str) {
103    let collected = UNBOUND.with(|unbound| {
104        unbound.borrow_mut().as_mut().map(|unbound| {
105            unbound.push(format!("a control of `{kind}` is bound to {what}: {why}"));
106        })
107    });
108    if collected.is_some() {
109        return;
110    }
111    assert!(
112        !STRICT.with(Cell::get),
113        "a control of `{kind}` is bound to {what}, which it cannot be: {why}"
114    );
115    tracing::warn!(kind, binding = what, reason = why, "control not bound");
116}
117
118/// What a control's committed value `I` invokes its action with.
119type Arguments<I> =
120    Box<dyn Fn(I) -> Result<serde_json::Map<String, serde_json::Value>, String> + Send + Sync>;
121
122/// The published actions of one resource, whatever its type: every binding
123/// is made here, so a resource type adds only the thin typed entry points of
124/// [`Actions`].
125#[derive(Clone)]
126pub(crate) struct ActionTable {
127    /// The app whose resource this is, for the keys of its actions.
128    app: &'static str,
129    /// The resource's kind.
130    kind: &'static str,
131    resource: Rc<RefCell<Published>>,
132    /// Another record than the app's own, published at this id, whose
133    /// actions a live row may offer; `None` for the app's own resource.
134    record: Option<Rc<str>>,
135}
136
137/// What bound controls look up: a publication shared with the root that
138/// built it, and the few actions the views bound over it since, which win
139/// over the shared ones of the same id.
140struct Published {
141    base: Rc<tilo_actions::Resource>,
142    overlay: Vec<tilo_actions::ActionSpec>,
143}
144
145impl Published {
146    /// The published spec of action `id`.
147    fn find(&self, id: &str) -> Option<&tilo_actions::ActionSpec> {
148        self.overlay
149            .iter()
150            .find(|spec| spec.id == id)
151            .or_else(|| self.base.actions.iter().find(|spec| spec.id == id))
152    }
153}
154
155impl ActionTable {
156    fn new(app: &'static str, kind: &'static str, published: Rc<tilo_actions::Resource>) -> Self {
157        Self {
158            app,
159            kind,
160            resource: Rc::new(RefCell::new(Published {
161                base: published,
162                overlay: Vec::new(),
163            })),
164            record: None,
165        }
166    }
167
168    /// Route the actions to the record `record`, where it may be changed.
169    fn route(&mut self, record: &str, editable: bool) {
170        self.record = editable.then(|| Rc::from(record));
171    }
172
173    /// Follow the canonical publication of this same hosted resource.
174    pub(crate) fn update(&self, published: tilo_actions::Resource) {
175        self.update_shared(Rc::new(published), Vec::new());
176    }
177
178    /// Follow `base`, a publication shared with whoever built it, with
179    /// `overlay`'s actions in place of its own of the same id: nothing of
180    /// `base` is copied, and the last one is only let go.
181    pub(crate) fn update_shared(
182        &self,
183        base: Rc<tilo_actions::Resource>,
184        overlay: Vec<tilo_actions::ActionSpec>,
185    ) {
186        let mut current = self.resource.borrow_mut();
187        // A handle belongs to one root; a replacement gets its own handle.
188        if current.base.id == base.id && current.base.kind == base.kind {
189            *current = Published { base, overlay };
190        } else {
191            tracing::error!("action publication changed resource identity");
192        }
193    }
194
195    /// The key of action `id`.
196    fn key(&self, id: &str) -> ActionKey {
197        ActionKey::new(self.app, self.kind, id)
198    }
199
200    /// The published spec of the action `key` names, or why there is none.
201    fn spec(&self, key: &ActionKey) -> Result<tilo_actions::ActionSpec, String> {
202        let published = self.resource.borrow();
203        if published.base.kind != key.kind {
204            return Err(format!("the resource is a `{}`", published.base.kind));
205        }
206        published
207            .find(&key.action)
208            .cloned()
209            .ok_or_else(|| "the resource does not publish it".to_owned())
210    }
211
212    /// The binding of action `id` for a control with input `I`, or why
213    /// there is none.
214    fn binding<I: 'static>(
215        &self,
216        id: &str,
217        arguments: Arguments<I>,
218    ) -> Result<ActionBinding<I>, (ActionKey, String)> {
219        let key = self.key(id);
220        let spec = self.spec(&key).map_err(|why| (key.clone(), why))?;
221        let published = self.exporting(spec.clone());
222        ActionBinding::fallible(&published, &spec, arguments)
223            .map_err(|error| (key, error.to_string()))
224    }
225
226    /// The binding of one fully specified invocation of action `id`, or why
227    /// there is none.
228    fn invocation(
229        &self,
230        id: &str,
231        refusal: Option<&str>,
232        arguments: serde_json::Map<String, serde_json::Value>,
233    ) -> Result<ActionBinding<()>, (ActionKey, String)> {
234        let key = self.key(id);
235        if let Some(refusal) = refusal {
236            return Err((key, refusal.to_owned()));
237        }
238        let spec = self.spec(&key).map_err(|why| (key.clone(), why))?;
239        let published = self.exporting(spec.clone());
240        ActionBinding::static_json(&published, &spec, arguments)
241            .map_err(|error| (key, error.to_string()))
242    }
243
244    /// [`Self::binding`] for a control being built: an action the resource
245    /// does not publish fails tests and `tilo-app check`.
246    fn control_binding<I: 'static>(
247        &self,
248        id: &str,
249        arguments: Arguments<I>,
250    ) -> Option<ActionBinding<I>> {
251        self.binding(id, arguments)
252            .map_err(|(key, why)| unbound_of(self.kind, &format!("`{key}`"), &why))
253            .ok()
254    }
255
256    /// The binding of the field edit described as `what` for a control with
257    /// input `I`, each committed input becoming the edit `edit` makes;
258    /// `None`, and a failure in tests, when the resource has no field edits
259    /// (`edits`) or the field is read-only.
260    fn edit_binding<I: 'static>(
261        &self,
262        what: &str,
263        edits: bool,
264        readonly: bool,
265        edit: Box<dyn Fn(I) -> Result<serde_json::Value, String> + Send + Sync>,
266    ) -> Option<ActionBinding<I>> {
267        if !edits {
268            unbound_of(self.kind, what, "it has no field edits");
269            return None;
270        }
271        if readonly {
272            unbound_of(
273                self.kind,
274                what,
275                "the field is read-only; only verbs change it",
276            );
277            return None;
278        }
279        let spec = match self.spec(&self.key(tilo_resource::FIELD_EDIT)) {
280            Ok(spec) => spec,
281            Err(why) => {
282                unbound_of(self.kind, what, &why);
283                return None;
284            }
285        };
286        let published = self.exporting(spec.clone());
287        let arguments: Arguments<I> = Box::new(move |input| {
288            let edit = edit(input)?;
289            Ok(serde_json::Map::from_iter([(
290                tilo_resource::EDIT_PARAM.to_owned(),
291                edit,
292            )]))
293        });
294        ActionBinding::fallible(&published, &spec, arguments)
295            .map_err(|error| unbound_of(self.kind, what, &error.to_string()))
296            .ok()
297    }
298
299    /// The binding of setting the field at `segments` (described as `what`)
300    /// for a control with input `I`, each committed input becoming the value
301    /// `value` makes of it.
302    fn set_binding<I: 'static>(
303        &self,
304        what: &str,
305        edits: bool,
306        readonly: bool,
307        segments: &[tilo_resource::Segment],
308        value: Box<dyn Fn(I) -> Result<serde_json::Value, String> + Send + Sync>,
309    ) -> Option<ActionBinding<I>> {
310        let path = serde_json::to_value(segments).unwrap_or_default();
311        self.edit_binding(
312            what,
313            edits,
314            readonly,
315            Box::new(move |input| {
316                let value = value(input)?;
317                let mut edit = serde_json::to_value(tilo_resource::EditOp::Set { value })
318                    .map_err(|error| error.to_string())?;
319                if let serde_json::Value::Object(edit) = &mut edit {
320                    edit.insert("path".to_owned(), path.clone());
321                }
322                Ok(edit)
323            }),
324        )
325    }
326
327    /// The resource as a binding names it, exporting `spec` alone: one of
328    /// its published actions, or an effect, which the handler a view or
329    /// surface registers publishes beside them.
330    fn exporting(&self, spec: tilo_actions::ActionSpec) -> tilo_actions::Resource {
331        let published = &self.resource.borrow().base;
332        tilo_actions::Resource {
333            id: published.id.clone(),
334            semantic_identity: published.semantic_identity.clone(),
335            kind: published.kind.clone(),
336            label: published.label.clone(),
337            presentation: None,
338            children: Vec::new(),
339            actions: vec![spec],
340            views: Vec::new(),
341        }
342    }
343
344    /// The binding of the effect `spec` (effect `id`) for a control with
345    /// input `I`.
346    fn effect_binding<I: 'static>(
347        &self,
348        id: &str,
349        spec: Option<tilo_actions::ActionSpec>,
350        arguments: Arguments<I>,
351    ) -> Option<ActionBinding<I>> {
352        let resource = self.exporting(spec?);
353        let spec = resource.actions.first()?.clone();
354        ActionBinding::fallible(&resource, &spec, arguments)
355            .map_err(|error| tracing::warn!(%error, effect = id, "control not bound"))
356            .ok()
357    }
358
359    /// The binding of one fully specified effect `id`, for a button.
360    fn effect_invocation(
361        &self,
362        id: &str,
363        spec: Option<tilo_actions::ActionSpec>,
364        arguments: serde_json::Map<String, serde_json::Value>,
365    ) -> Option<ActionBinding<()>> {
366        let resource = self.exporting(spec?);
367        let spec = resource.actions.first()?.clone();
368        ActionBinding::static_json(&resource, &spec, arguments)
369            .map_err(|error| tracing::warn!(%error, effect = id, "control not bound"))
370            .ok()
371    }
372}
373
374/// The spec `E` declares for effect `id`.
375fn effect_spec<E: Effect>(id: &str) -> Option<tilo_actions::ActionSpec> {
376    E::specs().into_iter().find(|spec| spec.id == id)
377}
378
379/// The actions a resource publishes, for binding controls that are built
380/// outside the view function: rows a table, list or tree creates on demand.
381/// Cheap to clone; clones share the published descriptor.
382pub struct Actions<R: Resource> {
383    table: ActionTable,
384    marker: PhantomData<fn() -> R>,
385}
386
387impl<R: Resource> Clone for Actions<R> {
388    fn clone(&self) -> Self {
389        Self {
390            table: self.table.clone(),
391            marker: PhantomData,
392        }
393    }
394}
395
396impl<R: Resource> Actions<R> {
397    pub(crate) fn new(app: &'static str, published: Rc<tilo_actions::Resource>) -> Self {
398        Self {
399            table: ActionTable::new(app, R::KIND, published),
400            marker: PhantomData,
401        }
402    }
403
404    /// These actions as another record's, published at `record`: a live row
405    /// may offer them ([`Self::operation`]) where it may change it.
406    pub(crate) fn routed_to(mut self, record: &str, editable: bool) -> Self {
407        self.table.route(record, editable);
408        self
409    }
410
411    /// `decl` — a bound field edit ([`tilo_resource::ActionDecl::edit`],
412    /// `edit_asking`) or verb of another record — as an operation a live
413    /// row offers ([`LiveRow::operation`](super::LiveRow::operation)),
414    /// carried out on that record as its person (*this* app's notification
415    /// rule on the app's row). `None` for the app's own resource, whose row
416    /// edits are [`ResourceSurface::row_declarations`](super::ResourceSurface::row_declarations),
417    /// and where the record is read only here.
418    #[must_use]
419    pub fn operation(
420        &self,
421        decl: &tilo_resource::ActionDecl<R>,
422    ) -> Option<tilo_actions::ActionSpec> {
423        let record = self.table.record.as_deref()?;
424        Some(super::record::route_spec(
425            tilo_resource::declaration_spec(decl),
426            record,
427        ))
428    }
429
430    /// The table these actions bind from, which follows the publication
431    /// whatever the resource's type.
432    pub(crate) fn table(&self) -> ActionTable {
433        self.table.clone()
434    }
435
436    /// The binding of `action` for a control with input `I`, or `None` while
437    /// the resource does not publish it (an edit bound to view state that
438    /// is not there now).
439    pub fn binding<I: 'static>(&self, action: impl Binds<R, I>) -> Option<ActionBinding<I>> {
440        let id = action.action_id();
441        self.table
442            .binding(id, Box::new(move |input| action.arguments(input)))
443            .ok()
444    }
445
446    /// The binding of one fully specified invocation, for a button, or
447    /// `None` while the resource does not publish it.
448    pub fn invocation(&self, invoke: Invoke<R>) -> Option<ActionBinding<()>> {
449        let refusal = invoke.refusal().map(str::to_owned);
450        self.table
451            .invocation(invoke.id, refusal.as_deref(), invoke.arguments)
452            .ok()
453    }
454
455    /// [`Self::binding`] for a control being built: an action the resource
456    /// does not publish fails tests and `tilo-app check`.
457    pub(crate) fn control_binding<I: 'static>(
458        &self,
459        action: impl Binds<R, I>,
460    ) -> Option<ActionBinding<I>> {
461        let id = action.action_id();
462        self.table
463            .control_binding(id, Box::new(move |input| action.arguments(input)))
464    }
465
466    /// [`Self::invocation`] for a control being built.
467    pub(crate) fn control_invocation(&self, invoke: Invoke<R>) -> Option<ActionBinding<()>> {
468        let refusal = invoke.refusal().map(str::to_owned);
469        self.table
470            .invocation(invoke.id, refusal.as_deref(), invoke.arguments)
471            .map_err(|(key, why)| unbound_of(R::KIND, &format!("`{key}`"), &why))
472            .ok()
473    }
474
475    /// The binding of a button that invokes `R`'s action `id` with the
476    /// arguments `arguments` makes when it is pressed (a form's values).
477    pub(crate) fn deferred_binding(
478        &self,
479        id: &str,
480        arguments: impl Fn() -> Result<serde_json::Map<String, serde_json::Value>, String>
481        + Send
482        + Sync
483        + 'static,
484    ) -> Option<ActionBinding<()>> {
485        self.table
486            .control_binding(id, Box::new(move |()| arguments()))
487    }
488
489    /// The binding of the field at `path` for a control with input `V`:
490    /// each committed value becomes the field edit `edit(path, value)`.
491    /// `None`, and a failure in tests, when the resource has no field edits
492    /// or the field is `#[model(readonly)]`.
493    pub fn edit_binding<V: 'static>(
494        &self,
495        path: Path<R, V>,
496        edit: impl Fn(&Path<R, V>, V) -> Edit<R> + Send + Sync + 'static,
497    ) -> Option<ActionBinding<V>> {
498        self.edit_binding_with(path, move |path, value| Ok(edit(path, value)))
499    }
500
501    /// [`Self::edit_binding`] for a control whose input `I` is not the
502    /// field's value: a dropdown of "125%" for a scale of 1.25, a slider's
503    /// position on a log scale. `edit` maps each committed input to the
504    /// field edit, or refuses it, which refuses the input like a value the
505    /// field cannot take.
506    pub fn edit_binding_with<V: 'static, I: 'static>(
507        &self,
508        path: Path<R, V>,
509        edit: impl Fn(&Path<R, V>, I) -> Result<Edit<R>, String> + Send + Sync + 'static,
510    ) -> Option<ActionBinding<I>> {
511        let what = format!("the field {path:?}");
512        let readonly = R::shape().is_some_and(|shape| shape.readonly_at(path.segments()));
513        self.table.edit_binding(
514            &what,
515            R::EDITS,
516            readonly,
517            Box::new(move |input| {
518                let edit = edit(&path, input)?;
519                serde_json::to_value(&edit).map_err(|error| error.to_string())
520            }),
521        )
522    }
523
524    /// The binding of the field at `path` for a control with input `I`:
525    /// each committed input sets the field to what `value` makes of it, or is
526    /// refused where `value` refuses it. What a settings row binds; the same
527    /// edit as [`Self::edit_binding_with`] and [`Path::set`].
528    pub fn set_binding<V, I>(
529        &self,
530        path: Path<R, V>,
531        value: impl Fn(I) -> Result<V, String> + Send + Sync + 'static,
532    ) -> Option<ActionBinding<I>>
533    where
534        V: tilo_resource::Settable + 'static,
535        I: 'static,
536    {
537        let what = format!("the field {path:?}");
538        let readonly = R::shape().is_some_and(|shape| shape.readonly_at(path.segments()));
539        let segments = path.into_segments();
540        self.table
541            .set_binding(&what, R::EDITS, readonly, &segments, json_of(value))
542    }
543
544    /// [`Self::set_binding`] of a control whose input is the field's value.
545    pub fn field_binding<V>(&self, path: Path<R, V>) -> Option<ActionBinding<V>>
546    where
547        V: tilo_resource::Settable + 'static,
548    {
549        self.set_binding(path, same::<V>)
550    }
551
552    /// `control`, bound to `action`. An action the resource does not publish
553    /// leaves it disabled, and fails tests and `tilo-app check`.
554    pub fn bind<I: 'static, C: ActionControl<I>>(&self, control: C, action: impl Binds<R, I>) -> C {
555        match self.control_binding(action) {
556            Some(binding) => control.bind_action(binding),
557            None => control.unavailable(),
558        }
559    }
560
561    /// `control` (a button), bound to one fully specified invocation.
562    pub fn bind_invocation<C: ActionControl<()>>(&self, control: C, invoke: Invoke<R>) -> C {
563        match self.control_invocation(invoke) {
564            Some(binding) => control.bind_action(binding),
565            None => control.unavailable(),
566        }
567    }
568
569    /// The binding of effect `effect` for a control with input `I`
570    /// (`Backups::RESTORE` for a dropdown of snapshots).
571    pub fn effect_binding<E: Effect, I: 'static>(
572        &self,
573        effect: impl Binds<E, I>,
574    ) -> Option<ActionBinding<I>> {
575        let id = effect.action_id();
576        self.table.effect_binding(
577            id,
578            effect_spec::<E>(id),
579            Box::new(move |input| effect.arguments(input)),
580        )
581    }
582
583    /// The binding of one fully specified effect, for a button.
584    pub fn effect<E: Effect>(&self, effect: &E) -> Option<ActionBinding<()>> {
585        self.table.effect_invocation(
586            effect.id(),
587            effect_spec::<E>(effect.id()),
588            effect.arguments(),
589        )
590    }
591
592    /// `control`, bound to effect `effect`.
593    pub fn bind_effect<E: Effect, I: 'static, C: ActionControl<I>>(
594        &self,
595        control: C,
596        effect: impl Binds<E, I>,
597    ) -> C {
598        match self.effect_binding(effect) {
599            Some(binding) => control.bind_action(binding),
600            None => control.unavailable(),
601        }
602    }
603
604    /// `control` (a button), bound to one fully specified effect.
605    pub fn bind_effect_invocation<E: Effect, C: ActionControl<()>>(
606        &self,
607        control: C,
608        effect: &E,
609    ) -> C {
610        match self.effect(effect) {
611            Some(binding) => control.bind_action(binding),
612            None => control.unavailable(),
613        }
614    }
615}
616
617/// `value` with its result in its JSON form, the same for every resource.
618fn json_of<V: tilo_resource::Model, I>(
619    value: impl Fn(I) -> Result<V, String> + Send + Sync + 'static,
620) -> Box<dyn Fn(I) -> Result<serde_json::Value, String> + Send + Sync> {
621    Box::new(move |input| value(input).map(|value| value.to_json()))
622}
623
624/// A control's value, set as it is.
625#[allow(
626    clippy::unnecessary_wraps,
627    reason = "the shape every value conversion has"
628)]
629fn same<V>(value: V) -> Result<V, String> {
630    Ok(value)
631}