1use 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
22pub trait ActionControl<I>: Sized {
24 #[must_use]
26 fn bind_action(self, binding: ActionBinding<I>) -> Self;
27
28 #[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 EditableLabel => String, |control| control;
60}
61
62thread_local! {
63 pub(super) static STRICT: Cell<bool> = const { Cell::new(false) };
66 pub(super) static UNBOUND: std::cell::RefCell<Option<Vec<String>>> =
68 const { std::cell::RefCell::new(None) };
69 pub(super) static OPENED: std::cell::RefCell<Option<Vec<tilo_resource::Surface>>> =
72 const { std::cell::RefCell::new(None) };
73}
74
75pub(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
90pub(crate) fn unbound<R: Resource>(what: &str, why: &str) {
95 unbound_of(R::KIND, what, why);
96}
97
98pub(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
118type Arguments<I> =
120 Box<dyn Fn(I) -> Result<serde_json::Map<String, serde_json::Value>, String> + Send + Sync>;
121
122#[derive(Clone)]
126pub(crate) struct ActionTable {
127 app: &'static str,
129 kind: &'static str,
131 resource: Rc<RefCell<Published>>,
132 record: Option<Rc<str>>,
135}
136
137struct Published {
141 base: Rc<tilo_actions::Resource>,
142 overlay: Vec<tilo_actions::ActionSpec>,
143}
144
145impl Published {
146 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 fn route(&mut self, record: &str, editable: bool) {
170 self.record = editable.then(|| Rc::from(record));
171 }
172
173 pub(crate) fn update(&self, published: tilo_actions::Resource) {
175 self.update_shared(Rc::new(published), Vec::new());
176 }
177
178 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 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 fn key(&self, id: &str) -> ActionKey {
197 ActionKey::new(self.app, self.kind, id)
198 }
199
200 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 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 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 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 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 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 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 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 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
374fn effect_spec<E: Effect>(id: &str) -> Option<tilo_actions::ActionSpec> {
376 E::specs().into_iter().find(|spec| spec.id == id)
377}
378
379pub 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 pub(crate) fn routed_to(mut self, record: &str, editable: bool) -> Self {
407 self.table.route(record, editable);
408 self
409 }
410
411 #[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 pub(crate) fn table(&self) -> ActionTable {
433 self.table.clone()
434 }
435
436 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
617fn 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#[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}