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