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    /// The window whose tree it was sent in, noted by that tree.
55    origin_window: Option<crate::window::TeksiloWindowId>,
56    /// The activation token the event it was sent for carried, noted when
57    /// its tree keeps it for another; see
58    /// [`activation_token`](Self::activation_token).
59    activation_token: Option<String>,
60    /// How many times it, or the routed intents whose actions sent it, have
61    /// crossed from one tree to another.
62    crossings: usize,
63    /// Whether an open modal overlay keeps it, as it keeps a key's; see
64    /// [`kept_in_modal`](Self::kept_in_modal).
65    kept_in_modal: bool,
66}
67
68impl Intent {
69    /// A parameter-less intent. Defaults to
70    /// `IntentSource::Programmatic`; framework activation paths
71    /// override via [`Intent::with_source`] before dispatching.
72    pub fn new(name: &'static str) -> Self {
73        Self {
74            name,
75            source: crate::telemetry::IntentSource::Programmatic,
76            payload: None,
77            origin_window: None,
78            activation_token: None,
79            crossings: 0,
80            kept_in_modal: false,
81        }
82    }
83
84    /// An intent carrying a typed payload. The payload is stored
85    /// type-erased in an `Rc<dyn Any>`; recover it with
86    /// [`Intent::payload`].
87    pub fn with_payload<T: 'static>(name: &'static str, payload: T) -> Self {
88        Self {
89            name,
90            source: crate::telemetry::IntentSource::Programmatic,
91            payload: Some(Rc::new(payload)),
92            origin_window: None,
93            activation_token: None,
94            crossings: 0,
95            kept_in_modal: false,
96        }
97    }
98
99    /// Tag the intent with its origin. Called by the shortcut
100    /// activation path right before dispatch; the other framework
101    /// activation wrappers (button, menu, gesture, AT) tag through
102    /// the dispatcher's `EventContext::current_source` instead. App
103    /// code typically doesn't call this directly — use
104    /// `EventContext::send_intent` from inside the right handler and
105    /// the source is set automatically.
106    ///
107    /// (Note: `EventContext::send_intent` infers the source from
108    /// the handler context where possible; this method is the
109    /// escape hatch for callers that need to override.)
110    pub fn with_source(mut self, source: crate::telemetry::IntentSource) -> Self {
111        self.source = source;
112        self
113    }
114
115    /// Mark the intent as input the user gave the window, as a key is: an
116    /// open modal overlay in its tree (a dialog presented in the window, the
117    /// command palette) keeps it, as it keeps a shortcut's intent. Only the
118    /// actions inside the modal, and the window-global actions registered
119    /// inside it, are offered it; it never walks on to the page behind. What
120    /// the window leaves still goes to the application tree. `teksilo-app`
121    /// marks the intent of a row chosen in the native menu bar, which AppKit
122    /// disables in a document window while a modal panel is up.
123    #[doc(hidden)]
124    pub fn kept_in_modal(mut self) -> Self {
125        self.kept_in_modal = true;
126        self
127    }
128
129    /// Whether an open modal keeps it: a shortcut's, or one marked
130    /// [`kept_in_modal`](Self::kept_in_modal). What a dialog leaves of it
131    /// goes to the application tree only: an open modal overlay keeps it
132    /// from the page, and `teksilo-app` keeps it from the windows a native
133    /// dialog window blocks.
134    #[doc(hidden)]
135    pub fn stays_in_modal(&self) -> bool {
136        self.kept_in_modal || self.source == crate::telemetry::IntentSource::Shortcut
137    }
138
139    /// Borrow the payload as `&T`, or `None` if the intent has no
140    /// payload or the payload's concrete type doesn't match.
141    pub fn payload<T: 'static>(&self) -> Option<&T> {
142        let any: &dyn Any = &**self.payload.as_ref()?;
143        any.downcast_ref::<T>()
144    }
145
146    /// Whether this intent carries any payload (typed or not).
147    pub fn has_payload(&self) -> bool {
148        self.payload.is_some()
149    }
150
151    /// The window it was sent in: the window whose tree dispatched it
152    /// first. `None` for an intent sent in the application tree, or in a
153    /// tree with no window, and before any tree has dispatched it.
154    ///
155    /// An intent a window leaves unclaimed keeps it as it crosses, so an
156    /// action of the application tree knows which window a command came
157    /// from: the one a menu row or a shortcut was used in, the window with
158    /// the OS focus for a macOS menu bar item. An intent re-sent from its
159    /// action keeps the window it was first sent in.
160    pub fn origin_window(&self) -> Option<crate::window::TeksiloWindowId> {
161        self.origin_window
162    }
163
164    /// Note `window` as the window it was sent in, unless one is noted.
165    pub(crate) fn note_origin_window(&mut self, window: Option<crate::window::TeksiloWindowId>) {
166        if self.origin_window.is_none() {
167            self.origin_window = window;
168        }
169    }
170
171    /// The activation token it carries across to another tree: the token
172    /// of the event it was sent for (see `WithActivationToken` in
173    /// `teksilo-app`), noted when its tree kept it unclaimed while that
174    /// token was still unused. The tree it is
175    /// [routed](crate::WidgetTree::dispatch_routed_intent) to holds it for
176    /// the first window its actions open or raise, as the first tree would
177    /// have.
178    #[doc(hidden)]
179    pub fn activation_token(&self) -> Option<&str> {
180        self.activation_token.as_deref()
181    }
182
183    /// Take the activation token it carries, leaving none.
184    pub(crate) fn take_activation_token(&mut self) -> Option<String> {
185        self.activation_token.take()
186    }
187
188    /// How many crossings between trees led to it: those it made, and
189    /// those of the routed intent whose action sent it. See
190    /// [`WidgetTree::dispatch_routed_intent`](crate::WidgetTree::dispatch_routed_intent).
191    pub(crate) fn crossings(&self) -> usize {
192        self.crossings
193    }
194
195    /// Note that `crossings` crossings led to it, unless more did.
196    pub(crate) fn note_crossings(&mut self, crossings: usize) {
197        self.crossings = self.crossings.max(crossings);
198    }
199
200    /// Note `token` as the one it carries, unless it carries one.
201    pub(crate) fn note_activation_token(&mut self, token: Option<String>) {
202        if self.activation_token.is_none() {
203            self.activation_token = token;
204        }
205    }
206}
207
208impl Clone for Intent {
209    fn clone(&self) -> Self {
210        Self {
211            name: self.name,
212            source: self.source,
213            // Rc<dyn Any> is cheaply clonable — bumps the refcount.
214            payload: self.payload.clone(),
215            origin_window: self.origin_window,
216            activation_token: self.activation_token.clone(),
217            crossings: self.crossings,
218            kept_in_modal: self.kept_in_modal,
219        }
220    }
221}
222
223impl std::fmt::Debug for Intent {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.debug_struct("Intent")
226            .field("name", &self.name)
227            .field("source", &self.source)
228            .field("has_payload", &self.payload.is_some())
229            .finish()
230    }
231}
232
233/// Typed DTO bridge between an app's intent enum and the runtime
234/// [`Intent`] dispatch type.
235///
236/// Apps that want compile-time guarantees — typo-safe intent
237/// construction, exhaustive matches on recognized intents, a single
238/// source of truth for intent names — define an enum and implement
239/// this trait (by hand or via `#[derive(IntentKind)]` from
240/// `teksilo-macros`).
241///
242/// ```ignore
243/// #[derive(Debug, IntentKind)]
244/// enum AppIntent {
245///     #[name = "app.save"]        Save,
246///     #[name = "app.open"]        Open(PathBuf),
247///     #[name = "app.add_item"]    AddItem { id: i64, dto: CreateItemDto },
248/// }
249///
250/// // Send (typo-safe at the enum variant — blanket From<K> for Intent
251/// // means no explicit .into_intent() call is needed):
252/// ctx.send_intent(AppIntent::Save);
253/// ctx.send_intent(AppIntent::Open(path));
254///
255/// // Handle (exhaustive match, recovers the full variant):
256/// Action::new("app.open").on_invoke(|intent, ctx| {
257///     if let Some(AppIntent::Open(path)) = AppIntent::from_intent(intent) {
258///         open_file(path, ctx);
259///     }
260/// })
261/// ```
262///
263/// The variant itself — including any fields — is stored as the
264/// intent's payload, so any `'static` variant works. Struct
265/// variants (`AddItem { .. }`), tuple variants (`Open(PathBuf)`),
266/// and unit variants (`Save`) are all supported without restriction.
267///
268/// `from_intent` returns a reference (`Option<&Self>`), so recovery
269/// does not require `Self: Clone`. If an owned variant is needed and
270/// the enum derives `Clone`, call `.cloned()` on the result.
271pub trait IntentKind: Sized + 'static {
272    /// Consume the variant and build the runtime [`Intent`] it
273    /// corresponds to. The variant — and any data it carries — is
274    /// stored as the intent's type-erased payload.
275    fn into_intent(self) -> Intent;
276
277    /// Recognise a runtime intent as one of this enum's variants and
278    /// borrow its payload. Returns `None` for foreign intents (names
279    /// this enum doesn't cover) and for intents whose payload is
280    /// missing or of a different concrete type.
281    fn from_intent(intent: &Intent) -> Option<&Self>;
282}
283
284/// Blanket conversion from any [`IntentKind`] into a runtime [`Intent`].
285///
286/// Lets call sites drop the explicit `.into_intent()` hop where an
287/// `Into<Intent>` bound is available (for example,
288/// [`EventContext::send_intent`](crate::widget::EventContext::send_intent)
289/// and [`ShortcutBuilder::on_activate`](crate::shortcut::ShortcutBuilder::on_activate)).
290impl<K: IntentKind> From<K> for Intent {
291    fn from(kind: K) -> Self {
292        kind.into_intent()
293    }
294}
295
296/// Return value of an [`Action`](crate::action::Action) handler. Controls
297/// whether the intent keeps bubbling up to ancestor widgets.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
299pub enum IntentResponse {
300    /// Intent was consumed; stop walking up the focus chain.
301    #[default]
302    Handled,
303    /// Intent was observed but not consumed; continue walking up so
304    /// ancestor widgets can also react.
305    Propagated,
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn intent_without_payload() {
314        let i = Intent::new("app.save");
315        assert_eq!(i.name, "app.save");
316        assert!(!i.has_payload());
317        assert_eq!(i.payload::<i32>(), None);
318    }
319
320    #[test]
321    fn typed_payload_round_trip() {
322        let i = Intent::with_payload("app.scroll_by", 42_i64);
323        assert!(i.has_payload());
324        assert_eq!(i.payload::<i64>(), Some(&42_i64));
325        // Wrong type: None.
326        assert_eq!(i.payload::<String>(), None);
327    }
328
329    #[test]
330    fn payload_carries_complex_types() {
331        #[derive(Debug, PartialEq)]
332        struct Dto {
333            id: i64,
334            name: String,
335        }
336        let i = Intent::with_payload(
337            "app.add_item",
338            Dto {
339                id: 7,
340                name: "Foo".into(),
341            },
342        );
343        assert_eq!(
344            i.payload::<Dto>(),
345            Some(&Dto {
346                id: 7,
347                name: "Foo".into(),
348            })
349        );
350    }
351
352    #[test]
353    fn default_response_is_handled() {
354        assert_eq!(IntentResponse::default(), IntentResponse::Handled);
355    }
356}