Skip to main content

fission_core/
effect.rs

1//! Side-effect primitives for async operations.
2//!
3//! Reducers are pure functions -- they must not perform I/O. When a reducer
4//! needs to trigger a host capability, async job, or runtime-control effect, it
5//! pushes an [`EffectEnvelope`] through the [`Effects`](crate::Effects) builder.
6//! The platform executor fulfils the effect outside the deterministic core and
7//! dispatches the `on_ok` / `on_err` callback actions back into the pipeline.
8
9use crate::action::{ActionEnvelope, UpdateTextInput};
10use crate::async_runtime::{
11    JobRef, JobRequestPayload, JobSpec, ResourceExecutionContext, ServiceBindings,
12    ServiceCommandPayload, ServiceSpec, ServiceStartPayload, ServiceStopPayload, ServiceType,
13};
14use crate::capability::CapabilityInvocationPayload;
15use crate::capability::{CapabilityType, OperationCapability};
16use crate::env::RouteLocation;
17use fission_ir::WidgetId;
18use serde::{Deserialize, Serialize};
19
20/// An opaque request identifier assigned to each emitted effect.
21///
22/// The platform executor returns this id when delivering the result so the
23/// runtime can correlate responses.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
25pub struct ReqId(pub u64);
26
27/// An opaque handle to a platform-managed resource (e.g. a large binary blob).
28///
29/// Resources live outside the action pipeline to avoid copying large payloads.
30/// Use [`RuntimeEffect::ReleaseResource`] to free them when no longer needed.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub struct ResourceId(pub u64);
33
34/// Axis selection for runtime scroll positioning.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum ScrollAxis {
37    /// Adjust vertical scroll offsets.
38    Vertical,
39    /// Adjust horizontal scroll offsets.
40    Horizontal,
41    /// Adjust any matching scroll axis.
42    Both,
43}
44
45/// Desired placement of a target inside a scroll viewport.
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
47pub enum ScrollAlignment {
48    /// Align the target's leading edge with the viewport's leading edge.
49    Start,
50    /// Center the target in the viewport.
51    Center,
52    /// Align the target's trailing edge with the viewport's trailing edge.
53    End,
54    /// Use the smallest scroll delta that makes the target visible.
55    Nearest,
56    /// Place the target at a fractional position in the viewport.
57    ///
58    /// `0.0` behaves like [`ScrollAlignment::Start`], `0.5` centers the target,
59    /// and `1.0` behaves like [`ScrollAlignment::End`].
60    Fraction(f32),
61}
62
63/// Runtime behavior for a scroll request.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub enum ScrollBehavior {
66    /// Apply the computed offset immediately.
67    Instant,
68    /// Reserve a smooth-scroll request. Current shells resolve this immediately.
69    Smooth,
70}
71
72/// Request a post-layout scroll adjustment that reveals a target widget.
73///
74/// Reducers can emit this as a runtime effect when application state changes.
75/// The runtime resolves it after the next layout pass, when target and container
76/// rectangles are available, then mutates the scroll state and schedules another
77/// frame so paint, hit testing, and semantics see the new offset.
78///
79/// # Example
80///
81/// ```rust,ignore
82/// ctx.effects.scroll_into_view(ScrollIntoViewRequest {
83///     container: Some(WidgetId::explicit("document.canvas.scroll")),
84///     target: WidgetId::explicit("document.page.3"),
85///     axis: ScrollAxis::Vertical,
86///     alignment: ScrollAlignment::Start,
87///     padding: [24.0, 24.0, 24.0, 24.0],
88///     behavior: ScrollBehavior::Instant,
89///     if_needed: false,
90/// });
91/// ```
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct ScrollIntoViewRequest {
94    /// Explicit scroll container, or `None` to use the nearest matching scroll ancestor.
95    pub container: Option<WidgetId>,
96    /// Descendant widget that should become visible.
97    pub target: WidgetId,
98    /// Axis to scroll.
99    pub axis: ScrollAxis,
100    /// Alignment to use when computing the new offset.
101    pub alignment: ScrollAlignment,
102    /// Reveal margin as `[left, right, top, bottom]`.
103    pub padding: [f32; 4],
104    /// Whether to jump immediately or request smooth behavior.
105    pub behavior: ScrollBehavior,
106    /// If `true`, leave the offset unchanged when the target is already fully visible.
107    pub if_needed: bool,
108}
109
110/// Runtime-managed effects that are not host capabilities.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub enum RuntimeEffect {
113    /// Cancel a previously issued effect by its request id.
114    Cancel { req_id: u64 },
115    /// Release a platform-managed resource.
116    ReleaseResource { resource_id: u64 },
117    /// Reveal a widget inside a scroll container after the next layout pass.
118    ScrollIntoView(ScrollIntoViewRequest),
119}
120
121/// A side-effect emitted by a reducer.
122///
123/// `Runtime` variants are handled by the runtime itself.
124/// All host-facing work is expressed as typed capabilities, jobs, or services.
125#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
126pub enum Effect {
127    /// A runtime-managed effect (cancellation, resource release).
128    Runtime(RuntimeEffect),
129    /// A typed one-shot host capability invocation.
130    Capability(CapabilityInvocationPayload),
131    /// A typed one-shot async job.
132    Job(JobRequestPayload),
133    /// Start a long-lived service for a logical slot.
134    StartService(ServiceStartPayload),
135    /// Send a command to an already-running service slot.
136    ServiceCommand(ServiceCommandPayload),
137    /// Stop a running service slot.
138    StopService(ServiceStopPayload),
139}
140
141/// A queued effect with optional success/failure callbacks.
142///
143/// The platform executor processes the [`Effect`], then dispatches either
144/// `on_ok` or `on_err` back into the runtime. The `req_id` is assigned
145/// automatically by the runtime and is globally unique within a session.
146///
147/// # Example
148///
149/// ```rust,ignore
150/// // Built via the Effects builder -- you rarely construct this manually.
151/// ctx.effects.capability(MY_CAPABILITY, request)
152///     .on_ok(ok_envelope)
153///     .on_err(err_envelope);
154/// ```
155#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
156pub struct EffectEnvelope {
157    /// Unique request identifier (assigned by the runtime).
158    pub req_id: u64,
159    /// The effect to execute.
160    pub effect: Effect,
161    /// Action dispatched when the effect completes successfully.
162    pub on_ok: Option<ActionEnvelope>,
163    /// Action dispatched when the effect fails.
164    pub on_err: Option<ActionEnvelope>,
165    /// Additional bindings used by service lifecycle operations.
166    pub service_bindings: Option<ServiceBindings>,
167    /// Optional resource ownership metadata used to suppress stale completions.
168    pub resource: Option<ResourceExecutionContext>,
169}
170
171/// Extra input data passed alongside an action dispatch.
172///
173/// When the platform delivers an effect result or a drag-and-drop event, it
174/// attaches an `ActionInput` so the reducer can access the associated data
175/// without encoding it in the action payload.
176///
177/// # Example
178///
179/// ```rust,ignore
180/// fn on_file_loaded(
181///     state: &mut MyState,
182///     _action: FileLoaded,
183///     ctx: &mut ReducerContext<MyState>,
184/// ) {
185///     if let Some(bytes) = ctx.input.as_bytes() {
186///         state.file_contents = String::from_utf8_lossy(bytes).into_owned();
187///     }
188/// }
189/// ```
190#[derive(Clone, Debug, PartialEq)]
191pub enum ActionInput {
192    /// No extra input.
193    None,
194    /// The host shell delivered a route/navigation change.
195    RouteChanged { location: RouteLocation },
196    /// A typed async job completed successfully.
197    JobOk {
198        job_name: String,
199        req_id: u64,
200        payload: Vec<u8>,
201    },
202    /// A typed async job failed.
203    JobErr {
204        job_name: String,
205        req_id: u64,
206        payload: Option<Vec<u8>>,
207        message: Option<String>,
208    },
209    /// A service slot started successfully.
210    ServiceStarted {
211        service_name: String,
212        slot_key: String,
213        instance_id: u64,
214    },
215    /// A service slot failed to start.
216    ServiceStartFailed {
217        service_name: String,
218        slot_key: String,
219        payload: Option<Vec<u8>>,
220        message: Option<String>,
221    },
222    /// A running service emitted an event.
223    ServiceEvent {
224        service_name: String,
225        slot_key: String,
226        instance_id: u64,
227        payload: Vec<u8>,
228    },
229    /// A running service stopped.
230    ServiceStopped {
231        service_name: String,
232        slot_key: String,
233        instance_id: u64,
234    },
235    /// A service command completed successfully.
236    ServiceCommandOk {
237        service_name: String,
238        slot_key: String,
239        instance_id: u64,
240        req_id: u64,
241        payload: Option<Vec<u8>>,
242    },
243    /// A service command failed.
244    ServiceCommandErr {
245        service_name: String,
246        slot_key: String,
247        instance_id: u64,
248        req_id: u64,
249        payload: Option<Vec<u8>>,
250        message: Option<String>,
251    },
252    /// A typed capability operation succeeded.
253    CapabilityOk {
254        capability: String,
255        req_id: u64,
256        payload: Vec<u8>,
257    },
258    /// A typed capability operation failed.
259    CapabilityErr {
260        capability: String,
261        req_id: u64,
262        payload: Option<Vec<u8>>,
263        message: Option<String>,
264    },
265    /// A timer resource fired.
266    TimerTick { payload: Vec<u8> },
267    /// Pointer coordinates and deltas (used by drag/gesture handlers).
268    Pointer {
269        x: f32,
270        y: f32,
271        delta_x: f32,
272        delta_y: f32,
273    },
274    /// Runtime details accompanying a text-input action.
275    ///
276    /// The action envelope retains the application-defined payload, while this
277    /// input carries the edited value and selection independently.
278    TextChanged(UpdateTextInput),
279    /// External file drop (e.g. from the OS file manager).
280    Drop {
281        paths: Vec<String>,
282        x: f32,
283        y: f32,
284        /// Modifier bitmask active during the drop (Shift=1, Alt=2,
285        /// Ctrl=4, Super=8).
286        modifiers: u8,
287    },
288    /// Internal drag-and-drop with an opaque byte payload.
289    InternalDrop {
290        payload: Vec<u8>,
291        x: f32,
292        y: f32,
293        /// Modifier bitmask active during the drop (Shift=1, Alt=2,
294        /// Ctrl=4, Super=8).
295        modifiers: u8,
296    },
297    /// The action was dispatched from a subtree with a raw action scope.
298    ScopedRaw {
299        scope_id: u128,
300        target: WidgetId,
301        input: Box<ActionInput>,
302    },
303}
304
305impl ActionInput {
306    pub fn scoped_raw(scope_id: u128, target: WidgetId, input: ActionInput) -> Self {
307        Self::ScopedRaw {
308            scope_id,
309            target: target.into(),
310            input: Box::new(input),
311        }
312    }
313
314    pub fn action_scope_id(&self) -> Option<u128> {
315        match self {
316            ActionInput::ScopedRaw { scope_id, .. } => Some(*scope_id),
317            _ => None,
318        }
319    }
320
321    pub fn scoped_target(&self) -> Option<WidgetId> {
322        match self {
323            ActionInput::ScopedRaw { target, .. } => Some(*target),
324            _ => None,
325        }
326    }
327
328    pub fn unscoped(&self) -> &ActionInput {
329        match self {
330            ActionInput::ScopedRaw { input, .. } => input.unscoped(),
331            _ => self,
332        }
333    }
334
335    pub fn as_bytes(&self) -> Option<&[u8]> {
336        match self.unscoped() {
337            ActionInput::JobOk { payload, .. } => Some(payload),
338            ActionInput::CapabilityOk { payload, .. } => Some(payload),
339            ActionInput::TimerTick { payload } => Some(payload),
340            ActionInput::InternalDrop { payload, .. } => Some(payload),
341            _ => None,
342        }
343    }
344
345    pub fn as_pointer(&self) -> Option<(f32, f32, f32, f32)> {
346        match self.unscoped() {
347            ActionInput::Pointer {
348                x,
349                y,
350                delta_x,
351                delta_y,
352            } => Some((*x, *y, *delta_x, *delta_y)),
353            ActionInput::Drop { x, y, .. } => Some((*x, *y, 0.0, 0.0)),
354            ActionInput::InternalDrop { x, y, .. } => Some((*x, *y, 0.0, 0.0)),
355            _ => None,
356        }
357    }
358
359    /// Returns the edit accompanying a text-input action.
360    ///
361    /// Scoped actions are unwrapped automatically, matching the other typed
362    /// input accessors.
363    pub fn text_change(&self) -> Option<&UpdateTextInput> {
364        match self.unscoped() {
365            ActionInput::TextChanged(change) => Some(change),
366            _ => None,
367        }
368    }
369
370    pub fn as_drop_paths(&self) -> Option<&[String]> {
371        match self.unscoped() {
372            ActionInput::Drop { paths, .. } => Some(paths),
373            _ => None,
374        }
375    }
376
377    pub fn as_internal_drop(&self) -> Option<&[u8]> {
378        match self.unscoped() {
379            ActionInput::InternalDrop { payload, .. } => Some(payload),
380            _ => None,
381        }
382    }
383
384    /// Modifier bitmask active during a drop action.
385    ///
386    /// This lets app reducers choose copy/move/link semantics without binding
387    /// that product rule into the drag runtime itself.
388    pub fn as_drop_modifiers(&self) -> Option<u8> {
389        match self.unscoped() {
390            ActionInput::Drop { modifiers, .. } => Some(*modifiers),
391            ActionInput::InternalDrop { modifiers, .. } => Some(*modifiers),
392            _ => None,
393        }
394    }
395
396    pub fn job_ok<J: JobSpec>(&self, job: JobRef<J>) -> Option<J::Ok> {
397        match self.unscoped() {
398            ActionInput::JobOk {
399                job_name, payload, ..
400            } if job_name == job.name => serde_json::from_slice(payload).ok(),
401            _ => None,
402        }
403    }
404
405    pub fn job_err<J: JobSpec>(&self, job: JobRef<J>) -> Option<J::Err> {
406        match self.unscoped() {
407            ActionInput::JobErr {
408                job_name,
409                payload: Some(payload),
410                ..
411            } if job_name == job.name => serde_json::from_slice(payload).ok(),
412            _ => None,
413        }
414    }
415
416    pub fn job_error_message<J: JobSpec>(&self, job: JobRef<J>) -> Option<&str> {
417        match self.unscoped() {
418            ActionInput::JobErr {
419                job_name,
420                message: Some(message),
421                ..
422            } if job_name == job.name => Some(message.as_str()),
423            _ => None,
424        }
425    }
426
427    pub fn capability_ok<C: OperationCapability>(
428        &self,
429        capability: CapabilityType<C>,
430    ) -> Option<C::Ok> {
431        match self.unscoped() {
432            ActionInput::CapabilityOk {
433                capability: actual,
434                payload,
435                ..
436            } if actual == capability.name => serde_json::from_slice(payload).ok(),
437            _ => None,
438        }
439    }
440
441    pub fn capability_error<C: OperationCapability>(
442        &self,
443        capability: CapabilityType<C>,
444    ) -> Option<C::Err> {
445        match self.unscoped() {
446            ActionInput::CapabilityErr {
447                capability: actual,
448                payload: Some(payload),
449                ..
450            } if actual == capability.name => serde_json::from_slice(payload).ok(),
451            _ => None,
452        }
453    }
454
455    pub fn capability_error_message<C: OperationCapability>(
456        &self,
457        capability: CapabilityType<C>,
458    ) -> Option<&str> {
459        match self.unscoped() {
460            ActionInput::CapabilityErr {
461                capability: actual,
462                message: Some(message),
463                ..
464            } if actual == capability.name => Some(message),
465            _ => None,
466        }
467    }
468
469    pub fn service_event<S: ServiceSpec>(&self, service: ServiceType<S>) -> Option<S::Event> {
470        match self.unscoped() {
471            ActionInput::ServiceEvent {
472                service_name,
473                payload,
474                ..
475            } if service_name == service.name => serde_json::from_slice(payload).ok(),
476            _ => None,
477        }
478    }
479
480    pub fn service_start_err<S: ServiceSpec>(
481        &self,
482        service: ServiceType<S>,
483    ) -> Option<S::StartErr> {
484        match self.unscoped() {
485            ActionInput::ServiceStartFailed {
486                service_name,
487                payload: Some(payload),
488                ..
489            } if service_name == service.name => serde_json::from_slice(payload).ok(),
490            _ => None,
491        }
492    }
493
494    pub fn service_start_error_message<S: ServiceSpec>(
495        &self,
496        service: ServiceType<S>,
497    ) -> Option<&str> {
498        match self.unscoped() {
499            ActionInput::ServiceStartFailed {
500                service_name,
501                message: Some(message),
502                ..
503            } if service_name == service.name => Some(message.as_str()),
504            _ => None,
505        }
506    }
507
508    pub fn service_command_ok<S: ServiceSpec>(
509        &self,
510        service: ServiceType<S>,
511    ) -> Option<S::CommandOk> {
512        match self.unscoped() {
513            ActionInput::ServiceCommandOk {
514                service_name,
515                payload: Some(payload),
516                ..
517            } if service_name == service.name => serde_json::from_slice(payload).ok(),
518            _ => None,
519        }
520    }
521
522    pub fn service_command_err<S: ServiceSpec>(
523        &self,
524        service: ServiceType<S>,
525    ) -> Option<S::CommandErr> {
526        match self.unscoped() {
527            ActionInput::ServiceCommandErr {
528                service_name,
529                payload: Some(payload),
530                ..
531            } if service_name == service.name => serde_json::from_slice(payload).ok(),
532            _ => None,
533        }
534    }
535
536    pub fn timer_tick<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
537        match self.unscoped() {
538            ActionInput::TimerTick { payload } => serde_json::from_slice(payload).ok(),
539            _ => None,
540        }
541    }
542
543    pub fn service_slot_key(&self) -> Option<&str> {
544        match self.unscoped() {
545            ActionInput::ServiceStarted { slot_key, .. }
546            | ActionInput::ServiceStartFailed { slot_key, .. }
547            | ActionInput::ServiceEvent { slot_key, .. }
548            | ActionInput::ServiceStopped { slot_key, .. }
549            | ActionInput::ServiceCommandOk { slot_key, .. }
550            | ActionInput::ServiceCommandErr { slot_key, .. } => Some(slot_key.as_str()),
551            _ => None,
552        }
553    }
554
555    pub fn service_instance_id(&self) -> Option<u64> {
556        match self.unscoped() {
557            ActionInput::ServiceStarted { instance_id, .. }
558            | ActionInput::ServiceEvent { instance_id, .. }
559            | ActionInput::ServiceStopped { instance_id, .. }
560            | ActionInput::ServiceCommandOk { instance_id, .. }
561            | ActionInput::ServiceCommandErr { instance_id, .. } => Some(*instance_id),
562            _ => None,
563        }
564    }
565}