Skip to main content

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}