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 the shortcut
80 /// activation path right before dispatch; the other framework
81 /// activation wrappers (button, menu, gesture, AT) tag through
82 /// the dispatcher's `EventContext::current_source` instead. App
83 /// code typically doesn't call this directly — use
84 /// `EventContext::send_intent` from inside the right handler and
85 /// the source is set automatically.
86 ///
87 /// (Note: `EventContext::send_intent` infers the source from
88 /// the handler context where possible; this method is the
89 /// escape hatch for callers that need to override.)
90 pub fn with_source(mut self, source: crate::telemetry::IntentSource) -> Self {
91 self.source = source;
92 self
93 }
94
95 /// Borrow the payload as `&T`, or `None` if the intent has no
96 /// payload or the payload's concrete type doesn't match.
97 pub fn payload<T: 'static>(&self) -> Option<&T> {
98 let any: &dyn Any = &**self.payload.as_ref()?;
99 any.downcast_ref::<T>()
100 }
101
102 /// Whether this intent carries any payload (typed or not).
103 pub fn has_payload(&self) -> bool {
104 self.payload.is_some()
105 }
106}
107
108impl Clone for Intent {
109 fn clone(&self) -> Self {
110 Self {
111 name: self.name,
112 source: self.source,
113 // Rc<dyn Any> is cheaply clonable — bumps the refcount.
114 payload: self.payload.clone(),
115 }
116 }
117}
118
119impl std::fmt::Debug for Intent {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.debug_struct("Intent")
122 .field("name", &self.name)
123 .field("source", &self.source)
124 .field("has_payload", &self.payload.is_some())
125 .finish()
126 }
127}
128
129/// Typed DTO bridge between an app's intent enum and the runtime
130/// [`Intent`] dispatch type.
131///
132/// Apps that want compile-time guarantees — typo-safe intent
133/// construction, exhaustive matches on recognized intents, a single
134/// source of truth for intent names — define an enum and implement
135/// this trait (by hand or via `#[derive(IntentKind)]` from
136/// `teksilo-macros`).
137///
138/// ```ignore
139/// #[derive(Debug, IntentKind)]
140/// enum AppIntent {
141/// #[name = "app.save"] Save,
142/// #[name = "app.open"] Open(PathBuf),
143/// #[name = "app.add_item"] AddItem { id: i64, dto: CreateItemDto },
144/// }
145///
146/// // Send (typo-safe at the enum variant — blanket From<K> for Intent
147/// // means no explicit .into_intent() call is needed):
148/// ctx.send_intent(AppIntent::Save);
149/// ctx.send_intent(AppIntent::Open(path));
150///
151/// // Handle (exhaustive match, recovers the full variant):
152/// Action::new("app.open").on_invoke(|intent, ctx| {
153/// if let Some(AppIntent::Open(path)) = AppIntent::from_intent(intent) {
154/// open_file(path, ctx);
155/// }
156/// })
157/// ```
158///
159/// The variant itself — including any fields — is stored as the
160/// intent's payload, so any `'static` variant works. Struct
161/// variants (`AddItem { .. }`), tuple variants (`Open(PathBuf)`),
162/// and unit variants (`Save`) are all supported without restriction.
163///
164/// `from_intent` returns a reference (`Option<&Self>`), so recovery
165/// does not require `Self: Clone`. If an owned variant is needed and
166/// the enum derives `Clone`, call `.cloned()` on the result.
167pub trait IntentKind: Sized + 'static {
168 /// Consume the variant and build the runtime [`Intent`] it
169 /// corresponds to. The variant — and any data it carries — is
170 /// stored as the intent's type-erased payload.
171 fn into_intent(self) -> Intent;
172
173 /// Recognise a runtime intent as one of this enum's variants and
174 /// borrow its payload. Returns `None` for foreign intents (names
175 /// this enum doesn't cover) and for intents whose payload is
176 /// missing or of a different concrete type.
177 fn from_intent(intent: &Intent) -> Option<&Self>;
178}
179
180/// Blanket conversion from any [`IntentKind`] into a runtime [`Intent`].
181///
182/// Lets call sites drop the explicit `.into_intent()` hop where an
183/// `Into<Intent>` bound is available (for example,
184/// [`EventContext::send_intent`](crate::widget::EventContext::send_intent)
185/// and [`ShortcutBuilder::on_activate`](crate::shortcut::ShortcutBuilder::on_activate)).
186impl<K: IntentKind> From<K> for Intent {
187 fn from(kind: K) -> Self {
188 kind.into_intent()
189 }
190}
191
192/// Return value of an [`Action`](crate::action::Action) handler. Controls
193/// whether the intent keeps bubbling up to ancestor widgets.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
195pub enum IntentResponse {
196 /// Intent was consumed; stop walking up the focus chain.
197 #[default]
198 Handled,
199 /// Intent was observed but not consumed; continue walking up so
200 /// ancestor widgets can also react.
201 Propagated,
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn intent_without_payload() {
210 let i = Intent::new("app.save");
211 assert_eq!(i.name, "app.save");
212 assert!(!i.has_payload());
213 assert_eq!(i.payload::<i32>(), None);
214 }
215
216 #[test]
217 fn typed_payload_round_trip() {
218 let i = Intent::with_payload("app.scroll_by", 42_i64);
219 assert!(i.has_payload());
220 assert_eq!(i.payload::<i64>(), Some(&42_i64));
221 // Wrong type: None.
222 assert_eq!(i.payload::<String>(), None);
223 }
224
225 #[test]
226 fn payload_carries_complex_types() {
227 #[derive(Debug, PartialEq)]
228 struct Dto {
229 id: i64,
230 name: String,
231 }
232 let i = Intent::with_payload(
233 "app.add_item",
234 Dto {
235 id: 7,
236 name: "Foo".into(),
237 },
238 );
239 assert_eq!(
240 i.payload::<Dto>(),
241 Some(&Dto {
242 id: 7,
243 name: "Foo".into(),
244 })
245 );
246 }
247
248 #[test]
249 fn default_response_is_handled() {
250 assert_eq!(IntentResponse::default(), IntentResponse::Handled);
251 }
252}