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 crate::navigation::NavigationCommand;
18use fission_ir::WidgetId;
19use serde::{Deserialize, Serialize};
20
21/// An opaque request identifier assigned to each emitted effect.
22///
23/// The platform executor returns this id when delivering the result so the
24/// runtime can correlate responses.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26pub struct ReqId(pub u64);
27
28/// An opaque handle to a platform-managed resource (e.g. a large binary blob).
29///
30/// Resources live outside the action pipeline to avoid copying large payloads.
31/// Use [`RuntimeEffect::ReleaseResource`] to free them when no longer needed.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub struct ResourceId(pub u64);
34
35/// Axis selection for runtime scroll positioning.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37pub enum ScrollAxis {
38    /// Adjust vertical scroll offsets.
39    Vertical,
40    /// Adjust horizontal scroll offsets.
41    Horizontal,
42    /// Adjust any matching scroll axis.
43    Both,
44}
45
46/// Desired placement of a target inside a scroll viewport.
47#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
48pub enum ScrollAlignment {
49    /// Align the target's leading edge with the viewport's leading edge.
50    Start,
51    /// Center the target in the viewport.
52    Center,
53    /// Align the target's trailing edge with the viewport's trailing edge.
54    End,
55    /// Use the smallest scroll delta that makes the target visible.
56    Nearest,
57    /// Place the target at a fractional position in the viewport.
58    ///
59    /// `0.0` behaves like [`ScrollAlignment::Start`], `0.5` centers the target,
60    /// and `1.0` behaves like [`ScrollAlignment::End`].
61    Fraction(f32),
62}
63
64/// Runtime behavior for a scroll request.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub enum ScrollBehavior {
67    /// Apply the computed offset immediately.
68    Instant,
69    /// Reserve a smooth-scroll request. Current shells resolve this immediately.
70    Smooth,
71}
72
73/// Request a post-layout scroll adjustment that reveals a target widget.
74///
75/// Reducers can emit this as a runtime effect when application state changes.
76/// The runtime resolves it after the next layout pass, when target and container
77/// rectangles are available, then mutates the scroll state and schedules another
78/// frame so paint, hit testing, and semantics see the new offset.
79///
80/// # Example
81///
82/// ```rust,ignore
83/// ctx.effects.scroll_into_view(ScrollIntoViewRequest {
84///     container: Some(WidgetId::explicit("document.canvas.scroll")),
85///     target: WidgetId::explicit("document.page.3"),
86///     axis: ScrollAxis::Vertical,
87///     alignment: ScrollAlignment::Start,
88///     padding: [24.0, 24.0, 24.0, 24.0],
89///     behavior: ScrollBehavior::Instant,
90///     if_needed: false,
91/// });
92/// ```
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct ScrollIntoViewRequest {
95    /// Explicit scroll container, or `None` to use the nearest matching scroll ancestor.
96    pub container: Option<WidgetId>,
97    /// Descendant widget that should become visible.
98    pub target: WidgetId,
99    /// Axis to scroll.
100    pub axis: ScrollAxis,
101    /// Alignment to use when computing the new offset.
102    pub alignment: ScrollAlignment,
103    /// Reveal margin as `[left, right, top, bottom]`.
104    pub padding: [f32; 4],
105    /// Whether to jump immediately or request smooth behavior.
106    pub behavior: ScrollBehavior,
107    /// If `true`, leave the offset unchanged when the target is already fully visible.
108    pub if_needed: bool,
109}
110
111/// Runtime-managed effects that are not host capabilities.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub enum RuntimeEffect {
114    /// Cancel a previously issued effect by its request id.
115    Cancel { req_id: u64 },
116    /// Release a platform-managed resource.
117    ReleaseResource { resource_id: u64 },
118    /// Reveal a widget inside a scroll container after the next layout pass.
119    ScrollIntoView(ScrollIntoViewRequest),
120    /// Ask the active shell to update its navigation model.
121    Navigate(NavigationCommand),
122    /// Update a coordinated read-only text selection after the tree is lowered.
123    SelectionRegion {
124        region_id: WidgetId,
125        command: crate::SelectionRegionCommand,
126    },
127    /// Update a retained editable session after the next lowered tree is available.
128    TextEditing {
129        input_id: WidgetId,
130        command: crate::TextEditingCommand,
131    },
132    /// Reveal an editable caret or range after paragraph layout resolves.
133    TextScroll {
134        input_id: WidgetId,
135        command: crate::TextScrollCommand,
136    },
137    /// Validate every editable field belonging to a logical form.
138    TextFormValidation { form_id: String },
139}
140
141/// A side-effect emitted by a reducer.
142///
143/// `Runtime` variants are handled by the runtime itself.
144/// All host-facing work is expressed as typed capabilities, jobs, or services.
145#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
146pub enum Effect {
147    /// A runtime-managed effect (cancellation, resource release).
148    Runtime(RuntimeEffect),
149    /// A typed one-shot host capability invocation.
150    Capability(CapabilityInvocationPayload),
151    /// A typed one-shot async job.
152    Job(JobRequestPayload),
153    /// Start a long-lived service for a logical slot.
154    StartService(ServiceStartPayload),
155    /// Send a command to an already-running service slot.
156    ServiceCommand(ServiceCommandPayload),
157    /// Stop a running service slot.
158    StopService(ServiceStopPayload),
159}
160
161/// A queued effect with optional success/failure callbacks.
162///
163/// The platform executor processes the [`Effect`], then dispatches either
164/// `on_ok` or `on_err` back into the runtime. The `req_id` is assigned
165/// automatically by the runtime and is globally unique within a session.
166///
167/// # Example
168///
169/// ```rust,ignore
170/// // Built via the Effects builder -- you rarely construct this manually.
171/// ctx.effects.capability(MY_CAPABILITY, request)
172///     .on_ok(ok_envelope)
173///     .on_err(err_envelope);
174/// ```
175#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
176pub struct EffectEnvelope {
177    /// Unique request identifier (assigned by the runtime).
178    pub req_id: u64,
179    /// The effect to execute.
180    pub effect: Effect,
181    /// Action dispatched when the effect completes successfully.
182    pub on_ok: Option<ActionEnvelope>,
183    /// Action dispatched when the effect fails.
184    pub on_err: Option<ActionEnvelope>,
185    /// Additional bindings used by service lifecycle operations.
186    pub service_bindings: Option<ServiceBindings>,
187    /// Optional resource ownership metadata used to suppress stale completions.
188    pub resource: Option<ResourceExecutionContext>,
189}
190
191/// Extra input data passed alongside an action dispatch.
192///
193/// When the platform delivers an effect result or a drag-and-drop event, it
194/// attaches an `ActionInput` so the reducer can access the associated data
195/// without encoding it in the action payload.
196///
197/// # Example
198///
199/// ```rust,ignore
200/// fn on_file_loaded(
201///     state: &mut MyState,
202///     _action: FileLoaded,
203///     ctx: &mut ReducerContext<MyState>,
204/// ) {
205///     if let Some(bytes) = ctx.input.as_bytes() {
206///         state.file_contents = String::from_utf8_lossy(bytes).into_owned();
207///     }
208/// }
209/// ```
210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211pub enum ActionInput {
212    /// No extra input.
213    None,
214    /// The host shell delivered a route/navigation change.
215    RouteChanged { location: RouteLocation },
216    /// A typed async job completed successfully.
217    JobOk {
218        job_name: String,
219        req_id: u64,
220        payload: Vec<u8>,
221    },
222    /// A typed async job failed.
223    JobErr {
224        job_name: String,
225        req_id: u64,
226        payload: Option<Vec<u8>>,
227        message: Option<String>,
228    },
229    /// A service slot started successfully.
230    ServiceStarted {
231        service_name: String,
232        slot_key: String,
233        instance_id: u64,
234    },
235    /// A service slot failed to start.
236    ServiceStartFailed {
237        service_name: String,
238        slot_key: String,
239        payload: Option<Vec<u8>>,
240        message: Option<String>,
241    },
242    /// A running service emitted an event.
243    ServiceEvent {
244        service_name: String,
245        slot_key: String,
246        instance_id: u64,
247        payload: Vec<u8>,
248    },
249    /// A running service stopped.
250    ServiceStopped {
251        service_name: String,
252        slot_key: String,
253        instance_id: u64,
254    },
255    /// A service command completed successfully.
256    ServiceCommandOk {
257        service_name: String,
258        slot_key: String,
259        instance_id: u64,
260        req_id: u64,
261        payload: Option<Vec<u8>>,
262    },
263    /// A service command failed.
264    ServiceCommandErr {
265        service_name: String,
266        slot_key: String,
267        instance_id: u64,
268        req_id: u64,
269        payload: Option<Vec<u8>>,
270        message: Option<String>,
271    },
272    /// A typed capability operation succeeded.
273    CapabilityOk {
274        capability: String,
275        req_id: u64,
276        payload: Vec<u8>,
277    },
278    /// A typed capability operation failed.
279    CapabilityErr {
280        capability: String,
281        req_id: u64,
282        payload: Option<Vec<u8>>,
283        message: Option<String>,
284    },
285    /// A timer resource fired.
286    TimerTick { payload: Vec<u8> },
287    /// Pointer coordinates and deltas (used by drag/gesture handlers).
288    Pointer {
289        x: f32,
290        y: f32,
291        delta_x: f32,
292        delta_y: f32,
293    },
294    /// Runtime details accompanying a text-input action.
295    ///
296    /// The action envelope retains the application-defined payload, while this
297    /// input carries the edited value and selection independently.
298    TextChanged(UpdateTextInput),
299    /// Runtime details accompanying a selection/caret-only action.
300    TextSelectionChanged(crate::action::UpdateTextSelection),
301    /// Runtime details accompanying an interactive viewport action.
302    ViewportInteraction(crate::input::viewport::ViewportInteraction),
303    /// Runtime details accompanying an InfiniteCanvas action.
304    CanvasInteraction(crate::input::canvas::CanvasInteraction),
305    /// External file drop (e.g. from the OS file manager).
306    Drop {
307        paths: Vec<String>,
308        x: f32,
309        y: f32,
310        /// Modifier bitmask active during the drop (Shift=1, Alt=2,
311        /// Ctrl=4, Super=8).
312        modifiers: u8,
313    },
314    /// Internal drag-and-drop with an opaque byte payload.
315    InternalDrop {
316        payload: Vec<u8>,
317        x: f32,
318        y: f32,
319        /// Modifier bitmask active during the drop (Shift=1, Alt=2,
320        /// Ctrl=4, Super=8).
321        modifiers: u8,
322    },
323    /// The action was dispatched from a subtree with a raw action scope.
324    ScopedRaw {
325        scope_id: u128,
326        target: WidgetId,
327        input: Box<ActionInput>,
328    },
329}
330
331impl ActionInput {
332    /// Encodes this runtime input into an opaque representation suitable for
333    /// storage or transport.
334    pub fn encode_opaque(&self) -> Result<Vec<u8>, ActionInputCodecError> {
335        serde_json::to_vec(self).map_err(ActionInputCodecError)
336    }
337
338    /// Decodes an input previously produced by [`Self::encode_opaque`].
339    pub fn decode_opaque(bytes: &[u8]) -> Result<Self, ActionInputCodecError> {
340        serde_json::from_slice(bytes).map_err(ActionInputCodecError)
341    }
342
343    pub fn scoped_raw(scope_id: u128, target: WidgetId, input: ActionInput) -> Self {
344        Self::ScopedRaw {
345            scope_id,
346            target: target.into(),
347            input: Box::new(input),
348        }
349    }
350
351    pub fn action_scope_id(&self) -> Option<u128> {
352        match self {
353            ActionInput::ScopedRaw { scope_id, .. } => Some(*scope_id),
354            _ => None,
355        }
356    }
357
358    pub fn scoped_target(&self) -> Option<WidgetId> {
359        match self {
360            ActionInput::ScopedRaw { target, .. } => Some(*target),
361            _ => None,
362        }
363    }
364
365    pub fn unscoped(&self) -> &ActionInput {
366        match self {
367            ActionInput::ScopedRaw { input, .. } => input.unscoped(),
368            _ => self,
369        }
370    }
371
372    pub fn as_bytes(&self) -> Option<&[u8]> {
373        match self.unscoped() {
374            ActionInput::JobOk { payload, .. } => Some(payload),
375            ActionInput::CapabilityOk { payload, .. } => Some(payload),
376            ActionInput::TimerTick { payload } => Some(payload),
377            ActionInput::InternalDrop { payload, .. } => Some(payload),
378            _ => None,
379        }
380    }
381
382    pub fn as_pointer(&self) -> Option<(f32, f32, f32, f32)> {
383        match self.unscoped() {
384            ActionInput::Pointer {
385                x,
386                y,
387                delta_x,
388                delta_y,
389            } => Some((*x, *y, *delta_x, *delta_y)),
390            ActionInput::Drop { x, y, .. } => Some((*x, *y, 0.0, 0.0)),
391            ActionInput::InternalDrop { x, y, .. } => Some((*x, *y, 0.0, 0.0)),
392            _ => None,
393        }
394    }
395
396    /// Returns the edit accompanying a text-input action.
397    ///
398    /// Scoped actions are unwrapped automatically, matching the other typed
399    /// input accessors.
400    pub fn text_change(&self) -> Option<&UpdateTextInput> {
401        match self.unscoped() {
402            ActionInput::TextChanged(change) => Some(change),
403            _ => None,
404        }
405    }
406
407    pub fn text_selection_change(&self) -> Option<&crate::action::UpdateTextSelection> {
408        match self.unscoped() {
409            ActionInput::TextSelectionChanged(change) => Some(change),
410            _ => None,
411        }
412    }
413
414    /// Returns the camera change accompanying an interactive-viewport action.
415    pub fn viewport_interaction(&self) -> Option<&crate::input::viewport::ViewportInteraction> {
416        match self.unscoped() {
417            ActionInput::ViewportInteraction(interaction) => Some(interaction),
418            _ => None,
419        }
420    }
421
422    /// Returns the node, edge, resize, or marquee change for a canvas action.
423    pub fn canvas_interaction(&self) -> Option<&crate::input::canvas::CanvasInteraction> {
424        match self.unscoped() {
425            ActionInput::CanvasInteraction(interaction) => Some(interaction),
426            _ => None,
427        }
428    }
429
430    pub fn as_drop_paths(&self) -> Option<&[String]> {
431        match self.unscoped() {
432            ActionInput::Drop { paths, .. } => Some(paths),
433            _ => None,
434        }
435    }
436
437    pub fn as_internal_drop(&self) -> Option<&[u8]> {
438        match self.unscoped() {
439            ActionInput::InternalDrop { payload, .. } => Some(payload),
440            _ => None,
441        }
442    }
443
444    /// Modifier bitmask active during a drop action.
445    ///
446    /// This lets app reducers choose copy/move/link semantics without binding
447    /// that product rule into the drag runtime itself.
448    pub fn as_drop_modifiers(&self) -> Option<u8> {
449        match self.unscoped() {
450            ActionInput::Drop { modifiers, .. } => Some(*modifiers),
451            ActionInput::InternalDrop { modifiers, .. } => Some(*modifiers),
452            _ => None,
453        }
454    }
455
456    pub fn job_ok<J: JobSpec>(&self, job: JobRef<J>) -> Option<J::Ok> {
457        match self.unscoped() {
458            ActionInput::JobOk {
459                job_name, payload, ..
460            } if job_name == job.name => serde_json::from_slice(payload).ok(),
461            _ => None,
462        }
463    }
464
465    pub fn job_err<J: JobSpec>(&self, job: JobRef<J>) -> Option<J::Err> {
466        match self.unscoped() {
467            ActionInput::JobErr {
468                job_name,
469                payload: Some(payload),
470                ..
471            } if job_name == job.name => serde_json::from_slice(payload).ok(),
472            _ => None,
473        }
474    }
475
476    pub fn job_error_message<J: JobSpec>(&self, job: JobRef<J>) -> Option<&str> {
477        match self.unscoped() {
478            ActionInput::JobErr {
479                job_name,
480                message: Some(message),
481                ..
482            } if job_name == job.name => Some(message.as_str()),
483            _ => None,
484        }
485    }
486
487    pub fn capability_ok<C: OperationCapability>(
488        &self,
489        capability: CapabilityType<C>,
490    ) -> Option<C::Ok> {
491        match self.unscoped() {
492            ActionInput::CapabilityOk {
493                capability: actual,
494                payload,
495                ..
496            } if actual == capability.name => serde_json::from_slice(payload).ok(),
497            _ => None,
498        }
499    }
500
501    pub fn capability_error<C: OperationCapability>(
502        &self,
503        capability: CapabilityType<C>,
504    ) -> Option<C::Err> {
505        match self.unscoped() {
506            ActionInput::CapabilityErr {
507                capability: actual,
508                payload: Some(payload),
509                ..
510            } if actual == capability.name => serde_json::from_slice(payload).ok(),
511            _ => None,
512        }
513    }
514
515    pub fn capability_error_message<C: OperationCapability>(
516        &self,
517        capability: CapabilityType<C>,
518    ) -> Option<&str> {
519        match self.unscoped() {
520            ActionInput::CapabilityErr {
521                capability: actual,
522                message: Some(message),
523                ..
524            } if actual == capability.name => Some(message),
525            _ => None,
526        }
527    }
528
529    /// Decodes a typed value returned by [`Effects::store`](crate::Effects::store).
530    #[cfg(feature = "store")]
531    pub fn store_value<T: serde::de::DeserializeOwned>(
532        &self,
533    ) -> Option<Result<T, fission_store::StoreError>> {
534        self.capability_ok(crate::storage::STORE_GET).map(|value| {
535            value
536                .ok_or_else(|| {
537                    fission_store::StoreError::new(
538                        fission_store::StoreErrorKind::InvalidRequest,
539                        "store key was not found",
540                    )
541                })
542                .and_then(|value| value.decode())
543        })
544    }
545
546    #[cfg(feature = "store")]
547    pub fn store_error(&self) -> Option<fission_store::StoreError> {
548        self.capability_error(crate::storage::STORE_GET)
549            .or_else(|| self.capability_error(crate::storage::STORE_SET))
550            .or_else(|| self.capability_error(crate::storage::STORE_CONTAINS))
551            .or_else(|| self.capability_error(crate::storage::STORE_REMOVE))
552            .or_else(|| self.capability_error(crate::storage::STORE_BATCH))
553            .or_else(|| self.capability_error(crate::storage::STORE_LIST_PREFIX))
554    }
555
556    #[cfg(feature = "store")]
557    pub fn store_contains(&self) -> Option<bool> {
558        self.capability_ok(crate::storage::STORE_CONTAINS)
559    }
560
561    #[cfg(feature = "store")]
562    pub fn store_removed(&self) -> Option<bool> {
563        self.capability_ok(crate::storage::STORE_REMOVE)
564    }
565
566    #[cfg(feature = "store")]
567    pub fn store_batch_result(&self) -> Option<fission_store::StoreBatchResult> {
568        self.capability_ok(crate::storage::STORE_BATCH)
569    }
570
571    #[cfg(feature = "store")]
572    pub fn store_entries(&self) -> Option<Vec<fission_store::StoreEntry>> {
573        self.capability_ok(crate::storage::STORE_LIST_PREFIX)
574    }
575
576    #[cfg(feature = "store-sql")]
577    pub fn sql_rows(&self) -> Option<fission_store::SqlRows> {
578        self.capability_ok(crate::storage::SQL_QUERY)
579    }
580
581    #[cfg(feature = "store-sql")]
582    pub fn sql_execute_result(&self) -> Option<fission_store::SqlExecuteResult> {
583        self.capability_ok(crate::storage::SQL_EXECUTE)
584    }
585
586    #[cfg(feature = "store-sql")]
587    pub fn sql_transaction_result(&self) -> Option<fission_store::SqlTransactionResult> {
588        self.capability_ok(crate::storage::SQL_TRANSACTION)
589    }
590
591    #[cfg(feature = "store-sql")]
592    pub fn sql_migration_result(&self) -> Option<fission_store::SqlMigrationResult> {
593        self.capability_ok(crate::storage::SQL_MIGRATE)
594    }
595
596    #[cfg(feature = "store-sql")]
597    pub fn sql_error(&self) -> Option<fission_store::SqlError> {
598        self.capability_error(crate::storage::SQL_EXECUTE)
599            .or_else(|| self.capability_error(crate::storage::SQL_QUERY))
600            .or_else(|| self.capability_error(crate::storage::SQL_TRANSACTION))
601            .or_else(|| self.capability_error(crate::storage::SQL_MIGRATE))
602    }
603
604    pub fn service_event<S: ServiceSpec>(&self, service: ServiceType<S>) -> Option<S::Event> {
605        match self.unscoped() {
606            ActionInput::ServiceEvent {
607                service_name,
608                payload,
609                ..
610            } if service_name == service.name => serde_json::from_slice(payload).ok(),
611            _ => None,
612        }
613    }
614
615    pub fn service_start_err<S: ServiceSpec>(
616        &self,
617        service: ServiceType<S>,
618    ) -> Option<S::StartErr> {
619        match self.unscoped() {
620            ActionInput::ServiceStartFailed {
621                service_name,
622                payload: Some(payload),
623                ..
624            } if service_name == service.name => serde_json::from_slice(payload).ok(),
625            _ => None,
626        }
627    }
628
629    pub fn service_start_error_message<S: ServiceSpec>(
630        &self,
631        service: ServiceType<S>,
632    ) -> Option<&str> {
633        match self.unscoped() {
634            ActionInput::ServiceStartFailed {
635                service_name,
636                message: Some(message),
637                ..
638            } if service_name == service.name => Some(message.as_str()),
639            _ => None,
640        }
641    }
642
643    pub fn service_command_ok<S: ServiceSpec>(
644        &self,
645        service: ServiceType<S>,
646    ) -> Option<S::CommandOk> {
647        match self.unscoped() {
648            ActionInput::ServiceCommandOk {
649                service_name,
650                payload: Some(payload),
651                ..
652            } if service_name == service.name => serde_json::from_slice(payload).ok(),
653            _ => None,
654        }
655    }
656
657    pub fn service_command_err<S: ServiceSpec>(
658        &self,
659        service: ServiceType<S>,
660    ) -> Option<S::CommandErr> {
661        match self.unscoped() {
662            ActionInput::ServiceCommandErr {
663                service_name,
664                payload: Some(payload),
665                ..
666            } if service_name == service.name => serde_json::from_slice(payload).ok(),
667            _ => None,
668        }
669    }
670
671    pub fn timer_tick<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
672        match self.unscoped() {
673            ActionInput::TimerTick { payload } => serde_json::from_slice(payload).ok(),
674            _ => None,
675        }
676    }
677
678    pub fn service_slot_key(&self) -> Option<&str> {
679        match self.unscoped() {
680            ActionInput::ServiceStarted { slot_key, .. }
681            | ActionInput::ServiceStartFailed { slot_key, .. }
682            | ActionInput::ServiceEvent { slot_key, .. }
683            | ActionInput::ServiceStopped { slot_key, .. }
684            | ActionInput::ServiceCommandOk { slot_key, .. }
685            | ActionInput::ServiceCommandErr { slot_key, .. } => Some(slot_key.as_str()),
686            _ => None,
687        }
688    }
689
690    pub fn service_instance_id(&self) -> Option<u64> {
691        match self.unscoped() {
692            ActionInput::ServiceStarted { instance_id, .. }
693            | ActionInput::ServiceEvent { instance_id, .. }
694            | ActionInput::ServiceStopped { instance_id, .. }
695            | ActionInput::ServiceCommandOk { instance_id, .. }
696            | ActionInput::ServiceCommandErr { instance_id, .. } => Some(*instance_id),
697            _ => None,
698        }
699    }
700}
701
702/// Failure to encode or decode an opaque [`ActionInput`] representation.
703#[derive(Debug)]
704pub struct ActionInputCodecError(serde_json::Error);
705
706impl std::fmt::Display for ActionInputCodecError {
707    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
708        formatter.write_str("action input codec failed")
709    }
710}
711
712impl std::error::Error for ActionInputCodecError {
713    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
714        Some(&self.0)
715    }
716}
717
718#[cfg(test)]
719mod action_input_codec_tests {
720    use super::*;
721    use crate::event::PointerKind;
722    use crate::input::canvas::{CanvasInteraction, CanvasInteractionKind, CanvasInteractionPhase};
723    use crate::input::viewport::{
724        ViewportInputKind, ViewportInteraction, ViewportInteractionPhase,
725    };
726    use fission_ir::{CanvasSelectionPolicy, ViewportTransform};
727    use fission_layout::{LayoutPoint, LayoutRect};
728
729    #[test]
730    fn opaque_codec_round_trips_full_width_scope_ids() {
731        let input = ActionInput::scoped_raw(
732            u128::MAX - 1,
733            WidgetId::from_u128(u128::MAX - 2),
734            ActionInput::TextChanged(UpdateTextInput {
735                node_id: WidgetId::from_u128(7),
736                new_text: "hello".into(),
737                new_caret: 4,
738                new_anchor: 1,
739                ..Default::default()
740            }),
741        );
742        let bytes = input.encode_opaque().expect("input should encode");
743        let decoded = ActionInput::decode_opaque(&bytes).expect("input should decode");
744        assert_eq!(decoded, input);
745    }
746
747    #[test]
748    fn opaque_codec_round_trips_viewport_interactions() {
749        let input = ActionInput::ViewportInteraction(ViewportInteraction {
750            node_id: WidgetId::from_u128(9),
751            phase: ViewportInteractionPhase::Update,
752            transform: ViewportTransform::new(12.0, -4.0, 1.5),
753            viewport_focal_point: LayoutPoint::new(40.0, 50.0),
754            world_focal_point: LayoutPoint::new(18.0, 36.0),
755            pan_delta: LayoutPoint::new(3.0, -2.0),
756            scale_factor: 1.1,
757            input_kind: ViewportInputKind::Touch,
758            modifiers: 1,
759        });
760
761        let bytes = input.encode_opaque().expect("input should encode");
762        let decoded = ActionInput::decode_opaque(&bytes).expect("input should decode");
763        assert_eq!(decoded, input);
764    }
765
766    #[test]
767    fn opaque_codec_round_trips_canvas_interactions() {
768        let input = ActionInput::CanvasInteraction(CanvasInteraction {
769            canvas_id: WidgetId::from_u128(10),
770            target_id: WidgetId::from_u128(11),
771            kind: CanvasInteractionKind::MoveNode { node_id: 12 },
772            selection_policy: CanvasSelectionPolicy::Toggle,
773            phase: CanvasInteractionPhase::Update,
774            input_kind: PointerKind::Mouse,
775            modifiers: 8,
776            screen_point: LayoutPoint::new(42.0, 24.0),
777            world_point: LayoutPoint::new(21.0, 12.0),
778            screen_delta: LayoutPoint::new(6.0, -4.0),
779            world_delta: LayoutPoint::new(3.0, -2.0),
780            bounds_before: Some(LayoutRect::new(1.0, 2.0, 30.0, 40.0)),
781            bounds_after: Some(LayoutRect::new(4.0, 0.0, 30.0, 40.0)),
782            marquee: None,
783        });
784
785        let bytes = input.encode_opaque().expect("input should encode");
786        let decoded = ActionInput::decode_opaque(&bytes).expect("input should decode");
787        assert_eq!(decoded, input);
788    }
789}