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}