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 std::rc::Rc;
18
19use crate::intent::{Intent, IntentResponse};
20use crate::signal::Prop;
21use crate::widget::EventContext;
22
23/// Closure signature for an action handler.
24pub type ActionHandler = Box<dyn FnMut(&Intent, &mut EventContext) -> IntentResponse + 'static>;
25
26/// A widget-owned handler bound to a named intent.
27pub struct Action {
28    /// The intent name this action responds to. Matches against
29    /// [`Intent::name`] during dispatch.
30    pub intent: &'static str,
31    /// Invoked when a matching intent is dispatched to this widget.
32    pub handler: ActionHandler,
33    /// Reactive "is this action currently applicable?" predicate.
34    /// `None` means always enabled.
35    ///
36    /// Disabled semantics at dispatch time: the intent propagates past
37    /// this action as if no match existed here, **unless** the firing
38    /// [`Shortcut`](crate::shortcut::Shortcut) has
39    /// `propagate_when_disabled == false`, in which case the intent
40    /// is consumed (dormant) at this level.
41    pub enabled_when: Option<Prop<bool>>,
42    /// Whether the action has anything to act on now, asked by what greys a
43    /// menu row and never by dispatch. See
44    /// [`ActionBuilder::available_when`]. `None` means available.
45    available_when: Option<Availability>,
46    /// Whether the handler passes the intent on now, asked by what greys a
47    /// menu row. See [`ActionBuilder::passes_on_when`]. `None` means it
48    /// never does.
49    passes_on_when: Option<Availability>,
50}
51
52/// The predicate behind [`ActionBuilder::available_when`] and
53/// [`ActionBuilder::passes_on_when`].
54pub(crate) type Availability = Rc<dyn Fn() -> bool>;
55
56impl std::fmt::Debug for Action {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("Action")
59            .field("intent", &self.intent)
60            .field("handler", &"<closure>")
61            .field("enabled_when", &self.enabled_when.is_some())
62            .field("available_when", &self.available_when.is_some())
63            .field("passes_on_when", &self.passes_on_when.is_some())
64            .finish()
65    }
66}
67
68impl Action {
69    /// Start building an [`Action`] bound to the given intent name.
70    #[allow(clippy::new_ret_no_self)]
71    pub fn new(intent: &'static str) -> ActionBuilder {
72        ActionBuilder {
73            intent,
74            handler: None,
75            enabled_when: None,
76            available_when: None,
77            passes_on_when: None,
78        }
79    }
80
81    /// Resolve the current enabled state. `true` when no predicate is
82    /// set; otherwise reads the signal.
83    pub fn is_enabled(&self) -> bool {
84        self.enabled_when.as_ref().map(|s| s.get()).unwrap_or(true)
85    }
86
87    /// Whether a menu row for this action's intent shows it enabled: the
88    /// action is [enabled](Self::is_enabled) and
89    /// [has something to act on](ActionBuilder::available_when) now.
90    pub(crate) fn is_available(&self) -> bool {
91        self.is_enabled()
92            && self
93                .available_when
94                .as_ref()
95                .is_none_or(|available| available())
96    }
97
98    /// Whether a dispatch reaching this action now would go past it: it
99    /// is disabled, or its handler [passes the intent on](ActionBuilder::passes_on_when).
100    /// The first action on a route this answers `false` for is the one a
101    /// sent intent stops at, and the one a menu row asks.
102    pub(crate) fn passes_on(&self) -> bool {
103        !self.is_enabled()
104            || self
105                .passes_on_when
106                .as_ref()
107                .is_some_and(|passes_on| passes_on())
108    }
109
110    /// The [availability predicate](ActionBuilder::available_when), shared.
111    pub(crate) fn availability(&self) -> Option<Availability> {
112        self.available_when.clone()
113    }
114
115    /// The [pass-on predicate](ActionBuilder::passes_on_when), shared.
116    pub(crate) fn pass_on(&self) -> Option<Availability> {
117        self.passes_on_when.clone()
118    }
119}
120
121/// Fluent builder for [`Action`]. Use [`ActionBuilder::on_invoke`] for
122/// the common case (handler consumes the intent) or
123/// [`ActionBuilder::on_invoke_with_response`] when the handler needs
124/// to decide whether to propagate.
125pub struct ActionBuilder {
126    intent: &'static str,
127    handler: Option<ActionHandler>,
128    enabled_when: Option<Prop<bool>>,
129    available_when: Option<Availability>,
130    passes_on_when: Option<Availability>,
131}
132
133impl ActionBuilder {
134    /// Reactive enabled-predicate. When the signal holds `false` the
135    /// action is skipped during dispatch.
136    pub fn enabled_when(mut self, signal: impl Into<Prop<bool>>) -> Self {
137        self.enabled_when = Some(signal.into());
138        self
139    }
140
141    /// Whether the action has anything to act on now, asked each time a
142    /// menu row for its intent decides whether to grey out (and by
143    /// [`BuildContext::command_available`](crate::build_context::BuildContext::command_available)):
144    /// a text field's Copy with nothing selected, its Paste while read-only.
145    /// The row greys out while it answers `false`.
146    ///
147    /// Dispatch never asks it: an intent that reaches the action runs its
148    /// handler whatever `available` answers, and the handler decides, as
149    /// one built with [`on_invoke_with_response`](Self::on_invoke_with_response)
150    /// does. So a shortcut behaves the same with or without it, unlike
151    /// [`enabled_when`](Self::enabled_when), which passes the intent by.
152    /// `available` runs outside every event handler, on every query (once
153    /// per event-loop turn for the native menu bar), and must be cheap.
154    pub fn available_when(mut self, available: impl Fn() -> bool + 'static) -> Self {
155        self.available_when = Some(Rc::new(available));
156        self
157    }
158
159    /// Whether the handler, built with
160    /// [`on_invoke_with_response`](Self::on_invoke_with_response), would
161    /// pass the intent on if it ran now (a read-only field's Cut, an
162    /// editor's Undo with nothing to undo when the tree passes those on).
163    /// It must answer as the handler decides: what greys a menu row asks it
164    /// to find the action a dispatch would stop at, which is the first
165    /// enabled action on the route this answers `false` for, and asks that
166    /// action's [availability](Self::available_when). So a field with
167    /// nothing selected, under a page with its own Copy, greys Copy (Copy
168    /// chosen there reaches the field and stops), and a read-only field
169    /// leaves Cut to whatever its Cut goes on to.
170    ///
171    /// Dispatch never asks it: the handler alone decides. Like `available`,
172    /// it runs outside every event handler, on every query, and must be
173    /// cheap. Without it, the action is taken never to pass anything on.
174    pub fn passes_on_when(mut self, passes_on: impl Fn() -> bool + 'static) -> Self {
175        self.passes_on_when = Some(Rc::new(passes_on));
176        self
177    }
178
179    /// Register a handler that consumes the intent. The handler's
180    /// return value is ignored; the framework treats every invocation
181    /// as [`IntentResponse::Handled`].
182    pub fn on_invoke(mut self, mut f: impl FnMut(&Intent, &mut EventContext) + 'static) -> Action {
183        self.handler = Some(Box::new(move |intent, ctx| {
184            f(intent, ctx);
185            IntentResponse::Handled
186        }));
187        self.finish()
188    }
189
190    /// Register a handler whose return value decides whether the
191    /// intent propagates to ancestor widgets. Use when a widget wants
192    /// to observe an intent (e.g., update a draft status) while still
193    /// letting an ancestor perform the primary action.
194    pub fn on_invoke_with_response(
195        mut self,
196        f: impl FnMut(&Intent, &mut EventContext) -> IntentResponse + 'static,
197    ) -> Action {
198        self.handler = Some(Box::new(f));
199        self.finish()
200    }
201
202    fn finish(self) -> Action {
203        Action {
204            intent: self.intent,
205            handler: self
206                .handler
207                .expect("ActionBuilder requires on_invoke or on_invoke_with_response"),
208            enabled_when: self.enabled_when,
209            available_when: self.available_when,
210            passes_on_when: self.passes_on_when,
211        }
212    }
213}
214
215/// The intents the application tree's actions take, as a window's tree sees
216/// them.
217///
218/// A shortcut whose intent nothing in its window takes is offered to the
219/// application tree when that tree takes it, and its key falls through to the
220/// focused widget otherwise. The window decides while it dispatches the key,
221/// when the application tree cannot be borrowed, so `teksilo-app` shares one
222/// of these between the application tree, which fills it whenever it has run
223/// ([`WidgetTree::publish_application_scope`](crate::WidgetTree::publish_application_scope)),
224/// and every window's tree, which reads it
225/// ([`WidgetTree::set_application_scope`](crate::WidgetTree::set_application_scope)).
226///
227/// It keeps each action's [enabled predicate](ActionBuilder::enabled_when),
228/// not its value, so an action enabled or disabled since the last publish is
229/// answered for as it is now, and its
230/// [availability](ActionBuilder::available_when) and
231/// [pass-on predicate](ActionBuilder::passes_on_when) the same way, for what
232/// greys a menu row. An action that may decline when it runs
233/// ([`on_invoke_with_response`](ActionBuilder::on_invoke_with_response))
234/// without saying when counts as taking the intent.
235#[doc(hidden)]
236#[derive(Clone, Default)]
237pub struct ApplicationScope {
238    actions: Rc<std::cell::RefCell<Vec<ScopedAction>>>,
239}
240
241/// One action of an [`ApplicationScope`]: its intent name, its enabled
242/// predicate, its availability and its pass-on predicate.
243pub(crate) type ScopedAction = (
244    &'static str,
245    Option<Prop<bool>>,
246    Option<Availability>,
247    Option<Availability>,
248);
249
250impl ApplicationScope {
251    /// An empty scope: the application takes nothing.
252    pub fn new() -> Self {
253        Self::default()
254    }
255
256    /// Whether an enabled application action takes intents named `name`:
257    /// what decides where a shortcut's intent goes. Availability does not
258    /// count, as it does not in a dispatch.
259    pub fn takes(&self, name: &str) -> bool {
260        self.actions
261            .borrow()
262            .iter()
263            .any(|(intent, enabled, _, _)| *intent == name && Self::enabled(enabled))
264    }
265
266    /// Whether a menu row for intents named `name` shows enabled for what
267    /// the application takes: the first enabled application action for it
268    /// that does not [pass it on](ActionBuilder::passes_on_when), the one an
269    /// intent sent there stops at,
270    /// [has something to act on](ActionBuilder::available_when).
271    pub(crate) fn offers(&self, name: &str) -> bool {
272        self.actions
273            .borrow()
274            .iter()
275            .find(|action| action.0 == name && Self::stops(action))
276            .is_some_and(Self::available)
277    }
278
279    /// Every intent name the application [offers](Self::offers), in the
280    /// order they were published, each once: what greys the menu rows.
281    pub fn taken(&self) -> Vec<&'static str> {
282        let actions = self.actions.borrow();
283        let mut decided: Vec<(&'static str, bool)> = Vec::new();
284        for action in actions.iter().filter(|action| Self::stops(action)) {
285            if decided.iter().all(|(name, _)| *name != action.0) {
286                decided.push((action.0, Self::available(action)));
287            }
288        }
289        decided
290            .into_iter()
291            .filter_map(|(name, available)| available.then_some(name))
292            .collect()
293    }
294
295    fn enabled(enabled: &Option<Prop<bool>>) -> bool {
296        enabled.as_ref().is_none_or(|enabled| enabled.get())
297    }
298
299    /// Whether an intent reaching `action` stops there: it is enabled and
300    /// does not pass the intent on now.
301    fn stops((_, enabled, _, passes_on): &ScopedAction) -> bool {
302        Self::enabled(enabled) && passes_on.as_ref().is_none_or(|passes_on| !passes_on())
303    }
304
305    fn available((_, _, available, _): &ScopedAction) -> bool {
306        available.as_ref().is_none_or(|available| available())
307    }
308
309    /// Replace what the application takes with `actions`, intent names
310    /// with their enabled predicates, availability and pass-on predicates.
311    pub(crate) fn replace(&self, actions: Vec<ScopedAction>) {
312        *self.actions.borrow_mut() = actions;
313    }
314}
315
316impl std::fmt::Debug for ApplicationScope {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        let actions = self.actions.borrow();
319        f.debug_list()
320            .entries(actions.iter().map(|(intent, _, _, _)| intent))
321            .finish()
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use std::cell::Cell;
329    use std::rc::Rc;
330
331    #[test]
332    fn action_builder_on_invoke_defaults_to_handled() {
333        let fired = Rc::new(Cell::new(false));
334        let fired_flag = fired.clone();
335        let mut action = Action::new("app.save").on_invoke(move |_intent, _ctx| {
336            fired_flag.set(true);
337        });
338
339        let mut ctx = EventContext::new();
340        let intent = Intent::new("app.save");
341        let response = (action.handler)(&intent, &mut ctx);
342        assert_eq!(response, IntentResponse::Handled);
343        assert!(fired.get());
344        assert_eq!(action.intent, "app.save");
345    }
346
347    #[test]
348    fn action_handler_receives_typed_payload() {
349        let seen = Rc::new(Cell::new(0_i64));
350        let seen_flag = seen.clone();
351        let mut action = Action::new("tab.switch").on_invoke(move |intent, _ctx| {
352            if let Some(&n) = intent.payload::<i64>() {
353                seen_flag.set(n);
354            }
355        });
356
357        let mut ctx = EventContext::new();
358        let intent = Intent::with_payload("tab.switch", 5_i64);
359        (action.handler)(&intent, &mut ctx);
360        assert_eq!(seen.get(), 5);
361    }
362
363    #[test]
364    fn action_with_response_can_propagate() {
365        let mut action = Action::new("log.observe")
366            .on_invoke_with_response(|_intent, _ctx| IntentResponse::Propagated);
367        let mut ctx = EventContext::new();
368        let intent = Intent::new("log.observe");
369        let response = (action.handler)(&intent, &mut ctx);
370        assert_eq!(response, IntentResponse::Propagated);
371    }
372
373    #[test]
374    fn enabled_when_defaults_true() {
375        let action = Action::new("edit.delete").on_invoke(|_, _| {});
376        assert!(action.is_enabled());
377    }
378
379    #[test]
380    fn enabled_when_follows_signal() {
381        let enabled = crate::signal::Signal::new(false);
382        let action = Action::new("edit.delete")
383            .enabled_when(enabled.clone())
384            .on_invoke(|_, _| {});
385        assert!(!action.is_enabled());
386        enabled.set(true);
387        assert!(action.is_enabled());
388    }
389}