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;
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    /// External file drop (e.g. from the OS file manager).
275    Drop { paths: Vec<String>, x: f32, y: f32 },
276    /// Internal drag-and-drop with an opaque byte payload.
277    InternalDrop { payload: Vec<u8>, x: f32, y: f32 },
278    /// The action was dispatched from a subtree with a raw action scope.
279    ScopedRaw {
280        scope_id: u128,
281        target: WidgetId,
282        input: Box<ActionInput>,
283    },
284}
285
286impl ActionInput {
287    pub fn scoped_raw(scope_id: u128, target: WidgetId, input: ActionInput) -> Self {
288        Self::ScopedRaw {
289            scope_id,
290            target: target.into(),
291            input: Box::new(input),
292        }
293    }
294
295    pub fn action_scope_id(&self) -> Option<u128> {
296        match self {
297            ActionInput::ScopedRaw { scope_id, .. } => Some(*scope_id),
298            _ => None,
299        }
300    }
301
302    pub fn scoped_target(&self) -> Option<WidgetId> {
303        match self {
304            ActionInput::ScopedRaw { target, .. } => Some(*target),
305            _ => None,
306        }
307    }
308
309    pub fn unscoped(&self) -> &ActionInput {
310        match self {
311            ActionInput::ScopedRaw { input, .. } => input.unscoped(),
312            _ => self,
313        }
314    }
315
316    pub fn as_bytes(&self) -> Option<&[u8]> {
317        match self.unscoped() {
318            ActionInput::JobOk { payload, .. } => Some(payload),
319            ActionInput::CapabilityOk { payload, .. } => Some(payload),
320            ActionInput::TimerTick { payload } => Some(payload),
321            ActionInput::InternalDrop { payload, .. } => Some(payload),
322            _ => None,
323        }
324    }
325
326    pub fn as_pointer(&self) -> Option<(f32, f32, f32, f32)> {
327        match self.unscoped() {
328            ActionInput::Pointer {
329                x,
330                y,
331                delta_x,
332                delta_y,
333            } => Some((*x, *y, *delta_x, *delta_y)),
334            ActionInput::Drop { x, y, .. } => Some((*x, *y, 0.0, 0.0)),
335            ActionInput::InternalDrop { x, y, .. } => Some((*x, *y, 0.0, 0.0)),
336            _ => None,
337        }
338    }
339
340    pub fn as_drop_paths(&self) -> Option<&[String]> {
341        match self.unscoped() {
342            ActionInput::Drop { paths, .. } => Some(paths),
343            _ => None,
344        }
345    }
346
347    pub fn as_internal_drop(&self) -> Option<&[u8]> {
348        match self.unscoped() {
349            ActionInput::InternalDrop { payload, .. } => Some(payload),
350            _ => None,
351        }
352    }
353
354    pub fn job_ok<J: JobSpec>(&self, job: JobRef<J>) -> Option<J::Ok> {
355        match self.unscoped() {
356            ActionInput::JobOk {
357                job_name, payload, ..
358            } if job_name == job.name => serde_json::from_slice(payload).ok(),
359            _ => None,
360        }
361    }
362
363    pub fn job_err<J: JobSpec>(&self, job: JobRef<J>) -> Option<J::Err> {
364        match self.unscoped() {
365            ActionInput::JobErr {
366                job_name,
367                payload: Some(payload),
368                ..
369            } if job_name == job.name => serde_json::from_slice(payload).ok(),
370            _ => None,
371        }
372    }
373
374    pub fn job_error_message<J: JobSpec>(&self, job: JobRef<J>) -> Option<&str> {
375        match self.unscoped() {
376            ActionInput::JobErr {
377                job_name,
378                message: Some(message),
379                ..
380            } if job_name == job.name => Some(message.as_str()),
381            _ => None,
382        }
383    }
384
385    pub fn capability_ok<C: OperationCapability>(
386        &self,
387        capability: CapabilityType<C>,
388    ) -> Option<C::Ok> {
389        match self.unscoped() {
390            ActionInput::CapabilityOk {
391                capability: actual,
392                payload,
393                ..
394            } if actual == capability.name => serde_json::from_slice(payload).ok(),
395            _ => None,
396        }
397    }
398
399    pub fn capability_error<C: OperationCapability>(
400        &self,
401        capability: CapabilityType<C>,
402    ) -> Option<C::Err> {
403        match self.unscoped() {
404            ActionInput::CapabilityErr {
405                capability: actual,
406                payload: Some(payload),
407                ..
408            } if actual == capability.name => serde_json::from_slice(payload).ok(),
409            _ => None,
410        }
411    }
412
413    pub fn capability_error_message<C: OperationCapability>(
414        &self,
415        capability: CapabilityType<C>,
416    ) -> Option<&str> {
417        match self.unscoped() {
418            ActionInput::CapabilityErr {
419                capability: actual,
420                message: Some(message),
421                ..
422            } if actual == capability.name => Some(message),
423            _ => None,
424        }
425    }
426
427    pub fn service_event<S: ServiceSpec>(&self, service: ServiceType<S>) -> Option<S::Event> {
428        match self.unscoped() {
429            ActionInput::ServiceEvent {
430                service_name,
431                payload,
432                ..
433            } if service_name == service.name => serde_json::from_slice(payload).ok(),
434            _ => None,
435        }
436    }
437
438    pub fn service_start_err<S: ServiceSpec>(
439        &self,
440        service: ServiceType<S>,
441    ) -> Option<S::StartErr> {
442        match self.unscoped() {
443            ActionInput::ServiceStartFailed {
444                service_name,
445                payload: Some(payload),
446                ..
447            } if service_name == service.name => serde_json::from_slice(payload).ok(),
448            _ => None,
449        }
450    }
451
452    pub fn service_start_error_message<S: ServiceSpec>(
453        &self,
454        service: ServiceType<S>,
455    ) -> Option<&str> {
456        match self.unscoped() {
457            ActionInput::ServiceStartFailed {
458                service_name,
459                message: Some(message),
460                ..
461            } if service_name == service.name => Some(message.as_str()),
462            _ => None,
463        }
464    }
465
466    pub fn service_command_ok<S: ServiceSpec>(
467        &self,
468        service: ServiceType<S>,
469    ) -> Option<S::CommandOk> {
470        match self.unscoped() {
471            ActionInput::ServiceCommandOk {
472                service_name,
473                payload: Some(payload),
474                ..
475            } if service_name == service.name => serde_json::from_slice(payload).ok(),
476            _ => None,
477        }
478    }
479
480    pub fn service_command_err<S: ServiceSpec>(
481        &self,
482        service: ServiceType<S>,
483    ) -> Option<S::CommandErr> {
484        match self.unscoped() {
485            ActionInput::ServiceCommandErr {
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 timer_tick<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
495        match self.unscoped() {
496            ActionInput::TimerTick { payload } => serde_json::from_slice(payload).ok(),
497            _ => None,
498        }
499    }
500
501    pub fn service_slot_key(&self) -> Option<&str> {
502        match self.unscoped() {
503            ActionInput::ServiceStarted { slot_key, .. }
504            | ActionInput::ServiceStartFailed { slot_key, .. }
505            | ActionInput::ServiceEvent { slot_key, .. }
506            | ActionInput::ServiceStopped { slot_key, .. }
507            | ActionInput::ServiceCommandOk { slot_key, .. }
508            | ActionInput::ServiceCommandErr { slot_key, .. } => Some(slot_key.as_str()),
509            _ => None,
510        }
511    }
512
513    pub fn service_instance_id(&self) -> Option<u64> {
514        match self.unscoped() {
515            ActionInput::ServiceStarted { instance_id, .. }
516            | ActionInput::ServiceEvent { instance_id, .. }
517            | ActionInput::ServiceStopped { instance_id, .. }
518            | ActionInput::ServiceCommandOk { instance_id, .. }
519            | ActionInput::ServiceCommandErr { instance_id, .. } => Some(*instance_id),
520            _ => None,
521        }
522    }
523}