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}