Skip to main content

teksilo_core/
action.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Widget-owned units of behavior bound to named intents.
5//!
6//! An [`Action`] lives on a widget alongside its event handlers. When
7//! an [`Intent`] is dispatched, the framework walks the focus /
8//! source-widget chain and, at each level, invokes any [`Action`]
9//! whose `intent` name matches the intent's name. The handler's
10//! [`IntentResponse`] controls whether the intent is consumed or
11//! continues up the chain.
12//!
13//! A widget may declare multiple actions for different intent names;
14//! at a single level, if two actions match the same name, the first
15//! (by declaration order) wins.
16
17use crate::intent::{Intent, IntentResponse};
18use crate::signal::Prop;
19use crate::widget::EventContext;
20
21/// Closure signature for an action handler.
22pub type ActionHandler = Box<dyn FnMut(&Intent, &mut EventContext) -> IntentResponse + 'static>;
23
24/// A widget-owned handler bound to a named intent.
25pub struct Action {
26    /// The intent name this action responds to. Matches against
27    /// [`Intent::name`] during dispatch.
28    pub intent: &'static str,
29    /// Invoked when a matching intent is dispatched to this widget.
30    pub handler: ActionHandler,
31    /// Reactive "is this action currently applicable?" predicate.
32    /// `None` means always enabled.
33    ///
34    /// Disabled semantics at dispatch time: the intent propagates past
35    /// this action as if no match existed here, **unless** the firing
36    /// [`Shortcut`](crate::shortcut::Shortcut) has
37    /// `propagate_when_disabled == false`, in which case the intent
38    /// is consumed (dormant) at this level.
39    pub enabled_when: Option<Prop<bool>>,
40}
41
42impl std::fmt::Debug for Action {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("Action")
45            .field("intent", &self.intent)
46            .field("handler", &"<closure>")
47            .field("enabled_when", &self.enabled_when.is_some())
48            .finish()
49    }
50}
51
52impl Action {
53    /// Start building an [`Action`] bound to the given intent name.
54    #[allow(clippy::new_ret_no_self)]
55    pub fn new(intent: &'static str) -> ActionBuilder {
56        ActionBuilder {
57            intent,
58            handler: None,
59            enabled_when: None,
60        }
61    }
62
63    /// Resolve the current enabled state. `true` when no predicate is
64    /// set; otherwise reads the signal.
65    pub fn is_enabled(&self) -> bool {
66        self.enabled_when.as_ref().map(|s| s.get()).unwrap_or(true)
67    }
68}
69
70/// Fluent builder for [`Action`]. Use [`ActionBuilder::on_invoke`] for
71/// the common case (handler consumes the intent) or
72/// [`ActionBuilder::on_invoke_with_response`] when the handler needs
73/// to decide whether to propagate.
74pub struct ActionBuilder {
75    intent: &'static str,
76    handler: Option<ActionHandler>,
77    enabled_when: Option<Prop<bool>>,
78}
79
80impl ActionBuilder {
81    /// Reactive enabled-predicate. When the signal holds `false` the
82    /// action is skipped during dispatch.
83    pub fn enabled_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
84        self.enabled_when = Some(signal.into());
85        self
86    }
87
88    /// Register a handler that consumes the intent. The handler's
89    /// return value is ignored; the framework treats every invocation
90    /// as [`IntentResponse::Handled`].
91    pub fn on_invoke(mut self, mut f: impl FnMut(&Intent, &mut EventContext) + 'static) -> Action {
92        self.handler = Some(Box::new(move |intent, ctx| {
93            f(intent, ctx);
94            IntentResponse::Handled
95        }));
96        self.finish()
97    }
98
99    /// Register a handler whose return value decides whether the
100    /// intent propagates to ancestor widgets. Use when a widget wants
101    /// to observe an intent (e.g., update a draft status) while still
102    /// letting an ancestor perform the primary action.
103    pub fn on_invoke_with_response(
104        mut self,
105        f: impl FnMut(&Intent, &mut EventContext) -> IntentResponse + 'static,
106    ) -> Action {
107        self.handler = Some(Box::new(f));
108        self.finish()
109    }
110
111    fn finish(self) -> Action {
112        Action {
113            intent: self.intent,
114            handler: self
115                .handler
116                .expect("ActionBuilder requires on_invoke or on_invoke_with_response"),
117            enabled_when: self.enabled_when,
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::cell::Cell;
126    use std::rc::Rc;
127
128    #[test]
129    fn action_builder_on_invoke_defaults_to_handled() {
130        let fired = Rc::new(Cell::new(false));
131        let fired_flag = fired.clone();
132        let mut action = Action::new("app.save").on_invoke(move |_intent, _ctx| {
133            fired_flag.set(true);
134        });
135
136        let mut ctx = EventContext::new();
137        let intent = Intent::new("app.save");
138        let response = (action.handler)(&intent, &mut ctx);
139        assert_eq!(response, IntentResponse::Handled);
140        assert!(fired.get());
141        assert_eq!(action.intent, "app.save");
142    }
143
144    #[test]
145    fn action_handler_receives_typed_payload() {
146        let seen = Rc::new(Cell::new(0_i64));
147        let seen_flag = seen.clone();
148        let mut action = Action::new("tab.switch").on_invoke(move |intent, _ctx| {
149            if let Some(&n) = intent.payload::<i64>() {
150                seen_flag.set(n);
151            }
152        });
153
154        let mut ctx = EventContext::new();
155        let intent = Intent::with_payload("tab.switch", 5_i64);
156        (action.handler)(&intent, &mut ctx);
157        assert_eq!(seen.get(), 5);
158    }
159
160    #[test]
161    fn action_with_response_can_propagate() {
162        let mut action = Action::new("log.observe")
163            .on_invoke_with_response(|_intent, _ctx| IntentResponse::Propagated);
164        let mut ctx = EventContext::new();
165        let intent = Intent::new("log.observe");
166        let response = (action.handler)(&intent, &mut ctx);
167        assert_eq!(response, IntentResponse::Propagated);
168    }
169
170    #[test]
171    fn enabled_when_defaults_true() {
172        let action = Action::new("edit.delete").on_invoke(|_, _| {});
173        assert!(action.is_enabled());
174    }
175
176    #[test]
177    fn enabled_when_follows_signal() {
178        let enabled = crate::signal::Signal::new(false);
179        let action = Action::new("edit.delete")
180            .enabled_when(enabled.clone())
181            .on_invoke(|_, _| {});
182        assert!(!action.is_enabled());
183        enabled.set(true);
184        assert!(action.is_enabled());
185    }
186}