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 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}