teksilo_core/intent.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Runtime intents dispatched by shortcuts and programmatic callers.
5//!
6//! An [`Intent`] is the unit of "something wants to happen" in the
7//! action system. It pairs a stable name (the intent string) with an
8//! optional type-erased payload that handlers downcast when they
9//! recognize the intent. Intents are produced by
10//! [`Shortcut`](crate::shortcut::Shortcut)s at activation time, by
11//! widgets via `ctx.send_intent`, or by programmatic callers.
12//!
13//! They dispatch through the widget tree by walking
14//! **source-widget → root**: each ancestor's
15//! [`Action`](crate::action::Action) whose `intent` name matches
16//! gets a chance to consume the intent or propagate it.
17//!
18//! ## Typed DTOs via [`IntentKind`]
19//!
20//! Apps that want typo-safe construction and handler-side
21//! exhaustiveness define an enum and implement [`IntentKind`] (by
22//! hand or via `#[derive(IntentKind)]` from `teksilo-macros`). The
23//! whole variant — including any fields it carries — is stored as
24//! the intent's payload; handlers recover it via
25//! [`Intent::payload`] or [`IntentKind::from_intent`].
26
27use std::any::Any;
28use std::rc::Rc;
29
30/// A runtime intent dispatched through the widget tree.
31///
32/// The `name` is the stable dispatch key (matched against
33/// [`Action::intent`](crate::action::Action)). The optional
34/// `payload` carries any type the sender wants to attach — recover
35/// it with [`Intent::payload::<T>`] when the handler knows the
36/// expected type (typically via `IntentKind::from_intent`).
37///
38/// The `source` field records where the intent originated — set by
39/// the framework's standard activation paths (button taps, menu
40/// selects, shortcut chords, gesture recognizers) so analytics can
41/// answer "which surface drives this intent?". See
42/// [`crate::telemetry::IntentSource`].
43pub struct Intent {
44 /// Stable intent name. Usually matches the originating
45 /// [`Shortcut`](crate::shortcut::Shortcut)'s `intent_name()`.
46 pub name: &'static str,
47 /// Origin of the intent. The framework's activation paths
48 /// (button, menu, shortcut, gesture) set this; programmatic
49 /// callers default to `Programmatic` via [`Intent::new`].
50 /// Read by the dispatch-tap to fill the `source` prop on
51 /// `intent.dispatched` events.
52 pub source: crate::telemetry::IntentSource,
53 payload: Option<Rc<dyn Any>>,
54}
55
56impl Intent {
57 /// A parameter-less intent. Defaults to
58 /// `IntentSource::Programmatic`; framework activation paths
59 /// override via [`Intent::with_source`] before dispatching.
60 pub fn new(name: &'static str) -> Self {
61 Self {
62 name,
63 source: crate::telemetry::IntentSource::Programmatic,
64 payload: None,
65 }
66 }
67
68 /// An intent carrying a typed payload. The payload is stored
69 /// type-erased in an `Rc<dyn Any>`; recover it with
70 /// [`Intent::payload`].
71 pub fn with_payload<T: 'static>(name: &'static str, payload: T) -> Self {
72 Self {
73 name,
74 source: crate::telemetry::IntentSource::Programmatic,
75 payload: Some(Rc::new(payload)),
76 }
77 }
78
79 /// Tag the intent with its origin. Called by framework
80 /// activation wrappers (button on_activate, menu on_select,
81 /// shortcut activation, gesture on_recognized) right before
82 /// dispatch. App code typically doesn't call this directly
83 /// — use `EventContext::send_intent` from inside the right
84 /// handler and the source is set automatically.
85 ///
86 /// (Note: `EventContext::send_intent` infers the source from
87 /// the handler context where possible; this method is the
88 /// escape hatch for callers that need to override.)
89 pub fn with_source(mut self, source: crate::telemetry::IntentSource) -> Self {
90 self.source = source;
91 self
92 }
93
94 /// Borrow the payload as `&T`, or `None` if the intent has no
95 /// payload or the payload's concrete type doesn't match.
96 pub fn payload<T: 'static>(&self) -> Option<&T> {
97 let any: &dyn Any = &**self.payload.as_ref()?;
98 any.downcast_ref::<T>()
99 }
100
101 /// Whether this intent carries any payload (typed or not).
102 pub fn has_payload(&self) -> bool {
103 self.payload.is_some()
104 }
105}
106
107impl Clone for Intent {
108 fn clone(&self) -> Self {
109 Self {
110 name: self.name,
111 source: self.source,
112 // Rc<dyn Any> is cheaply clonable — bumps the refcount.
113 payload: self.payload.clone(),
114 }
115 }
116}
117
118impl std::fmt::Debug for Intent {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.debug_struct("Intent")
121 .field("name", &self.name)
122 .field("source", &self.source)
123 .field("has_payload", &self.payload.is_some())
124 .finish()
125 }
126}
127
128/// Typed DTO bridge between an app's intent enum and the runtime
129/// [`Intent`] dispatch type.
130///
131/// Apps that want compile-time guarantees — typo-safe intent
132/// construction, exhaustive matches on recognized intents, a single
133/// source of truth for intent names — define an enum and implement
134/// this trait (by hand or via `#[derive(IntentKind)]` from
135/// `teksilo-macros`).
136///
137/// ```ignore
138/// #[derive(Debug, IntentKind)]
139/// enum AppIntent {
140/// #[name = "app.save"] Save,
141/// #[name = "app.open"] Open(PathBuf),
142/// #[name = "app.add_item"] AddItem { id: i64, dto: CreateItemDto },
143/// }
144///
145/// // Send (typo-safe at the enum variant — blanket From<K> for Intent
146/// // means no explicit .into_intent() call is needed):
147/// ctx.send_intent(AppIntent::Save);
148/// ctx.send_intent(AppIntent::Open(path));
149///
150/// // Handle (exhaustive match, recovers the full variant):
151/// Action::new("app.open").on_invoke(|intent, ctx| {
152/// if let Some(AppIntent::Open(path)) = AppIntent::from_intent(intent) {
153/// open_file(path, ctx);
154/// }
155/// })
156/// ```
157///
158/// The variant itself — including any fields — is stored as the
159/// intent's payload, so any `'static` variant works. Struct
160/// variants (`AddItem { .. }`), tuple variants (`Open(PathBuf)`),
161/// and unit variants (`Save`) are all supported without restriction.
162///
163/// `from_intent` returns a reference (`Option<&Self>`), so recovery
164/// does not require `Self: Clone`. If an owned variant is needed and
165/// the enum derives `Clone`, call `.cloned()` on the result.
166pub trait IntentKind: Sized + 'static {
167 /// Consume the variant and build the runtime [`Intent`] it
168 /// corresponds to. The variant — and any data it carries — is
169 /// stored as the intent's type-erased payload.
170 fn into_intent(self) -> Intent;
171
172 /// Recognise a runtime intent as one of this enum's variants and
173 /// borrow its payload. Returns `None` for foreign intents (names
174 /// this enum doesn't cover) and for intents whose payload is
175 /// missing or of a different concrete type.
176 fn from_intent(intent: &Intent) -> Option<&Self>;
177}
178
179/// Blanket conversion from any [`IntentKind`] into a runtime [`Intent`].
180///
181/// Lets call sites drop the explicit `.into_intent()` hop where an
182/// `Into<Intent>` bound is available (for example,
183/// [`EventContext::send_intent`](crate::widget::EventContext::send_intent)
184/// and [`ShortcutBuilder::on_activate`](crate::shortcut::ShortcutBuilder::on_activate)).
185impl<K: IntentKind> From<K> for Intent {
186 fn from(kind: K) -> Self {
187 kind.into_intent()
188 }
189}
190
191/// Return value of an [`Action`](crate::action::Action) handler. Controls
192/// whether the intent keeps bubbling up to ancestor widgets.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194pub enum IntentResponse {
195 /// Intent was consumed; stop walking up the focus chain.
196 #[default]
197 Handled,
198 /// Intent was observed but not consumed; continue walking up so
199 /// ancestor widgets can also react.
200 Propagated,
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn intent_without_payload() {
209 let i = Intent::new("app.save");
210 assert_eq!(i.name, "app.save");
211 assert!(!i.has_payload());
212 assert_eq!(i.payload::<i32>(), None);
213 }
214
215 #[test]
216 fn typed_payload_round_trip() {
217 let i = Intent::with_payload("app.scroll_by", 42_i64);
218 assert!(i.has_payload());
219 assert_eq!(i.payload::<i64>(), Some(&42_i64));
220 // Wrong type: None.
221 assert_eq!(i.payload::<String>(), None);
222 }
223
224 #[test]
225 fn payload_carries_complex_types() {
226 #[derive(Debug, PartialEq)]
227 struct Dto {
228 id: i64,
229 name: String,
230 }
231 let i = Intent::with_payload(
232 "app.add_item",
233 Dto {
234 id: 7,
235 name: "Foo".into(),
236 },
237 );
238 assert_eq!(
239 i.payload::<Dto>(),
240 Some(&Dto {
241 id: 7,
242 name: "Foo".into(),
243 })
244 );
245 }
246
247 #[test]
248 fn default_response_is_handled() {
249 assert_eq!(IntentResponse::default(), IntentResponse::Handled);
250 }
251}