Skip to main content

fission_core/
runtime.rs

1use crate::action::{Action, ActionEnvelope, ActionId, GlobalState};
2use crate::async_runtime::ServiceStopPayload;
3use crate::effect::{
4    ActionInput, Effect, EffectEnvelope, RuntimeEffect, ScrollAlignment, ScrollAxis,
5    ScrollBehavior, ScrollIntoViewRequest,
6};
7use crate::env::{RuntimeState, VideoStatus};
8use crate::registry::{
9    ActionRegistry, ResourcePolicy, RuntimeResourceDeclaration, RuntimeResourceKind, TimerResource,
10    VideoRegistration,
11};
12use crate::ui::custom_render::downcast_render_object;
13use crate::{BoxedReducer, EffectCallbackRegistry};
14use crate::{
15    Clipboard, Clock, CurrentTime, ImeHandler, InputEvent, KeyCode, KeyEvent, PointerButton,
16    PointerEvent, ResourceExecutionContext,
17};
18use anyhow::{anyhow, Context, Result};
19use fission_diagnostics::prelude as diag;
20use fission_ir::{CoreIR, FlexDirection, FocusPolicy, LayoutOp, Op, WidgetId};
21use fission_layout::{LayoutPoint, LayoutRect, LayoutSize, LayoutSnapshot, TextMeasurer};
22use glam::{Mat4, Vec4};
23use serde_json;
24use std::any::TypeId;
25use std::collections::{HashMap, HashSet};
26use std::sync::Arc;
27
28#[derive(Debug, Default, Clone)]
29pub struct TickResult {
30    pub changed_motions: Vec<(WidgetId, crate::MotionPropertyId)>,
31    /// Number of timer resource actions dispatched during this tick.
32    ///
33    /// Shells use this to rebuild after timer reducers mutate application state.
34    pub resource_actions_dispatched: usize,
35}
36
37#[derive(Debug, Clone)]
38enum ActiveResourceKind {
39    Job,
40    Service {
41        service_name: String,
42        slot_key: String,
43    },
44    Timer {
45        interval_ms: u64,
46        payload: Vec<u8>,
47        on_tick: Option<ActionEnvelope>,
48        next_fire_at: CurrentTime,
49    },
50}
51
52#[derive(Debug, Clone)]
53struct ActiveResource {
54    generation: u64,
55    deps: Option<Vec<u8>>,
56    policy: ResourcePolicy,
57    kind: ActiveResourceKind,
58}
59
60#[derive(Debug, Clone)]
61struct PendingScrollIntoView {
62    request: ScrollIntoViewRequest,
63    retries_remaining: u8,
64}
65
66#[derive(Debug, Clone, Copy)]
67struct FocusBarrierFrame {
68    id: WidgetId,
69    restore_target: Option<WidgetId>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum ScrollIntoViewOutcome {
74    Applied { changed: bool },
75    Retry,
76    Ignored,
77}
78
79/// The core runtime that owns application state, reducers, and the effect queue.
80///
81/// `Runtime` is the single entry point for the action/reducer pipeline. Platform
82/// shells create one `Runtime`, register their `GlobalState`, build the widget tree
83/// each frame, absorb the resulting [`ActionRegistry`], and call
84/// [`Runtime::handle_input`] to process user events.
85///
86/// # Lifecycle
87///
88/// ```text
89/// 1. runtime = Runtime::default()
90///        .with_measurer(measurer)
91///        .with_clipboard(clipboard);
92/// 2. runtime.add_global_state(Box::new(MyState::default()))?;
93/// 3. loop {
94///        let mut ctx = BuildCtx::new();
95///        let tree = fission_core::build::enter(&mut ctx, &view, || MyRoot.into());
96///        runtime.clear_reducers();
97///        runtime.absorb_registry(ctx.registry);
98///        // lower tree -> IR -> layout -> render
99///        runtime.handle_input(event, &ir, &layout)?;
100///        runtime.tick(dt)?;
101///    }
102/// ```
103///
104/// # Example
105///
106/// ```rust,ignore
107/// let mut runtime = Runtime::default();
108/// runtime.add_global_state(Box::new(Counter { count: 0 }))?;
109/// runtime.tick(16)?;
110/// ```
111pub struct Runtime {
112    /// Per-frame reducers, cleared and re-populated every frame via
113    /// [`absorb_registry`](Runtime::absorb_registry).
114    pub(crate) reducers: HashMap<ActionId, Vec<BoxedReducer>>,
115    /// Persistent reducers that survive [`clear_reducers`](Runtime::clear_reducers)
116    /// calls, installed once at app startup.
117    pub(crate) persistent_reducers: HashMap<ActionId, Vec<BoxedReducer>>,
118    /// One-shot reducers bound by reducers to async effect completions.
119    effect_callbacks: Arc<EffectCallbackRegistry>,
120    /// Type-indexed application state store.
121    pub app_states: HashMap<TypeId, Box<dyn GlobalState>>,
122    /// Mutable runtime state (interaction, scroll, text editing, motions).
123    pub runtime_state: RuntimeState,
124    /// Platform-provided text measurer for layout.
125    pub measurer: Option<Arc<dyn TextMeasurer>>,
126    /// Platform-provided clipboard backend.
127    pub clipboard_backend: Option<Arc<dyn Clipboard>>,
128    /// Platform-provided IME (Input Method Editor) handler.
129    pub ime_handler: Option<Arc<dyn ImeHandler>>,
130    /// Text-editing conventions for the host that owns this runtime.
131    pub editing_convention: crate::input::TextEditingConvention,
132    /// Effects emitted by reducers, awaiting platform execution.
133    pub pending_effects: Vec<EffectEnvelope>,
134    /// Post-layout scroll requests that need computed geometry before applying.
135    pending_scroll_into_view: Vec<PendingScrollIntoView>,
136    /// Shell navigation commands waiting for the host adapter.
137    pending_navigation: Vec<crate::NavigationCommand>,
138    /// Selection commands waiting for a lowered tree to resolve their members.
139    pending_selection_regions: Vec<(WidgetId, crate::SelectionRegionCommand)>,
140    pending_text_editing: Vec<(WidgetId, crate::TextEditingCommand)>,
141    pending_text_scroll: Vec<(WidgetId, crate::TextScrollCommand)>,
142    pending_text_form_validation: Vec<String>,
143    /// Active focus barriers and the targets restored as each barrier closes.
144    focus_barriers: Vec<FocusBarrierFrame>,
145    /// Monotonically increasing counter for deterministic request id generation.
146    pub next_req_id: u64,
147    /// Declarative runtime resources that currently exist.
148    active_resources: HashMap<String, ActiveResource>,
149    /// Monotonically increasing generation counter for runtime resources.
150    next_resource_generation: u64,
151}
152
153impl Default for Runtime {
154    fn default() -> Self {
155        let mut runtime = Self {
156            reducers: HashMap::new(),
157            persistent_reducers: HashMap::new(),
158            effect_callbacks: Arc::new(EffectCallbackRegistry::new()),
159            app_states: HashMap::new(),
160            runtime_state: RuntimeState::default(),
161            measurer: None,
162            clipboard_backend: None,
163            ime_handler: None,
164            editing_convention: crate::input::TextEditingConvention::default(),
165            pending_effects: Vec::new(),
166            pending_scroll_into_view: Vec::new(),
167            pending_navigation: Vec::new(),
168            pending_selection_regions: Vec::new(),
169            pending_text_editing: Vec::new(),
170            pending_text_scroll: Vec::new(),
171            pending_text_form_validation: Vec::new(),
172            focus_barriers: Vec::new(),
173            next_req_id: 0,
174            active_resources: HashMap::new(),
175            next_resource_generation: 1,
176        };
177
178        runtime
179            .add_global_state(Box::new(runtime.runtime_state.local_widget_state.clone()))
180            .expect("Failed to add local widget state store");
181
182        runtime
183            .add_global_state(Box::new(Clock::default()))
184            .expect("Failed to add Clock state");
185
186        runtime.register_base_reducers();
187
188        runtime
189    }
190}
191
192impl Runtime {
193    pub fn with_measurer(mut self, measurer: Arc<dyn TextMeasurer>) -> Self {
194        self.measurer = Some(measurer);
195        self
196    }
197
198    pub fn with_clipboard(mut self, backend: Arc<dyn Clipboard>) -> Self {
199        self.clipboard_backend = Some(backend);
200        self
201    }
202
203    pub fn with_ime_handler(mut self, handler: Arc<dyn ImeHandler>) -> Self {
204        self.ime_handler = Some(handler);
205        self
206    }
207
208    /// Reconciles keyboard focus with the focus barriers in a newly lowered IR.
209    ///
210    /// New barriers capture focus, nested barriers retain a restoration chain,
211    /// and closing barriers restores the most recent valid target.
212    pub fn reconcile_focus(&mut self, ir: &CoreIR) -> Result<bool> {
213        use crate::hit_test::{
214            focus_barriers_in_tree_order, get_all_focusable_nodes, is_descendant_or_self,
215            is_enabled_focus_node, preferred_focus_node_in_scope,
216        };
217
218        let active_barriers = focus_barriers_in_tree_order(ir);
219        let common_prefix = self
220            .focus_barriers
221            .iter()
222            .map(|frame| frame.id)
223            .zip(active_barriers.iter().copied())
224            .take_while(|(tracked, active)| tracked == active)
225            .count();
226        let popped_any = self.focus_barriers.len() > common_prefix;
227        let mut restore_target = None;
228        while self.focus_barriers.len() > common_prefix {
229            restore_target = self
230                .focus_barriers
231                .pop()
232                .and_then(|frame| frame.restore_target);
233        }
234
235        for (index, barrier_id) in active_barriers
236            .iter()
237            .copied()
238            .enumerate()
239            .skip(common_prefix)
240        {
241            let restore = if index == 0 {
242                restore_target
243                    .filter(|id| is_enabled_focus_node(ir, *id))
244                    .or_else(|| {
245                        self.runtime_state
246                            .interaction
247                            .focused
248                            .filter(|id| is_enabled_focus_node(ir, *id))
249                    })
250            } else {
251                let parent_barrier_id = active_barriers[index - 1];
252                self.runtime_state
253                    .interaction
254                    .focused
255                    .filter(|id| {
256                        is_enabled_focus_node(ir, *id)
257                            && is_descendant_or_self(ir, *id, parent_barrier_id)
258                            && !is_descendant_or_self(ir, *id, barrier_id)
259                    })
260                    .or_else(|| preferred_focus_node_in_scope(ir, parent_barrier_id))
261            };
262            self.focus_barriers.push(FocusBarrierFrame {
263                id: barrier_id,
264                restore_target: restore,
265            });
266        }
267
268        let current = self.runtime_state.interaction.focused;
269        let next = if let Some(barrier_id) = active_barriers.last().copied() {
270            current
271                .filter(|id| {
272                    is_enabled_focus_node(ir, *id) && is_descendant_or_self(ir, *id, barrier_id)
273                })
274                .or_else(|| {
275                    restore_target.filter(|id| {
276                        is_enabled_focus_node(ir, *id) && is_descendant_or_self(ir, *id, barrier_id)
277                    })
278                })
279                .or_else(|| preferred_focus_node_in_scope(ir, barrier_id))
280        } else if popped_any {
281            restore_target
282                .filter(|id| is_enabled_focus_node(ir, *id))
283                .or_else(|| {
284                    let nodes = get_all_focusable_nodes(ir);
285                    nodes
286                        .iter()
287                        .copied()
288                        .find(|id| {
289                            matches!(
290                                ir.nodes.get(id).map(|node| &node.op),
291                                Some(Op::Semantics(semantics)) if semantics.autofocus
292                            )
293                        })
294                        .or_else(|| nodes.first().copied())
295                })
296        } else {
297            current.filter(|id| is_enabled_focus_node(ir, *id))
298        };
299
300        if current == next {
301            return Ok(false);
302        }
303
304        self.set_focused_widget(ir, next, crate::TextEditSource::Programmatic)
305    }
306
307    pub fn caret_from_point_in_text(
308        &self,
309        value: &str,
310        font_size: f32,
311        viewport_x: f32,
312        viewport_w: f32,
313        content_w: f32,
314        scroll_offset: f32,
315        point_x: f32,
316    ) -> usize {
317        crate::input::text::caret_from_point_in_text(
318            self.measurer.as_ref(),
319            value,
320            font_size,
321            viewport_x,
322            viewport_w,
323            content_w,
324            scroll_offset,
325            point_x,
326        )
327    }
328
329    // Helper for manual reducer registration (internal use)
330    pub fn register_reducer<S: GlobalState + 'static>(
331        &mut self,
332        action_id: ActionId,
333        reducer_fn: crate::action::Reducer<S>,
334    ) -> Result<()> {
335        let state_type_id = TypeId::of::<S>();
336
337        // Wrap legacy 3-arg reducer into 5-arg BoxedReducer
338        let boxed_reducer: BoxedReducer = Box::new(
339            move |app_states: &mut HashMap<TypeId, Box<dyn GlobalState>>,
340                  action: &ActionEnvelope,
341                  target: WidgetId,
342                  _effects: &mut Vec<EffectEnvelope>,
343                  _input: &ActionInput,
344                  _callback_registry|
345                  -> Result<()> {
346                if let Some(state_box) = app_states.get_mut(&state_type_id) {
347                    let concrete_state = state_box.downcast_mut::<S>().ok_or_else(|| {
348                        anyhow!("Failed to downcast GlobalState to concrete type for reducer")
349                    })?;
350                    reducer_fn(concrete_state, action, target.into())
351                } else {
352                    anyhow::bail!("Target GlobalState for reducer not found in runtime.");
353                }
354            },
355        );
356
357        self.reducers
358            .entry(action_id)
359            .or_default()
360            .push(boxed_reducer);
361        Ok(())
362    }
363
364    pub fn register_base_reducers(&mut self) {
365        use crate::{AdvanceTo, Tick, ADVANCE_TO_ACTION_ID, TICK_ACTION_ID};
366
367        self.register_reducer::<Clock>(
368            *TICK_ACTION_ID,
369            |state: &mut Clock, action: &ActionEnvelope, _target| {
370                let tick_action: Tick = serde_json::from_slice(&action.payload)
371                    .map_err(|e| anyhow!("Failed to deserialize Tick: {}", e))?;
372                state.advance_by(tick_action.dt)
373            },
374        )
375        .expect("Failed to register Tick reducer");
376
377        self.register_reducer::<Clock>(
378            *ADVANCE_TO_ACTION_ID,
379            |state: &mut Clock, action: &ActionEnvelope, _target| {
380                let advance_action: AdvanceTo = serde_json::from_slice(&action.payload)
381                    .map_err(|e| anyhow!("Failed to deserialize AdvanceTo: {}", e))?;
382                state.set_to(advance_action.time)
383            },
384        )
385        .expect("Failed to register AdvanceTo reducer");
386    }
387
388    pub fn clear_reducers(&mut self) {
389        self.reducers.clear();
390        self.register_base_reducers();
391    }
392
393    pub fn absorb_registry<S: GlobalState>(&mut self, registry: ActionRegistry<S>) {
394        let new_reducers = registry.into_runtime_reducers();
395        for (id, mut list) in new_reducers {
396            self.reducers.entry(id).or_default().append(&mut list);
397        }
398    }
399
400    /// Registers reducers that should survive `clear_reducers()` calls.
401    ///
402    /// This is intended for app-level "global" handlers (e.g. system effects) that
403    /// are installed once at app startup, while per-frame widget handlers are
404    /// regenerated every frame via `BuildCtx` and `absorb_registry`.
405    pub fn absorb_persistent_registry<S: GlobalState>(&mut self, registry: ActionRegistry<S>) {
406        let new_reducers = registry.into_runtime_reducers();
407        for (id, mut list) in new_reducers {
408            self.persistent_reducers
409                .entry(id)
410                .or_default()
411                .append(&mut list);
412        }
413    }
414
415    pub fn clock(&self) -> &Clock {
416        self.get_global_state::<Clock>()
417            .expect("Clock state must always be present")
418    }
419
420    pub fn get_global_state<S: GlobalState + 'static>(&self) -> Option<&S> {
421        self.app_states
422            .get(&TypeId::of::<S>())
423            .and_then(|s_box| s_box.downcast_ref::<S>())
424    }
425
426    pub fn get_global_state_mut<S: GlobalState + 'static>(&mut self) -> Option<&mut S> {
427        self.app_states
428            .get_mut(&TypeId::of::<S>())
429            .and_then(|s_box| s_box.downcast_mut::<S>())
430    }
431
432    pub fn add_global_state<S: GlobalState + 'static>(&mut self, state: Box<S>) -> Result<()> {
433        let type_id = TypeId::of::<S>();
434        if self.app_states.insert(type_id, state).is_some() {
435            anyhow::bail!("Global state of this type already registered.");
436        }
437        Ok(())
438    }
439
440    pub fn with_global_state<S: GlobalState + 'static>(mut self, state: S) -> Self {
441        self.app_states.insert(TypeId::of::<S>(), Box::new(state));
442        self
443    }
444
445    #[doc(hidden)]
446    pub fn get_app_state<S: GlobalState + 'static>(&self) -> Option<&S> {
447        self.get_global_state::<S>()
448    }
449
450    #[doc(hidden)]
451    pub fn get_app_state_mut<S: GlobalState + 'static>(&mut self) -> Option<&mut S> {
452        self.get_global_state_mut::<S>()
453    }
454
455    #[doc(hidden)]
456    pub fn add_app_state<S: GlobalState + 'static>(&mut self, state: Box<S>) -> Result<()> {
457        self.add_global_state(state)
458    }
459
460    pub fn dispatch(&mut self, action: ActionEnvelope, target: WidgetId) -> Result<()> {
461        self.dispatch_with_input(action, target, &ActionInput::None)
462    }
463
464    fn enqueue_effect(&mut self, mut envelope: EffectEnvelope) {
465        envelope.req_id = self.next_req_id;
466        self.next_req_id += 1;
467        self.pending_effects.push(envelope);
468    }
469
470    /// Rejects every queued host effect and its one-shot completion callbacks.
471    ///
472    /// This is only for shells that intentionally do not execute any effects
473    /// produced by a dispatch. Executing or retaining an effect after calling
474    /// this method would make its completion callback unavailable.
475    ///
476    /// Returns the total number of discarded effect envelopes and callbacks.
477    #[doc(hidden)]
478    pub fn discard_pending_effects(&mut self) -> usize {
479        let discarded = self.pending_effects.len() + self.effect_callbacks.clear();
480        self.pending_effects.clear();
481        discarded
482    }
483
484    pub fn dispatch_with_input(
485        &mut self,
486        action: ActionEnvelope,
487        target: WidgetId,
488        input: &ActionInput,
489    ) -> Result<()> {
490        self.dispatch_node_with_input(action, target.into(), input)
491    }
492
493    fn dispatch_node(&mut self, action: ActionEnvelope, target: WidgetId) -> Result<()> {
494        self.dispatch_node_with_input(action, target, &ActionInput::None)
495    }
496
497    fn dispatch_node_with_input(
498        &mut self,
499        action: ActionEnvelope,
500        target: WidgetId,
501        input: &ActionInput,
502    ) -> Result<()> {
503        let action_id = action.id;
504        let result = self.try_dispatch_node_with_input(action, target, input);
505        if let Err(error) = &result {
506            crate::registry::emit_action_dispatch_failure(action_id, target, error);
507        }
508        result
509    }
510
511    fn try_dispatch_node_with_input(
512        &mut self,
513        action: ActionEnvelope,
514        target: WidgetId,
515        input: &ActionInput,
516    ) -> Result<()> {
517        diag::emit(
518            diag::DiagCategory::Input,
519            diag::DiagLevel::Debug,
520            diag::DiagEventKind::InputEvent {
521                kind: "dispatch_start".into(),
522                target: Some(target.as_u128()),
523                position: None,
524            },
525        );
526
527        if action.id == crate::NavigationRequested::static_id() {
528            let request: crate::NavigationRequested = serde_json::from_slice(&action.payload)
529                .context("failed to decode built-in navigation request")?;
530            self.pending_navigation.push(request.command);
531            return Ok(());
532        }
533
534        // Delegate video actions to media module
535        if crate::media::handle_video_action(&mut self.runtime_state.video, &action)? {
536            return Ok(());
537        }
538
539        let action = if let Some(resolution) =
540            crate::scoped_action_handlers::dispatch_scoped_action_handler(&action, target, input)?
541        {
542            match resolution {
543                crate::scoped_action_handlers::ScopedActionResolution::Handled => return Ok(()),
544                crate::scoped_action_handlers::ScopedActionResolution::Forward(forwarded) => {
545                    if crate::media::handle_video_action(&mut self.runtime_state.video, &forwarded)?
546                    {
547                        return Ok(());
548                    }
549                    forwarded
550                }
551            }
552        } else {
553            action
554        };
555
556        let action_id = action.id;
557
558        // Collect effects from this dispatch (both persistent and per-frame reducers).
559        let mut effects = Vec::new();
560        let callback_registry = self.effect_callbacks.clone();
561
562        let mut callback_reducers = callback_registry.take(action_id);
563        for reducer_wrapper in callback_reducers.iter_mut() {
564            reducer_wrapper(
565                &mut self.app_states,
566                &action,
567                target,
568                &mut effects,
569                input,
570                &callback_registry,
571            )?;
572        }
573
574        if let Some(reducers) = self.persistent_reducers.get_mut(&action_id) {
575            diag::emit(
576                diag::DiagCategory::Input,
577                diag::DiagLevel::Debug,
578                diag::DiagEventKind::InputEvent {
579                    kind: format!("persistent_reducers:{}", reducers.len()),
580                    target: Some(target.as_u128()),
581                    position: None,
582                },
583            );
584
585            let mut temp_reducers: Vec<BoxedReducer> = reducers.drain(..).collect();
586            let dispatch_result = temp_reducers.iter_mut().try_for_each(|reducer_wrapper| {
587                reducer_wrapper(
588                    &mut self.app_states,
589                    &action,
590                    target,
591                    &mut effects,
592                    input,
593                    &callback_registry,
594                )
595            });
596            reducers.extend(temp_reducers);
597            dispatch_result?;
598        }
599
600        if let Some(reducers) = self.reducers.get_mut(&action_id) {
601            diag::emit(
602                diag::DiagCategory::Input,
603                diag::DiagLevel::Debug,
604                diag::DiagEventKind::InputEvent {
605                    kind: format!("reducers:{}", reducers.len()),
606                    target: Some(target.as_u128()),
607                    position: None,
608                },
609            );
610
611            let mut temp_reducers: Vec<BoxedReducer> = reducers.drain(..).collect();
612            let dispatch_result = temp_reducers.iter_mut().try_for_each(|reducer_wrapper| {
613                reducer_wrapper(
614                    &mut self.app_states,
615                    &action,
616                    target,
617                    &mut effects,
618                    input,
619                    &callback_registry,
620                )
621            });
622            reducers.extend(temp_reducers);
623            dispatch_result?;
624        }
625
626        for envelope in effects {
627            self.enqueue_effect(envelope);
628        }
629
630        diag::emit(
631            diag::DiagCategory::Input,
632            diag::DiagLevel::Debug,
633            diag::DiagEventKind::InputEvent {
634                kind: "dispatch_end".into(),
635                target: Some(target.as_u128()),
636                position: None,
637            },
638        );
639        Ok(())
640    }
641
642    pub fn tick(&mut self, dt: CurrentTime) -> Result<TickResult> {
643        use crate::Tick;
644        let action = Tick { dt };
645        let envelope: ActionEnvelope = action.into();
646        self.dispatch_node(envelope, WidgetId::derived(0, &[0]))?;
647
648        let resource_actions_dispatched = self.tick_resource_timers()?;
649
650        let current_time = self.clock().current_time();
651        let changed_motions =
652            crate::motion::tick_motion(&mut self.runtime_state.motion, current_time);
653        Ok(TickResult {
654            changed_motions,
655            resource_actions_dispatched,
656        })
657    }
658
659    fn tick_resource_timers(&mut self) -> Result<usize> {
660        let now = self.clock().current_time();
661        let mut ticks = Vec::new();
662
663        for resource in self.active_resources.values_mut() {
664            if let ActiveResourceKind::Timer {
665                interval_ms,
666                payload,
667                on_tick,
668                next_fire_at,
669            } = &mut resource.kind
670            {
671                let Some(action) = on_tick.clone() else {
672                    continue;
673                };
674
675                let interval_ms = (*interval_ms).max(1);
676                while now >= *next_fire_at {
677                    ticks.push((action.clone(), payload.clone()));
678                    *next_fire_at = next_fire_at.saturating_add(interval_ms);
679                }
680            }
681        }
682
683        let dispatched = ticks.len();
684        for (action, payload) in ticks {
685            self.dispatch_node_with_input(
686                action,
687                WidgetId::derived(0, &[0]),
688                &ActionInput::TimerTick { payload },
689            )?;
690        }
691
692        Ok(dispatched)
693    }
694
695    pub fn sync_motion_declarations(
696        &mut self,
697        declarations: &[crate::MotionDeclaration],
698        layout: Option<&LayoutSnapshot>,
699    ) -> Vec<(WidgetId, crate::MotionPropertyId)> {
700        let current_time = self.clock().current_time();
701        let snapshot = self.runtime_state.clone();
702        let result = crate::motion::sync_motion_declarations(
703            &mut self.runtime_state.motion,
704            declarations,
705            &snapshot,
706            layout,
707            current_time,
708        );
709        result.changed
710    }
711
712    pub fn sync_video_nodes(&mut self, registrations: &[VideoRegistration]) {
713        let mut seen: HashSet<WidgetId> = HashSet::new();
714
715        for reg in registrations {
716            seen.insert(reg.node_id);
717            let entry = self
718                .runtime_state
719                .video
720                .states
721                .entry(reg.node_id)
722                .or_insert_with(crate::env::VideoState::default);
723            entry.asset_source = reg.source.clone();
724            entry.looped = reg.loop_playback;
725            entry.audio = reg.audio.clone();
726            if reg.autoplay && entry.status == VideoStatus::Stopped {
727                entry.status = VideoStatus::Playing;
728            }
729        }
730
731        self.runtime_state
732            .video
733            .states
734            .retain(|node_id, _| seen.contains(node_id));
735    }
736
737    pub fn sync_web_nodes(&mut self, registrations: &[crate::registry::WebRegistration]) {
738        let mut seen: HashSet<WidgetId> = HashSet::new();
739
740        for reg in registrations {
741            seen.insert(reg.node_id);
742            let entry = self
743                .runtime_state
744                .web
745                .states
746                .entry(reg.node_id)
747                .or_insert_with(crate::env::WebState::default);
748
749            // Only update URL if it changes to avoid reload loops
750            if entry.url != reg.url {
751                entry.url = reg.url.clone();
752                entry.loading = true; // Assume loading starts
753            }
754            entry.user_agent = reg.user_agent.clone();
755        }
756
757        self.runtime_state
758            .web
759            .states
760            .retain(|node_id, _| seen.contains(node_id));
761    }
762
763    /// Queues a runtime effect that must be resolved by the core runtime.
764    ///
765    /// Shells call this for effects that require runtime-owned state or a
766    /// post-layout pass instead of a host capability executor.
767    pub fn queue_runtime_effect(&mut self, effect: RuntimeEffect) -> bool {
768        match effect {
769            RuntimeEffect::ScrollIntoView(request) => {
770                self.queue_scroll_into_view(request);
771                true
772            }
773            RuntimeEffect::Navigate(command) => {
774                self.pending_navigation.push(command);
775                true
776            }
777            RuntimeEffect::SelectionRegion { region_id, command } => {
778                self.pending_selection_regions.push((region_id, command));
779                true
780            }
781            RuntimeEffect::TextEditing { input_id, command } => {
782                self.pending_text_editing.push((input_id, command));
783                true
784            }
785            RuntimeEffect::TextScroll { input_id, command } => {
786                self.pending_text_scroll.push((input_id, command));
787                true
788            }
789            RuntimeEffect::TextFormValidation { form_id } => {
790                self.pending_text_form_validation.push(form_id);
791                true
792            }
793            RuntimeEffect::Cancel { .. } | RuntimeEffect::ReleaseResource { .. } => false,
794        }
795    }
796
797    /// Takes navigation commands queued since the previous shell turn.
798    #[doc(hidden)]
799    pub fn take_pending_navigation(&mut self) -> Vec<crate::NavigationCommand> {
800        std::mem::take(&mut self.pending_navigation)
801    }
802
803    /// Queues a post-layout request to reveal a widget in a scroll container.
804    pub fn queue_scroll_into_view(&mut self, request: ScrollIntoViewRequest) {
805        self.pending_scroll_into_view.push(PendingScrollIntoView {
806            request,
807            retries_remaining: 1,
808        });
809    }
810
811    fn drain_post_layout_effects(&mut self) {
812        let pending = std::mem::take(&mut self.pending_effects);
813
814        for env in pending {
815            let EffectEnvelope {
816                req_id,
817                effect,
818                on_ok,
819                on_err,
820                service_bindings,
821                resource,
822            } = env;
823
824            match effect {
825                Effect::Runtime(RuntimeEffect::ScrollIntoView(request)) => {
826                    self.queue_scroll_into_view(request);
827                }
828                Effect::Runtime(RuntimeEffect::SelectionRegion { region_id, command }) => {
829                    self.pending_selection_regions.push((region_id, command));
830                }
831                Effect::Runtime(RuntimeEffect::TextEditing { input_id, command }) => {
832                    self.pending_text_editing.push((input_id, command));
833                }
834                Effect::Runtime(RuntimeEffect::TextScroll { input_id, command }) => {
835                    self.pending_text_scroll.push((input_id, command));
836                }
837                Effect::Runtime(RuntimeEffect::TextFormValidation { form_id }) => {
838                    self.pending_text_form_validation.push(form_id);
839                }
840                retained => self.pending_effects.push(EffectEnvelope {
841                    req_id,
842                    effect: retained,
843                    on_ok,
844                    on_err,
845                    service_bindings,
846                    resource,
847                }),
848            }
849        }
850    }
851
852    fn apply_pending_scroll_into_view(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) -> bool {
853        self.drain_post_layout_effects();
854
855        let mut needs_follow_up_frame = false;
856        let pending = std::mem::take(&mut self.pending_scroll_into_view);
857
858        for mut pending_request in pending {
859            match self.apply_scroll_into_view(&pending_request.request, ir, layout) {
860                ScrollIntoViewOutcome::Applied { changed } => {
861                    needs_follow_up_frame |= changed;
862                }
863                ScrollIntoViewOutcome::Retry if pending_request.retries_remaining > 0 => {
864                    pending_request.retries_remaining -= 1;
865                    self.pending_scroll_into_view.push(pending_request);
866                    needs_follow_up_frame = true;
867                }
868                ScrollIntoViewOutcome::Retry | ScrollIntoViewOutcome::Ignored => {}
869            }
870        }
871
872        needs_follow_up_frame
873    }
874
875    fn apply_scroll_into_view(
876        &mut self,
877        request: &ScrollIntoViewRequest,
878        ir: &CoreIR,
879        layout: &LayoutSnapshot,
880    ) -> ScrollIntoViewOutcome {
881        let Some(target_geom) = layout.get_node_geometry(request.target) else {
882            Self::emit_scroll_into_view_diag("missing_target", request, None);
883            return ScrollIntoViewOutcome::Retry;
884        };
885
886        let Some(container_id) = self.resolve_scroll_container(request, ir, layout) else {
887            Self::emit_scroll_into_view_diag("missing_container", request, None);
888            return ScrollIntoViewOutcome::Retry;
889        };
890
891        if !Self::is_descendant_or_self(ir, request.target, container_id) {
892            Self::emit_scroll_into_view_diag("target_not_descendant", request, Some(container_id));
893            return ScrollIntoViewOutcome::Ignored;
894        }
895
896        let Some(container_geom) = layout.get_node_geometry(container_id) else {
897            Self::emit_scroll_into_view_diag(
898                "missing_container_layout",
899                request,
900                Some(container_id),
901            );
902            return ScrollIntoViewOutcome::Retry;
903        };
904
905        let Some(direction) = Self::scroll_direction(ir, container_id) else {
906            Self::emit_scroll_into_view_diag("not_scroll_container", request, Some(container_id));
907            return ScrollIntoViewOutcome::Ignored;
908        };
909
910        if !Self::axis_matches(request.axis, direction) {
911            Self::emit_scroll_into_view_diag("axis_mismatch", request, Some(container_id));
912            return ScrollIntoViewOutcome::Ignored;
913        }
914
915        if matches!(request.behavior, ScrollBehavior::Smooth) {
916            Self::emit_scroll_into_view_diag(
917                "smooth_resolved_as_instant",
918                request,
919                Some(container_id),
920            );
921        }
922
923        let current_offset = self.runtime_state.scroll.get_offset(container_id);
924        let new_offset = match direction {
925            FlexDirection::Column => Self::compute_scroll_offset(
926                current_offset,
927                target_geom.rect.y() - container_geom.rect.y(),
928                target_geom.rect.height(),
929                container_geom.rect.height(),
930                container_geom.content_size.height,
931                request.padding[2],
932                request.padding[3],
933                request.alignment,
934                request.if_needed,
935            ),
936            FlexDirection::Row => Self::compute_scroll_offset(
937                current_offset,
938                target_geom.rect.x() - container_geom.rect.x(),
939                target_geom.rect.width(),
940                container_geom.rect.width(),
941                container_geom.content_size.width,
942                request.padding[0],
943                request.padding[1],
944                request.alignment,
945                request.if_needed,
946            ),
947        };
948
949        if (new_offset - current_offset).abs() > f32::EPSILON {
950            self.runtime_state
951                .scroll
952                .set_offset(container_id, new_offset);
953            ScrollIntoViewOutcome::Applied { changed: true }
954        } else {
955            ScrollIntoViewOutcome::Applied { changed: false }
956        }
957    }
958
959    fn resolve_scroll_container(
960        &self,
961        request: &ScrollIntoViewRequest,
962        ir: &CoreIR,
963        layout: &LayoutSnapshot,
964    ) -> Option<WidgetId> {
965        if let Some(container) = request.container {
966            return ir
967                .nodes
968                .contains_key(&container)
969                .then_some(container)
970                .filter(|id| layout.get_node_geometry(*id).is_some());
971        }
972
973        let mut current = ir.nodes.get(&request.target)?.parent;
974        while let Some(node_id) = current {
975            if let Some(direction) = Self::scroll_direction(ir, node_id) {
976                if Self::axis_matches(request.axis, direction)
977                    && layout.get_node_geometry(node_id).is_some()
978                {
979                    return Some(node_id);
980                }
981            }
982            current = ir.nodes.get(&node_id).and_then(|node| node.parent);
983        }
984
985        None
986    }
987
988    fn scroll_direction(ir: &CoreIR, node_id: WidgetId) -> Option<FlexDirection> {
989        match ir.nodes.get(&node_id).map(|node| &node.op) {
990            Some(Op::Layout(LayoutOp::Scroll { direction, .. })) => Some(*direction),
991            _ => None,
992        }
993    }
994
995    fn axis_matches(axis: ScrollAxis, direction: FlexDirection) -> bool {
996        matches!(
997            (axis, direction),
998            (ScrollAxis::Both, _)
999                | (ScrollAxis::Vertical, FlexDirection::Column)
1000                | (ScrollAxis::Horizontal, FlexDirection::Row)
1001        )
1002    }
1003
1004    fn is_descendant_or_self(ir: &CoreIR, target: WidgetId, ancestor: WidgetId) -> bool {
1005        let mut current = Some(target);
1006        while let Some(node_id) = current {
1007            if node_id == ancestor {
1008                return true;
1009            }
1010            current = ir.nodes.get(&node_id).and_then(|node| node.parent);
1011        }
1012        false
1013    }
1014
1015    fn compute_scroll_offset(
1016        current_offset: f32,
1017        target_content_start: f32,
1018        target_size: f32,
1019        viewport_size: f32,
1020        content_size: f32,
1021        padding_start: f32,
1022        padding_end: f32,
1023        alignment: ScrollAlignment,
1024        if_needed: bool,
1025    ) -> f32 {
1026        let current_offset = Self::finite_or_zero(current_offset).max(0.0);
1027        let viewport_size = Self::finite_or_zero(viewport_size).max(0.0);
1028        let content_size = Self::finite_or_zero(content_size).max(0.0);
1029        let target_size = Self::finite_or_zero(target_size).max(0.0);
1030        let padding_start = Self::finite_or_zero(padding_start).max(0.0);
1031        let padding_end = Self::finite_or_zero(padding_end).max(0.0);
1032        let max_offset = (content_size - viewport_size).max(0.0);
1033
1034        if viewport_size <= f32::EPSILON || max_offset <= f32::EPSILON {
1035            return 0.0;
1036        }
1037
1038        let target_start = Self::finite_or_zero(target_content_start);
1039        let target_end = target_start + target_size;
1040        let reveal_start = target_start - padding_start;
1041        let reveal_end = target_end + padding_end;
1042        let viewport_start = current_offset;
1043        let viewport_end = current_offset + viewport_size;
1044
1045        if if_needed && reveal_start >= viewport_start && reveal_end <= viewport_end {
1046            return current_offset.min(max_offset);
1047        }
1048
1049        let desired = match alignment {
1050            ScrollAlignment::Start => reveal_start,
1051            ScrollAlignment::Center => {
1052                let padded_viewport = (viewport_size - padding_start - padding_end).max(0.0);
1053                target_start - padding_start - (padded_viewport - target_size) * 0.5
1054            }
1055            ScrollAlignment::End => reveal_end - viewport_size,
1056            ScrollAlignment::Nearest => {
1057                if reveal_end - reveal_start > viewport_size {
1058                    reveal_start
1059                } else if reveal_start < viewport_start {
1060                    reveal_start
1061                } else if reveal_end > viewport_end {
1062                    reveal_end - viewport_size
1063                } else {
1064                    current_offset
1065                }
1066            }
1067            ScrollAlignment::Fraction(fraction) => {
1068                let fraction = Self::finite_or_zero(fraction).clamp(0.0, 1.0);
1069                let padded_viewport = (viewport_size - padding_start - padding_end).max(0.0);
1070                target_start - padding_start - (padded_viewport - target_size) * fraction
1071            }
1072        };
1073
1074        Self::finite_or_zero(desired).clamp(0.0, max_offset)
1075    }
1076
1077    fn finite_or_zero(value: f32) -> f32 {
1078        if value.is_finite() {
1079            value
1080        } else {
1081            0.0
1082        }
1083    }
1084
1085    fn emit_scroll_into_view_diag(
1086        kind: &'static str,
1087        request: &ScrollIntoViewRequest,
1088        container: Option<WidgetId>,
1089    ) {
1090        diag::emit(
1091            diag::DiagCategory::Input,
1092            diag::DiagLevel::Debug,
1093            diag::DiagEventKind::InputEvent {
1094                kind: format!(
1095                    "scroll_into_view:{kind}:target={:?}:container={:?}",
1096                    request.target,
1097                    container.or(request.container)
1098                ),
1099                target: Some(request.target.as_u128()),
1100                position: None,
1101            },
1102        );
1103    }
1104
1105    /// Reconciles runtime-owned widget state against the current lowered tree.
1106    ///
1107    /// Shells must call this after replacing the IR and before computing layout
1108    /// so an unmounted widget's state cannot affect the replacement tree's
1109    /// first frame.
1110    pub fn reconcile_ir(&mut self, ir: &CoreIR) {
1111        self.runtime_state.viewport.reconcile(ir);
1112        crate::selection::reconcile_selection_state(&mut self.runtime_state.selectable_text, ir);
1113        let active_scroll_nodes: HashSet<WidgetId> = ir
1114            .nodes
1115            .iter()
1116            .filter_map(|(id, node)| match node.op {
1117                Op::Layout(LayoutOp::Scroll { .. }) => Some(*id),
1118                _ => None,
1119            })
1120            .collect();
1121        self.runtime_state
1122            .scroll
1123            .retain_active(&active_scroll_nodes);
1124        if self
1125            .runtime_state
1126            .gesture
1127            .scrollbar_drag
1128            .is_some_and(|drag| !active_scroll_nodes.contains(&drag.node_id))
1129        {
1130            self.runtime_state.gesture.scrollbar_drag = None;
1131        }
1132    }
1133
1134    /// Runs runtime work that depends on a freshly computed layout snapshot.
1135    ///
1136    /// Returns `true` when the hook changed runtime state and the shell should
1137    /// schedule another frame.
1138    pub fn post_layout_hook(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) -> bool {
1139        // Preserve correct behavior for direct Runtime embedders that have not
1140        // yet adopted the pre-layout reconciliation hook. Production shells
1141        // call `reconcile_ir` before layout, making this pass idempotent.
1142        self.reconcile_ir(ir);
1143        let mut needs_follow_up_frame = self.apply_pending_scroll_into_view(ir, layout);
1144        needs_follow_up_frame |= self.apply_pending_selection_regions(ir);
1145        needs_follow_up_frame |= self.apply_pending_text_editing(ir, layout);
1146        needs_follow_up_frame |= self.apply_pending_text_scroll(ir, layout);
1147        needs_follow_up_frame |= self.apply_pending_text_form_validation(ir);
1148        let current_time = self.clock().current_time();
1149        needs_follow_up_frame |=
1150            self.runtime_state
1151                .viewport
1152                .advance_inertia(ir, layout, current_time);
1153
1154        let mut current_heroes = HashMap::new();
1155
1156        for (id, node) in &ir.nodes {
1157            if let Op::Semantics(s) = &node.op {
1158                if let Some(tag) = &s.hero_tag {
1159                    if let Some(geom) = layout.get_node_geometry(*id) {
1160                        current_heroes.insert(tag.clone(), (*id, geom.rect));
1161                    }
1162                }
1163            }
1164        }
1165
1166        // Detection logic for future flight motions
1167        for (tag, (_new_id, new_rect)) in &current_heroes {
1168            if let Some((_old_id, old_rect)) = self.runtime_state.hero.positions.get(tag) {
1169                if *new_rect != *old_rect {
1170                    // Logic to spawn overlay flight ghost would go here
1171                    diag::emit(
1172                        diag::DiagCategory::Layout,
1173                        diag::DiagLevel::Debug,
1174                        diag::DiagEventKind::AnchorPlacement {
1175                            widget: 0,
1176                            node: 0,
1177                            rect_x: old_rect.origin.x,
1178                            rect_y: old_rect.origin.y,
1179                            rect_w: old_rect.size.width,
1180                            rect_h: old_rect.size.height,
1181                            place_left: new_rect.origin.x,
1182                            place_top: new_rect.origin.y,
1183                            note: Some(format!("Hero flight: {}", tag)),
1184                        },
1185                    );
1186                }
1187            }
1188        }
1189
1190        self.runtime_state.hero.positions = current_heroes;
1191        needs_follow_up_frame
1192    }
1193
1194    fn apply_pending_selection_regions(&mut self, ir: &CoreIR) -> bool {
1195        let pending = std::mem::take(&mut self.pending_selection_regions);
1196        let mut changed = false;
1197        for (region_id, command) in pending {
1198            changed |= crate::selection::apply_region_command(
1199                &mut self.runtime_state.selectable_text,
1200                ir,
1201                region_id,
1202                command,
1203            )
1204            .is_ok();
1205        }
1206        changed
1207    }
1208
1209    fn apply_pending_text_form_validation(&mut self, ir: &CoreIR) -> bool {
1210        let pending = std::mem::take(&mut self.pending_text_form_validation);
1211        let mut dispatched = false;
1212        for form_id in pending {
1213            let fields = ir
1214                .nodes
1215                .iter()
1216                .filter_map(|(id, node)| match &node.op {
1217                    Op::Semantics(semantics)
1218                        if semantics.role == fission_ir::Role::TextInput
1219                            && semantics.text_form_id.as_deref() == Some(form_id.as_str()) =>
1220                    {
1221                        Some((*id, semantics.clone()))
1222                    }
1223                    _ => None,
1224                })
1225                .collect::<Vec<_>>();
1226            for (id, semantics) in fields {
1227                let Some(entry) = semantics
1228                    .actions
1229                    .entries
1230                    .iter()
1231                    .find(|entry| entry.trigger == fission_ir::ActionTrigger::Validation)
1232                else {
1233                    continue;
1234                };
1235                let value = self.text_input_value(ir, id);
1236                let mut update = crate::UpdateTextInput::from_values(
1237                    id,
1238                    value.clone(),
1239                    value,
1240                    crate::TextEditSource::Programmatic,
1241                    crate::TextEditPhase::Validated,
1242                );
1243                update.validation_state = Some(semantics.validation_state);
1244                update.validation_message = semantics.validation_message.clone();
1245                let input = crate::input::scoped_action_input(
1246                    ir,
1247                    id,
1248                    crate::ActionInput::TextChanged(update),
1249                );
1250                let envelope = crate::ActionEnvelope {
1251                    id: crate::ActionId::from_u128(entry.action_id),
1252                    payload: entry.payload_data.clone().unwrap_or_else(|| {
1253                        serde_json::to_vec(&()).expect("unit action payload must serialize")
1254                    }),
1255                };
1256                if let Err(error) = self.dispatch_with_input(envelope, id, &input) {
1257                    diag::emit(
1258                        diag::DiagCategory::Input,
1259                        diag::DiagLevel::Warn,
1260                        diag::DiagEventKind::InputEvent {
1261                            kind: format!("text_form_validation:{error}"),
1262                            target: Some(id.as_u128()),
1263                            position: None,
1264                        },
1265                    );
1266                } else {
1267                    dispatched = true;
1268                }
1269            }
1270        }
1271        dispatched
1272    }
1273
1274    fn apply_pending_text_editing(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) -> bool {
1275        let pending = std::mem::take(&mut self.pending_text_editing);
1276        let mut changed = false;
1277        for (input_id, command) in pending {
1278            let result = match command {
1279                crate::TextEditingCommand::Focus => {
1280                    self.set_focused_widget(ir, Some(input_id), crate::TextEditSource::Programmatic)
1281                }
1282                crate::TextEditingCommand::Unfocus => {
1283                    if self.runtime_state.interaction.focused == Some(input_id) {
1284                        self.set_focused_widget(ir, None, crate::TextEditSource::Programmatic)
1285                    } else {
1286                        Ok(false)
1287                    }
1288                }
1289                crate::TextEditingCommand::SelectAll => {
1290                    let value = self.text_input_value(ir, input_id);
1291                    let end = crate::TextPosition::at_end(&value.text);
1292                    self.apply_text_edit_command_to(
1293                        ir,
1294                        layout,
1295                        input_id,
1296                        crate::TextEditCommand::SetSelection {
1297                            selection: crate::TextSelection {
1298                                base: crate::TextPosition::START,
1299                                extent: end,
1300                                affinity: crate::TextAffinity::Downstream,
1301                            },
1302                            source: crate::TextEditSource::Programmatic,
1303                        },
1304                    )
1305                }
1306                crate::TextEditingCommand::SetSelection(selection) => self
1307                    .apply_text_edit_command_to(
1308                        ir,
1309                        layout,
1310                        input_id,
1311                        crate::TextEditCommand::SetSelection {
1312                            selection,
1313                            source: crate::TextEditSource::Programmatic,
1314                        },
1315                    ),
1316                crate::TextEditingCommand::SetValue(value) => self.apply_text_edit_command_to(
1317                    ir,
1318                    layout,
1319                    input_id,
1320                    crate::TextEditCommand::SetValue {
1321                        value,
1322                        source: crate::TextEditSource::Programmatic,
1323                        phase: crate::TextValuePhase::Committed,
1324                    },
1325                ),
1326            };
1327            match result {
1328                Ok(applied) => changed |= applied,
1329                Err(error) => diag::emit(
1330                    diag::DiagCategory::Input,
1331                    diag::DiagLevel::Warn,
1332                    diag::DiagEventKind::InputEvent {
1333                        kind: format!("text_editing_controller:{error}"),
1334                        target: Some(input_id.as_u128()),
1335                        position: None,
1336                    },
1337                ),
1338            }
1339        }
1340        changed
1341    }
1342
1343    /// Applies a complete edit to a specific text input through the same
1344    /// transaction, formatter, action, scrolling, and IME path as user input.
1345    #[doc(hidden)]
1346    pub fn apply_text_edit_command_to(
1347        &mut self,
1348        ir: &CoreIR,
1349        layout: &LayoutSnapshot,
1350        input_id: WidgetId,
1351        command: crate::TextEditCommand,
1352    ) -> Result<bool> {
1353        use crate::input::text::TextInputController;
1354        use crate::input::ControllerContext;
1355
1356        let previous_text_state = self.runtime_state.text_edit.get(input_id).cloned();
1357        let current_time = self.clock().current_time();
1358        let (handled, dispatched_actions) = {
1359            let mut context = ControllerContext {
1360                ir,
1361                layout,
1362                text_edit: &mut self.runtime_state.text_edit,
1363                selectable_text: &mut self.runtime_state.selectable_text,
1364                context_menu: &mut self.runtime_state.context_menu,
1365                interaction: &mut self.runtime_state.interaction,
1366                scroll: &mut self.runtime_state.scroll,
1367                viewport: &self.runtime_state.viewport,
1368                gesture: &mut self.runtime_state.gesture,
1369                editing_convention: self.editing_convention,
1370                current_time,
1371                clipboard: self.clipboard_backend.as_ref(),
1372                measurer: self.measurer.as_ref(),
1373                dispatched_actions: Vec::new(),
1374            };
1375            let handled =
1376                TextInputController.handle_text_edit_command_for(&mut context, input_id, command);
1377            (handled, context.dispatched_actions)
1378        };
1379        if let Err(error) = self.dispatch_input_actions(dispatched_actions) {
1380            if let Some(previous) = previous_text_state {
1381                self.runtime_state
1382                    .text_edit
1383                    .states
1384                    .insert(input_id, previous);
1385            } else {
1386                self.runtime_state.text_edit.states.remove(&input_id);
1387            }
1388            return Err(error);
1389        }
1390        if self.runtime_state.interaction.focused == Some(input_id) {
1391            self.update_focused_ime_state(ir, layout);
1392        }
1393        Ok(handled)
1394    }
1395
1396    fn apply_pending_text_scroll(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) -> bool {
1397        let pending = std::mem::take(&mut self.pending_text_scroll);
1398        let mut changed = false;
1399        for (input_id, command) in pending {
1400            changed |= crate::TextScrollController::new(input_id)
1401                .apply(&mut self.runtime_state, ir, layout, command)
1402                .is_ok();
1403        }
1404        changed
1405    }
1406
1407    pub fn handle_input(
1408        &mut self,
1409        event: InputEvent,
1410        ir: &CoreIR,
1411        layout: &LayoutSnapshot,
1412    ) -> Result<()> {
1413        use crate::hit_test::{
1414            find_neighbor_focus_node, find_next_focus_node, hit_test_with_viewports, FocusDirection,
1415        };
1416        use crate::input::gesture::GestureController;
1417        use crate::input::hover::HoverController;
1418        use crate::input::selectable_text::SelectableTextController;
1419        use crate::input::slider::SliderController;
1420        use crate::input::text::TextInputController;
1421        use crate::input::{ControllerContext, InputController};
1422        use crate::scrollbar::scrollbar_hit_test;
1423        use crate::ui::custom_render::downcast_render_object;
1424
1425        self.reconcile_focus(ir)?;
1426        let input_time = self.clock().current_time();
1427
1428        if self.runtime_state.interaction.focused.is_none() {
1429            if let Some(autofocus_id) = Self::find_autofocus_node(ir) {
1430                self.set_focused_widget(
1431                    ir,
1432                    Some(autofocus_id),
1433                    crate::TextEditSource::Programmatic,
1434                )?;
1435            }
1436        }
1437
1438        if matches!(event, InputEvent::Pointer(_)) {
1439            let dispatched_actions = {
1440                let mut ctx = ControllerContext {
1441                    ir,
1442                    layout,
1443                    text_edit: &mut self.runtime_state.text_edit,
1444                    selectable_text: &mut self.runtime_state.selectable_text,
1445                    context_menu: &mut self.runtime_state.context_menu,
1446                    interaction: &mut self.runtime_state.interaction,
1447                    scroll: &mut self.runtime_state.scroll,
1448                    viewport: &self.runtime_state.viewport,
1449                    gesture: &mut self.runtime_state.gesture,
1450                    editing_convention: self.editing_convention,
1451                    current_time: input_time,
1452                    clipboard: self.clipboard_backend.as_ref(),
1453                    measurer: self.measurer.as_ref(),
1454                    dispatched_actions: Vec::new(),
1455                };
1456                let mut hover_controller = HoverController;
1457                let _ = hover_controller.handle_event(&mut ctx, &event);
1458                ctx.dispatched_actions
1459            };
1460            self.dispatch_input_actions(dispatched_actions)?;
1461        }
1462
1463        // --- Custom render object event handling (runs first) ----------------
1464        // For pointer events we hit-test, then walk up from the hit node to
1465        // check whether any ancestor carries a custom render object.  The
1466        // first one that returns `handled = true` short-circuits the entire
1467        // standard controller chain.
1468        let pointer_targets_scrollbar = match &event {
1469            InputEvent::Pointer(PointerEvent::Down { point, button, .. })
1470                if matches!(button, PointerButton::Primary) =>
1471            {
1472                scrollbar_hit_test(ir, layout, &self.runtime_state.scroll, *point).is_some()
1473            }
1474            InputEvent::Pointer(PointerEvent::Move { .. })
1475            | InputEvent::Pointer(PointerEvent::Up { .. }) => {
1476                self.runtime_state.gesture.scrollbar_drag.is_some()
1477            }
1478            InputEvent::Pointer(PointerEvent::Scroll { point, .. }) => {
1479                scrollbar_hit_test(ir, layout, &self.runtime_state.scroll, *point).is_some()
1480            }
1481            _ => false,
1482        };
1483
1484        if !pointer_targets_scrollbar {
1485            if let Some(point) = Self::event_point(&event) {
1486                if let Some(hit_node_id) = hit_test_with_viewports(
1487                    ir,
1488                    layout,
1489                    &self.runtime_state.scroll,
1490                    &self.runtime_state.viewport,
1491                    point,
1492                ) {
1493                    // Find the custom render object for this click.  Walk up from the
1494                    // hit node first; if not found, check all registered render objects
1495                    // by rect containment (the hit may be on a wrapper node above the
1496                    // InternalRenderNode's lowered subtree).
1497                    let mut target_ro: Option<(WidgetId, &fission_ir::AnyRenderObject)> = None;
1498                    {
1499                        let mut walk = Some(hit_node_id);
1500                        while let Some(nid) = walk {
1501                            if let Some(ro) = ir.custom_render_objects.get(&nid) {
1502                                target_ro = Some((nid, ro));
1503                                break;
1504                            }
1505                            walk = ir.nodes.get(&nid).and_then(|n| n.parent);
1506                        }
1507                    }
1508                    if target_ro.is_none() {
1509                        for (ro_nid, ro) in &ir.custom_render_objects {
1510                            if let Some(rect) = layout.get_node_rect(*ro_nid) {
1511                                if rect.contains(point) {
1512                                    target_ro = Some((*ro_nid, ro));
1513                                    break;
1514                                }
1515                            }
1516                        }
1517                    }
1518
1519                    if let Some((nid, any_ro)) = target_ro {
1520                        if let Some(render_obj) = downcast_render_object(any_ro) {
1521                            let mut node_rect = layout
1522                                .get_node_rect(nid)
1523                                .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
1524                            // Adjust node_rect by ancestor scroll offsets so it reflects
1525                            // the VISUAL position, matching the screen-coordinate click.
1526                            {
1527                                let mut walk = ir.nodes.get(&nid).and_then(|n| n.parent);
1528                                while let Some(pid) = walk {
1529                                    if let Some(pnode) = ir.nodes.get(&pid) {
1530                                        if let fission_ir::Op::Layout(
1531                                            fission_ir::LayoutOp::Scroll { direction, .. },
1532                                        ) = &pnode.op
1533                                        {
1534                                            let off = self.runtime_state.scroll.get_offset(pid);
1535                                            match direction {
1536                                                fission_ir::FlexDirection::Row => {
1537                                                    node_rect.origin.x -= off
1538                                                }
1539                                                fission_ir::FlexDirection::Column => {
1540                                                    node_rect.origin.y -= off
1541                                                }
1542                                            }
1543                                        }
1544                                        walk = pnode.parent;
1545                                    } else {
1546                                        break;
1547                                    }
1548                                }
1549                            }
1550                            let result = render_obj.handle_event(nid, &event, node_rect);
1551                            if result.handled {
1552                                // Set focus to this node so keyboard events route here
1553                                if matches!(
1554                                    event,
1555                                    InputEvent::Pointer(PointerEvent::Down {
1556                                        button: PointerButton::Primary,
1557                                        ..
1558                                    })
1559                                ) {
1560                                    self.set_focused_widget(
1561                                        ir,
1562                                        Some(nid),
1563                                        crate::TextEditSource::Pointer,
1564                                    )?;
1565                                    if let Some(ime_handler) = &self.ime_handler {
1566                                        let accepts_text = render_obj.accepts_text_input();
1567                                        ime_handler.set_ime_allowed(accepts_text);
1568                                        if accepts_text {
1569                                            if let Some(rect) =
1570                                                render_obj.ime_cursor_area(node_rect)
1571                                            {
1572                                                ime_handler.set_ime_cursor_area(rect);
1573                                            }
1574                                        }
1575                                    }
1576                                }
1577                                // Dispatch any actions the render object produced.
1578                                for (target, envelope) in result.actions {
1579                                    self.dispatch_node(envelope, target)?;
1580                                }
1581                                self.update_focused_ime_state(ir, layout);
1582                                return Ok(());
1583                            }
1584                        }
1585                    }
1586                }
1587            }
1588        }
1589
1590        // --- Keyboard events → focused node's custom render object -----------
1591        // Keyboard events have no point, so we route them to the focused node
1592        // (if any) and walk up its ancestor chain looking for a custom render
1593        // object.  This allows custom editor nodes to handle arrow keys,
1594        // typing, etc. before the framework's default focus-navigation logic.
1595        if matches!(
1596            event,
1597            InputEvent::Keyboard(_)
1598                | InputEvent::Ime(_)
1599                | InputEvent::Editing(_)
1600                | InputEvent::TextEdit(_)
1601        ) {
1602            if let Some(focused_id) = self.runtime_state.interaction.focused {
1603                let mut walk_id = Some(focused_id);
1604                while let Some(nid) = walk_id {
1605                    if let Some(any_ro) = ir.custom_render_objects.get(&nid) {
1606                        if let Some(render_obj) = downcast_render_object(any_ro) {
1607                            let node_rect = layout
1608                                .get_node_rect(nid)
1609                                .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
1610                            let result = render_obj.handle_event(nid, &event, node_rect);
1611                            if result.handled {
1612                                for (target, envelope) in result.actions {
1613                                    self.dispatch_node(envelope, target)?;
1614                                }
1615                                self.update_focused_ime_state(ir, layout);
1616                                return Ok(());
1617                            }
1618                        }
1619                    }
1620                    walk_id = ir.nodes.get(&nid).and_then(|n| n.parent);
1621                }
1622            }
1623        }
1624
1625        let (viewport_handled, viewport_actions) = {
1626            let current_time = self.clock().current_time();
1627            let mut ctx = crate::input::viewport::ViewportControllerContext {
1628                ir,
1629                layout,
1630                scroll: &self.runtime_state.scroll,
1631                viewport: &mut self.runtime_state.viewport,
1632                gesture: &mut self.runtime_state.gesture,
1633                current_time,
1634                dispatched_actions: Vec::new(),
1635            };
1636            let mut controller = crate::input::viewport::ViewportController;
1637            let handled = controller.handle_event(&mut ctx, &event);
1638            (handled, ctx.dispatched_actions)
1639        };
1640        self.dispatch_input_actions(viewport_actions)?;
1641        if viewport_handled {
1642            if matches!(
1643                event,
1644                InputEvent::Pointer(PointerEvent::Up { .. } | PointerEvent::Cancel { .. })
1645            ) {
1646                self.runtime_state.interaction.pressed.clear();
1647                self.runtime_state.interaction.last_down_point = None;
1648            }
1649            self.update_focused_ime_state(ir, layout);
1650            return Ok(());
1651        }
1652
1653        let mut pointer_select_all_target = None;
1654
1655        // Establish pointer focus before the standard controllers run. Text
1656        // editing, selection, and gesture controllers can then observe one
1657        // authoritative focus transition instead of each partially recreating
1658        // focus/blur behavior.
1659        if let InputEvent::Pointer(PointerEvent::Down {
1660            point,
1661            button: PointerButton::Primary,
1662            ..
1663        }) = &event
1664        {
1665            let mut candidate = hit_test_with_viewports(
1666                ir,
1667                layout,
1668                &self.runtime_state.scroll,
1669                &self.runtime_state.viewport,
1670                *point,
1671            );
1672            let mut preserve_current = false;
1673            let mut next = None;
1674            while let Some(node_id) = candidate {
1675                let Some(node) = ir.nodes.get(&node_id) else {
1676                    break;
1677                };
1678                if let Op::Semantics(semantics) = &node.op {
1679                    if semantics.focusable {
1680                        if semantics.focus_policy == FocusPolicy::PreserveCurrentOnPointer {
1681                            preserve_current = true;
1682                        } else {
1683                            next = Some(node_id);
1684                        }
1685                        break;
1686                    }
1687                }
1688                candidate = node.parent;
1689            }
1690            if !preserve_current {
1691                let changed = self.set_focused_widget(ir, next, crate::TextEditSource::Pointer)?;
1692                if changed
1693                    && next.is_some_and(|id| {
1694                        ir.custom_render_objects
1695                            .get(&id)
1696                            .and_then(
1697                                crate::ui::widgets::text_input::downcast_text_input_runtime_config,
1698                            )
1699                            .is_some_and(|config| config.select_all_on_focus)
1700                    })
1701                {
1702                    pointer_select_all_target = next;
1703                }
1704            }
1705        }
1706
1707        let (handled, dispatched_actions) = {
1708            let mut ctx = ControllerContext {
1709                ir,
1710                layout,
1711                text_edit: &mut self.runtime_state.text_edit,
1712                selectable_text: &mut self.runtime_state.selectable_text,
1713                context_menu: &mut self.runtime_state.context_menu,
1714                interaction: &mut self.runtime_state.interaction,
1715                scroll: &mut self.runtime_state.scroll,
1716                viewport: &self.runtime_state.viewport,
1717                gesture: &mut self.runtime_state.gesture,
1718                editing_convention: self.editing_convention,
1719                current_time: input_time,
1720                clipboard: self.clipboard_backend.as_ref(),
1721                measurer: self.measurer.as_ref(),
1722                dispatched_actions: Vec::new(),
1723            };
1724
1725            let mut hover_controller = HoverController;
1726            let _ = hover_controller.handle_event(&mut ctx, &event);
1727
1728            let mut selectable_text_controller = SelectableTextController;
1729            let handled = if selectable_text_controller.handle_event(&mut ctx, &event) {
1730                true
1731            } else {
1732                let mut gesture_controller = GestureController;
1733                if gesture_controller.handle_event(&mut ctx, &event) {
1734                    true
1735                } else {
1736                    let text_handled = if pointer_select_all_target.is_some() {
1737                        true
1738                    } else {
1739                        let mut text_controller = TextInputController;
1740                        text_controller.handle_event(&mut ctx, &event)
1741                    };
1742                    if text_handled {
1743                        true
1744                    } else {
1745                        let mut slider_controller = SliderController;
1746                        slider_controller.handle_event(&mut ctx, &event)
1747                    }
1748                }
1749            };
1750            (handled, ctx.dispatched_actions)
1751        };
1752
1753        self.dispatch_input_actions(dispatched_actions)?;
1754
1755        if handled {
1756            if matches!(
1757                event,
1758                InputEvent::Pointer(PointerEvent::Up { .. } | PointerEvent::Cancel { .. })
1759            ) {
1760                self.runtime_state.interaction.pressed.clear();
1761                self.runtime_state.interaction.last_down_point = None;
1762            }
1763            self.update_focused_ime_state(ir, layout);
1764            return Ok(());
1765        }
1766
1767        match event {
1768            InputEvent::Pointer(PointerEvent::Scroll { point, delta, .. }) => {
1769                let trace_scroll =
1770                    std::env::var("FISSION_SCROLL_TRACE").ok().as_deref() == Some("1");
1771                if trace_scroll {
1772                    eprintln!(
1773                        "[scroll-trace] event point=({:.1},{:.1}) delta=({:.1},{:.1})",
1774                        point.x, point.y, delta.x, delta.y
1775                    );
1776                }
1777                let hit_node_id = scrollbar_hit_test(ir, layout, &self.runtime_state.scroll, point)
1778                    .map(|hit| hit.geometry.node_id)
1779                    .or_else(|| {
1780                        hit_test_with_viewports(
1781                            ir,
1782                            layout,
1783                            &self.runtime_state.scroll,
1784                            &self.runtime_state.viewport,
1785                            point,
1786                        )
1787                    });
1788                if let Some(hit_node_id) = hit_node_id {
1789                    if trace_scroll {
1790                        eprintln!("[scroll-trace] hit_node={}", hit_node_id.as_u128());
1791                    }
1792                    let mut current_id = Some(hit_node_id);
1793                    while let Some(node_id) = current_id {
1794                        if let Some(node) = ir.nodes.get(&node_id) {
1795                            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
1796                                if crate::ui::widgets::text_input::text_input_scroll_physics_for_node(
1797                                    ir, node_id,
1798                                ) == Some(crate::ui::widgets::text_input::TextScrollPhysics::NeverScrollable)
1799                                {
1800                                    current_id = node.parent;
1801                                    continue;
1802                                }
1803                                let current_offset = self.runtime_state.scroll.get_offset(node_id);
1804                                let delta_val = match direction {
1805                                    FlexDirection::Row => delta.x,
1806                                    FlexDirection::Column => delta.y,
1807                                };
1808                                let mut new_offset = current_offset + delta_val;
1809
1810                                let mut max_offset = 0.0f32;
1811                                let mut viewport_w = 0.0f32;
1812                                let mut viewport_h = 0.0f32;
1813                                let mut content_w = 0.0f32;
1814                                let mut content_h = 0.0f32;
1815                                if let Some(geom) = layout.get_node_geometry(node_id) {
1816                                    viewport_w = geom.rect.width();
1817                                    viewport_h = geom.rect.height();
1818                                    content_w = geom.content_size.width;
1819                                    content_h = geom.content_size.height;
1820                                    max_offset = if matches!(direction, FlexDirection::Row) {
1821                                        (geom.content_size.width - geom.rect.width()).max(0.0)
1822                                    } else {
1823                                        (geom.content_size.height - geom.rect.height()).max(0.0)
1824                                    };
1825                                    new_offset = new_offset.clamp(0.0, max_offset);
1826                                }
1827
1828                                if trace_scroll {
1829                                    eprintln!(
1830                                        "[scroll-trace] scroll_node={} axis={} offset={:.1}->{:.1} max={:.1} viewport=({:.1},{:.1}) content=({:.1},{:.1})",
1831                                        node_id.as_u128(),
1832                                        match direction { FlexDirection::Row => "x", FlexDirection::Column => "y" },
1833                                        current_offset,
1834                                        new_offset,
1835                                        max_offset,
1836                                        viewport_w,
1837                                        viewport_h,
1838                                        content_w,
1839                                        content_h
1840                                    );
1841                                }
1842
1843                                {
1844                                    use fission_diagnostics::prelude as diag;
1845                                    diag::emit(
1846                                        diag::DiagCategory::Input,
1847                                        diag::DiagLevel::Debug,
1848                                        diag::DiagEventKind::ScrollUpdate {
1849                                            node: node_id.as_u128(),
1850                                            axis: match direction {
1851                                                FlexDirection::Row => "x".into(),
1852                                                FlexDirection::Column => "y".into(),
1853                                            },
1854                                            point_x: point.x,
1855                                            point_y: point.y,
1856                                            delta: delta_val,
1857                                            old_offset: current_offset,
1858                                            new_offset,
1859                                            max_offset,
1860                                            viewport_w,
1861                                            viewport_h,
1862                                            content_w,
1863                                            content_h,
1864                                        },
1865                                    );
1866                                }
1867
1868                                self.runtime_state.scroll.set_offset(node_id, new_offset);
1869                                // If scroll actually changed, consume the event.
1870                                // If it didn't (clamped to same value, e.g. max_offset==0),
1871                                // propagate to parent scroll nodes.
1872                                if (new_offset - current_offset).abs() > 0.001 {
1873                                    break;
1874                                }
1875                                // Fall through to parent
1876                            }
1877                            current_id = node.parent;
1878                        } else {
1879                            break;
1880                        }
1881                    }
1882                } else if trace_scroll {
1883                    eprintln!("[scroll-trace] hit_test: no node");
1884                }
1885            }
1886            InputEvent::Keyboard(KeyEvent::Down {
1887                key_code,
1888                modifiers,
1889            }) => match key_code {
1890                KeyCode::Tab => {
1891                    let reverse = (modifiers & 1) != 0;
1892                    let old_focus = self.runtime_state.interaction.focused;
1893                    let next =
1894                        find_next_focus_node(ir, self.runtime_state.interaction.focused, reverse);
1895                    if next != old_focus {
1896                        self.set_focused_widget(ir, next, crate::TextEditSource::Keyboard)?;
1897                    }
1898                }
1899                KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => {
1900                    let reverse = matches!(key_code, KeyCode::Up | KeyCode::Left);
1901                    let old_focus = self.runtime_state.interaction.focused;
1902                    let next = if let Some(focused) = old_focus {
1903                        let dir = match key_code {
1904                            KeyCode::Up => FocusDirection::Up,
1905                            KeyCode::Down => FocusDirection::Down,
1906                            KeyCode::Left => FocusDirection::Left,
1907                            KeyCode::Right => FocusDirection::Right,
1908                            _ => unreachable!(),
1909                        };
1910                        find_neighbor_focus_node(ir, layout, focused, dir)
1911                            .or_else(|| find_next_focus_node(ir, Some(focused), reverse))
1912                    } else {
1913                        find_next_focus_node(ir, None, reverse)
1914                    };
1915                    if next != old_focus {
1916                        self.set_focused_widget(ir, next, crate::TextEditSource::Keyboard)?;
1917                    }
1918                }
1919                KeyCode::Enter | KeyCode::Space => {
1920                    if let Some(focused_id) = self.runtime_state.interaction.focused {
1921                        let mut current_id = Some(focused_id);
1922                        while let Some(node_id) = current_id {
1923                            if let Some(node) = ir.nodes.get(&node_id) {
1924                                if let Op::Semantics(semantics) = &node.op {
1925                                    let action_entry = semantics
1926                                        .actions
1927                                        .entries
1928                                        .iter()
1929                                        .find(|entry| {
1930                                            entry.trigger
1931                                                == fission_ir::semantics::ActionTrigger::Default
1932                                        })
1933                                        .and_then(|entry| {
1934                                            entry.payload_data.as_ref().map(|payload| {
1935                                                ActionEnvelope {
1936                                                    id: ActionId::from_u128(entry.action_id),
1937                                                    payload: payload.clone(),
1938                                                }
1939                                            })
1940                                        });
1941                                    let hyperlink = semantics.hyperlink.clone();
1942                                    if action_entry.is_some() || hyperlink.is_some() {
1943                                        let input = crate::input::scoped_action_input(
1944                                            ir,
1945                                            node_id,
1946                                            ActionInput::None,
1947                                        );
1948                                        let navigation_already_bound =
1949                                            action_entry.as_ref().is_some_and(|entry| {
1950                                                entry.id == crate::NavigationRequested::static_id()
1951                                            });
1952                                        if let Some(envelope) = action_entry {
1953                                            self.dispatch_node_with_input(
1954                                                envelope, node_id, &input,
1955                                            )?;
1956                                        }
1957                                        if let Some(hyperlink) =
1958                                            hyperlink.filter(|_| !navigation_already_bound)
1959                                        {
1960                                            self.dispatch_node_with_input(
1961                                                crate::NavigationRequested::new(
1962                                                    crate::NavigationCommand::Open(hyperlink),
1963                                                )
1964                                                .into(),
1965                                                node_id,
1966                                                &input,
1967                                            )?;
1968                                        }
1969                                        return Ok(());
1970                                    }
1971                                }
1972                                current_id = node.parent;
1973                            } else {
1974                                break;
1975                            }
1976                        }
1977                    }
1978                }
1979                _ => {}
1980            },
1981            InputEvent::Pointer(PointerEvent::Down {
1982                point,
1983                button: PointerButton::Primary,
1984                ..
1985            }) => {
1986                if let Some(hit_node_id) = hit_test_with_viewports(
1987                    ir,
1988                    layout,
1989                    &self.runtime_state.scroll,
1990                    &self.runtime_state.viewport,
1991                    point,
1992                ) {
1993                    diag::emit(
1994                        diag::DiagCategory::Input,
1995                        diag::DiagLevel::Debug,
1996                        diag::DiagEventKind::InputEvent {
1997                            kind: "pointer_down_hit".into(),
1998                            target: Some(hit_node_id.as_u128()),
1999                            position: Some((point.x, point.y)),
2000                        },
2001                    );
2002                    let mut focus_candidate = Some(hit_node_id);
2003                    while let Some(node_id) = focus_candidate {
2004                        if let Some(node) = ir.nodes.get(&node_id) {
2005                            if let Op::Semantics(s) = &node.op {
2006                                if s.focusable {
2007                                    if s.focus_policy == FocusPolicy::PreserveCurrentOnPointer {
2008                                        break;
2009                                    }
2010                                    self.set_focused_widget(
2011                                        ir,
2012                                        Some(node_id),
2013                                        crate::TextEditSource::Pointer,
2014                                    )?;
2015                                    break;
2016                                }
2017                            }
2018                            focus_candidate = node.parent;
2019                        } else {
2020                            break;
2021                        }
2022                    }
2023                    if focus_candidate.is_none() {
2024                        self.set_focused_widget(ir, None, crate::TextEditSource::Pointer)?;
2025                    }
2026
2027                    let mut current_pressed_id = Some(hit_node_id);
2028                    while let Some(node_id) = current_pressed_id {
2029                        self.runtime_state.interaction.set_pressed(node_id, true);
2030                        if let Some(node) = ir.nodes.get(&node_id) {
2031                            current_pressed_id = node.parent;
2032                        } else {
2033                            break;
2034                        }
2035                    }
2036                    self.runtime_state.interaction.last_down_point = Some(point);
2037
2038                    if let Some(focused_id) = self.runtime_state.interaction.focused {
2039                        if let Some(node) = ir.nodes.get(&focused_id) {
2040                            if let Op::Semantics(s) = &node.op {
2041                                if s.role == fission_ir::semantics::Role::TextInput {
2042                                    if let Some(ime_handler) = &self.ime_handler {
2043                                        ime_handler.set_ime_cursor_area(LayoutRect::new(
2044                                            point.x, point.y, 2.0, 16.0,
2045                                        ));
2046                                    }
2047                                }
2048                            }
2049                        }
2050                    }
2051                } else {
2052                    self.set_focused_widget(ir, None, crate::TextEditSource::Pointer)?;
2053                }
2054            }
2055            InputEvent::Pointer(PointerEvent::Up {
2056                point,
2057                button: PointerButton::Primary,
2058                ..
2059            }) => {
2060                self.runtime_state.interaction.pressed.clear();
2061                let had_primary_down = self
2062                    .runtime_state
2063                    .interaction
2064                    .last_down_point
2065                    .take()
2066                    .is_some();
2067                if had_primary_down {
2068                    if let Some(hit_node_id) = hit_test_with_viewports(
2069                        ir,
2070                        layout,
2071                        &self.runtime_state.scroll,
2072                        &self.runtime_state.viewport,
2073                        point,
2074                    ) {
2075                        let mut current_id = Some(hit_node_id);
2076                        while let Some(node_id) = current_id {
2077                            if let Some(node) = ir.nodes.get(&node_id) {
2078                                if let Op::Semantics(semantics) = &node.op {
2079                                    if semantics.role == fission_ir::semantics::Role::TextInput {
2080                                        // No action
2081                                    } else if let Some(action_entry) =
2082                                        semantics.actions.entries.iter().find(|entry| {
2083                                            entry.trigger
2084                                                == fission_ir::semantics::ActionTrigger::Default
2085                                        })
2086                                    {
2087                                        if let Some(payload) = &action_entry.payload_data {
2088                                            let envelope = ActionEnvelope {
2089                                                id: ActionId::from_u128(action_entry.action_id),
2090                                                payload: payload.clone(),
2091                                            };
2092                                            diag::emit(
2093                                                diag::DiagCategory::Input,
2094                                                diag::DiagLevel::Debug,
2095                                                diag::DiagEventKind::InputEvent {
2096                                                    kind: "pointer_up_dispatch".into(),
2097                                                    target: Some(node_id.as_u128()),
2098                                                    position: Some((point.x, point.y)),
2099                                                },
2100                                            );
2101                                            let input = crate::input::scoped_action_input(
2102                                                ir,
2103                                                node_id,
2104                                                ActionInput::None,
2105                                            );
2106                                            return self.dispatch_node_with_input(
2107                                                envelope, node_id, &input,
2108                                            );
2109                                        }
2110                                    }
2111                                }
2112                                current_id = node.parent;
2113                            } else {
2114                                break;
2115                            }
2116                        }
2117                    }
2118                }
2119            }
2120            _ => {}
2121        }
2122        self.update_focused_ime_state(ir, layout);
2123        Ok(())
2124    }
2125
2126    pub fn clear_hover_state(&mut self, ir: &CoreIR, point: Option<LayoutPoint>) -> Result<bool> {
2127        use crate::input::hover::HoverController;
2128        use crate::input::ControllerContext;
2129
2130        let input_time = self.clock().current_time();
2131        let dispatched_actions = {
2132            let layout = &LayoutSnapshot::new(LayoutSize::ZERO);
2133            let mut ctx = ControllerContext {
2134                ir,
2135                layout,
2136                text_edit: &mut self.runtime_state.text_edit,
2137                selectable_text: &mut self.runtime_state.selectable_text,
2138                context_menu: &mut self.runtime_state.context_menu,
2139                interaction: &mut self.runtime_state.interaction,
2140                scroll: &mut self.runtime_state.scroll,
2141                viewport: &self.runtime_state.viewport,
2142                gesture: &mut self.runtime_state.gesture,
2143                editing_convention: self.editing_convention,
2144                current_time: input_time,
2145                clipboard: self.clipboard_backend.as_ref(),
2146                measurer: self.measurer.as_ref(),
2147                dispatched_actions: Vec::new(),
2148            };
2149            let changed = HoverController::clear(&mut ctx, point);
2150            (changed, ctx.dispatched_actions)
2151        };
2152        self.dispatch_input_actions(dispatched_actions.1)?;
2153        Ok(dispatched_actions.0)
2154    }
2155
2156    fn dispatch_input_actions(
2157        &mut self,
2158        dispatched_actions: Vec<(WidgetId, ActionEnvelope, ActionInput)>,
2159    ) -> Result<()> {
2160        for (target, action, input) in dispatched_actions {
2161            self.dispatch_node_with_input(action, target, &input)?;
2162        }
2163        Ok(())
2164    }
2165
2166    #[doc(hidden)]
2167    pub fn update_focused_ime_state(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) {
2168        let Some(ime_handler) = self.ime_handler.clone() else {
2169            return;
2170        };
2171        let Some(focused_id) = self.runtime_state.interaction.focused else {
2172            ime_handler.set_ime_allowed(false);
2173            return;
2174        };
2175
2176        let mut walk = Some(focused_id);
2177        while let Some(node_id) = walk {
2178            if let Some(any_ro) = ir.custom_render_objects.get(&node_id) {
2179                if let Some(render_obj) = crate::ui::custom_render::downcast_render_object(any_ro) {
2180                    let accepts_text = render_obj.accepts_text_input();
2181                    ime_handler.set_ime_allowed(accepts_text);
2182                    if accepts_text {
2183                        let rect =
2184                            Self::visual_node_rect(ir, layout, &self.runtime_state.scroll, node_id)
2185                                .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
2186                        if let Some(cursor_area) = render_obj.ime_cursor_area(rect) {
2187                            ime_handler.set_ime_cursor_area(cursor_area);
2188                        }
2189                    }
2190                    return;
2191                }
2192            }
2193            walk = ir.nodes.get(&node_id).and_then(|node| node.parent);
2194        }
2195
2196        let accepts_text = ir
2197            .nodes
2198            .get(&focused_id)
2199            .and_then(|node| match &node.op {
2200                Op::Semantics(semantics) => {
2201                    Some(semantics.role == fission_ir::semantics::Role::TextInput)
2202                }
2203                _ => None,
2204            })
2205            .unwrap_or(false);
2206        ime_handler.set_ime_allowed(accepts_text);
2207
2208        if accepts_text {
2209            let editing_value = self
2210                .runtime_state
2211                .text_edit
2212                .get(focused_id)
2213                .map(crate::env::TextEditState::editing_value)
2214                .or_else(|| {
2215                    let semantics = ir.nodes.get(&focused_id).and_then(|node| match &node.op {
2216                        Op::Semantics(semantics) => Some(semantics),
2217                        _ => None,
2218                    })?;
2219                    let text = semantics.value.clone().unwrap_or_default();
2220                    let (anchor, caret) =
2221                        semantics.text_selection.unwrap_or((text.len(), text.len()));
2222                    let selection = crate::TextSelection::new(
2223                        &text,
2224                        anchor,
2225                        caret,
2226                        crate::TextAffinity::Downstream,
2227                    )
2228                    .unwrap_or_else(|_| {
2229                        crate::TextSelection::collapsed(crate::TextPosition::at_end(&text))
2230                    });
2231                    Some(crate::TextEditingValue {
2232                        text,
2233                        selection,
2234                        composing: None,
2235                    })
2236                });
2237            if let Some(editing_value) = editing_value.as_ref() {
2238                ime_handler.set_editing_value(editing_value);
2239            }
2240
2241            let input_time = self.clock().current_time();
2242            let cursor_area = {
2243                let mut ctx = crate::input::ControllerContext {
2244                    ir,
2245                    layout,
2246                    text_edit: &mut self.runtime_state.text_edit,
2247                    selectable_text: &mut self.runtime_state.selectable_text,
2248                    context_menu: &mut self.runtime_state.context_menu,
2249                    interaction: &mut self.runtime_state.interaction,
2250                    scroll: &mut self.runtime_state.scroll,
2251                    viewport: &self.runtime_state.viewport,
2252                    gesture: &mut self.runtime_state.gesture,
2253                    editing_convention: self.editing_convention,
2254                    current_time: input_time,
2255                    clipboard: self.clipboard_backend.as_ref(),
2256                    measurer: self.measurer.as_ref(),
2257                    dispatched_actions: Vec::new(),
2258                };
2259                crate::input::text::TextInputController::ime_cursor_area(&mut ctx, focused_id)
2260            };
2261            if let Some(cursor_area) = cursor_area {
2262                ime_handler.set_ime_cursor_area(cursor_area);
2263            }
2264        }
2265    }
2266
2267    fn visual_node_rect(
2268        ir: &CoreIR,
2269        layout: &LayoutSnapshot,
2270        scroll: &crate::env::ScrollStateMap,
2271        node_id: WidgetId,
2272    ) -> Option<LayoutRect> {
2273        let mut rect = layout.get_node_rect(node_id)?;
2274        let mut walk = ir.nodes.get(&node_id).and_then(|node| node.parent);
2275        while let Some(parent_id) = walk {
2276            let Some(parent) = ir.nodes.get(&parent_id) else {
2277                break;
2278            };
2279            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
2280                let offset = scroll.get_offset(parent_id);
2281                match direction {
2282                    FlexDirection::Row => rect.origin.x -= offset,
2283                    FlexDirection::Column => rect.origin.y -= offset,
2284                }
2285            }
2286            walk = parent.parent;
2287        }
2288        Some(rect)
2289    }
2290
2291    fn clear_text_pending_on_blur(
2292        &mut self,
2293        old_focus: Option<WidgetId>,
2294        new_focus: Option<WidgetId>,
2295    ) {
2296        if old_focus == new_focus {
2297            return;
2298        }
2299        if let Some(old_id) = old_focus {
2300            if let Some(st) = self.runtime_state.text_edit.states.get_mut(&old_id) {
2301                st.pending_model_sync = false;
2302                st.clear_preedit();
2303            }
2304        }
2305    }
2306
2307    fn text_input_value(&self, ir: &CoreIR, id: WidgetId) -> crate::TextEditingValue {
2308        self.runtime_state
2309            .text_edit
2310            .get(id)
2311            .map(|state| state.editing_value())
2312            .unwrap_or_else(|| {
2313                let text = ir
2314                    .nodes
2315                    .get(&id)
2316                    .and_then(|node| match &node.op {
2317                        Op::Semantics(semantics) => semantics.value.clone(),
2318                        _ => None,
2319                    })
2320                    .unwrap_or_default();
2321                crate::TextEditingValue::from_text(text)
2322            })
2323    }
2324
2325    fn dispatch_text_session_action(
2326        &mut self,
2327        ir: &CoreIR,
2328        id: WidgetId,
2329        trigger: fission_ir::semantics::ActionTrigger,
2330        source: crate::TextEditSource,
2331        phase: crate::TextEditPhase,
2332    ) -> Result<()> {
2333        let Some(semantics) = ir.nodes.get(&id).and_then(|node| match &node.op {
2334            Op::Semantics(semantics)
2335                if semantics.role == fission_ir::semantics::Role::TextInput =>
2336            {
2337                Some(semantics)
2338            }
2339            _ => None,
2340        }) else {
2341            return Ok(());
2342        };
2343        let value = self.text_input_value(ir, id);
2344        if let Some((envelope, input)) = crate::input::prepare_scoped_text_session_action(
2345            ir, semantics, id, trigger, value, source, phase,
2346        ) {
2347            self.dispatch_node_with_input(envelope, id, &input)?;
2348        }
2349        Ok(())
2350    }
2351
2352    /// Applies one focus transition and all of its text-session side effects.
2353    ///
2354    /// Shell accessibility adapters and runtime focus navigation use this
2355    /// boundary so focus/blur actions, select-all-on-focus, and IME lifetime do
2356    /// not diverge by input source.
2357    #[doc(hidden)]
2358    pub fn set_focused_widget(
2359        &mut self,
2360        ir: &CoreIR,
2361        next: Option<WidgetId>,
2362        source: crate::TextEditSource,
2363    ) -> Result<bool> {
2364        let current = self.runtime_state.interaction.focused;
2365        if current == next {
2366            return Ok(false);
2367        }
2368
2369        self.clear_text_pending_on_blur(current, next);
2370        self.dispatch_custom_blur_actions(ir, current)?;
2371        if let Some(old_id) = current {
2372            if source == crate::TextEditSource::Pointer {
2373                self.dispatch_text_session_action(
2374                    ir,
2375                    old_id,
2376                    fission_ir::semantics::ActionTrigger::TapOutside,
2377                    source,
2378                    crate::TextEditPhase::TapOutside,
2379                )?;
2380            }
2381            self.dispatch_text_session_action(
2382                ir,
2383                old_id,
2384                fission_ir::semantics::ActionTrigger::Blur,
2385                source,
2386                crate::TextEditPhase::Blurred,
2387            )?;
2388        }
2389
2390        self.runtime_state.interaction.set_focused(next);
2391        if let Some(new_id) = next {
2392            let select_all = ir
2393                .custom_render_objects
2394                .get(&new_id)
2395                .and_then(crate::ui::widgets::text_input::downcast_text_input_runtime_config)
2396                .is_some_and(|config| config.select_all_on_focus);
2397            if select_all {
2398                self.pending_text_editing
2399                    .push((new_id, crate::TextEditingCommand::SelectAll));
2400            }
2401        }
2402
2403        if let Some(ime_handler) = &self.ime_handler {
2404            let accepts_text = next.is_some_and(|id| {
2405                matches!(
2406                    ir.nodes.get(&id).map(|node| &node.op),
2407                    Some(Op::Semantics(semantics))
2408                        if semantics.role == fission_ir::semantics::Role::TextInput
2409                            && !semantics.disabled
2410                            && !semantics.read_only
2411                ) || ir
2412                    .custom_render_objects
2413                    .get(&id)
2414                    .and_then(downcast_render_object)
2415                    .is_some_and(|render_object| render_object.accepts_text_input())
2416            });
2417            ime_handler.set_ime_allowed(accepts_text);
2418        }
2419
2420        if let Some(new_id) = next {
2421            self.dispatch_text_session_action(
2422                ir,
2423                new_id,
2424                fission_ir::semantics::ActionTrigger::Focus,
2425                source,
2426                crate::TextEditPhase::Focused,
2427            )?;
2428        }
2429        Ok(true)
2430    }
2431
2432    fn dispatch_custom_blur_actions(
2433        &mut self,
2434        ir: &CoreIR,
2435        old_focus: Option<WidgetId>,
2436    ) -> Result<()> {
2437        if let Some(old_id) = old_focus {
2438            if let Some(any_ro) = ir.custom_render_objects.get(&old_id) {
2439                if let Some(render_obj) = crate::ui::custom_render::downcast_render_object(any_ro) {
2440                    if render_obj.accepts_text_input() {
2441                        if let Some(ime_handler) = &self.ime_handler {
2442                            ime_handler.set_ime_allowed(false);
2443                        }
2444                    }
2445                    for (target, envelope) in render_obj.blur_actions(old_id) {
2446                        self.dispatch_node(envelope, target)?;
2447                    }
2448                }
2449            }
2450        }
2451        Ok(())
2452    }
2453
2454    pub fn hit_test(
2455        &self,
2456        point: LayoutPoint,
2457        ir: &CoreIR,
2458        snapshot: &LayoutSnapshot,
2459    ) -> Option<WidgetId> {
2460        if let Some(root) = ir.root {
2461            return self.hit_test_recursive(root, point, ir, snapshot);
2462        }
2463        None
2464    }
2465
2466    fn hit_test_recursive(
2467        &self,
2468        node_id: WidgetId,
2469        point: LayoutPoint,
2470        ir: &CoreIR,
2471        snapshot: &LayoutSnapshot,
2472    ) -> Option<WidgetId> {
2473        if let Some(geom) = snapshot.nodes.get(&node_id) {
2474            if geom.rect.contains(point) {
2475                if let Some(node) = ir.nodes.get(&node_id) {
2476                    for child in node.children.iter().rev() {
2477                        let mut child_point = point;
2478
2479                        if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
2480                            if !geom.rect.contains(point) {
2481                                continue;
2482                            }
2483                            let offset = self.runtime_state.scroll.get_offset(node_id);
2484                            match direction {
2485                                FlexDirection::Row => child_point.x += offset,
2486                                FlexDirection::Column => child_point.y += offset,
2487                            }
2488                        }
2489
2490                        if let Op::Layout(LayoutOp::Transform { transform }) = &node.op {
2491                            let mat = Mat4::from_cols_array(transform);
2492                            // We need to transform the point relative to the node's origin?
2493                            // Layout coordinates are relative to the parent.
2494                            // In hit_test_recursive, `point` is relative to current `node_id`?
2495                            // No, `point` is relative to the `geom.rect.origin` of `node_id`?
2496                            // Let's check recursion.
2497
2498                            // hit_test starts at root with absolute point.
2499                            // recursion: `child_point = point`.
2500                            // wait, `hit_test_recursive` doesn't subtract location?
2501                            // Ah, I see: `if geom.rect.contains(point)`.
2502                            // This implies `point` is ABSOLUTE.
2503
2504                            // If `point` is absolute, and we want to transform into child local space:
2505                            // 1. Move point to node local space: `point - node_pos`.
2506                            // 2. Apply inverse transform.
2507                            // 3. (Implicitly) Move back or keep local?
2508                            // Recursive call expects absolute point?
2509                            // No, `hit_test_recursive` calls itself with `child_point`.
2510                            // If it expects absolute point, then `Transform` node doesn't work well with absolute recursion.
2511
2512                            // Actually, my `hit_test_recursive` impl seems to assume absolute points for all nodes?
2513                            // `if geom.rect.contains(point)` confirms it.
2514
2515                            // So if I have a Transform, I MUST return a point that looks "absolute" to the child
2516                            // but is logically transformed.
2517                            // Absolute child rect is NOT transformed by LayoutEngine.
2518
2519                            // This means `geom.rect` for children of a Transform is WRONG if they are visually moved.
2520                            // BUT LayoutEngine doesn't know about Matrix4.
2521                            // So the children think they are at `(0,0)` relative to parent.
2522
2523                            // To make hit test work:
2524                            // 1. Convert absolute `point` to `node_local_point`.
2525                            // 2. Apply inverse transform to `node_local_point` -> `transformed_local_point`.
2526                            // 3. Convert `transformed_local_point` back to absolute for children -> `transformed_absolute_point`.
2527
2528                            let local_x = point.x - geom.rect.origin.x;
2529                            let local_y = point.y - geom.rect.origin.y;
2530
2531                            let p = Vec4::new(local_x, local_y, 0.0, 1.0);
2532                            let inv = mat.inverse();
2533                            let transformed = inv * p;
2534
2535                            child_point = LayoutPoint::new(
2536                                transformed.x + geom.rect.origin.x,
2537                                transformed.y + geom.rect.origin.y,
2538                            );
2539                        }
2540
2541                        if let Some(hit) =
2542                            self.hit_test_recursive(*child, child_point, ir, snapshot)
2543                        {
2544                            return Some(hit);
2545                        }
2546                    }
2547
2548                    match &node.op {
2549                        Op::Paint(_)
2550                        | Op::Layout(LayoutOp::Scroll { .. })
2551                        | Op::Layout(LayoutOp::Embed { .. }) => return Some(node_id),
2552                        _ => return None,
2553                    }
2554                }
2555                return None;
2556            }
2557        }
2558        None
2559    }
2560
2561    /// Extract the pointer position from an input event, if applicable.
2562    ///
2563    /// Used by the custom-render-object event dispatch to perform a hit-test
2564    /// before delegating to render objects.  Returns `None` for keyboard and
2565    /// other non-positional events.
2566    fn event_point(event: &InputEvent) -> Option<LayoutPoint> {
2567        match event {
2568            InputEvent::Pointer(PointerEvent::Down { point, .. })
2569            | InputEvent::Pointer(PointerEvent::Up { point, .. })
2570            | InputEvent::Pointer(PointerEvent::Move { point, .. })
2571            | InputEvent::Pointer(PointerEvent::Cancel { point, .. })
2572            | InputEvent::Pointer(PointerEvent::Scroll { point, .. })
2573            | InputEvent::Pointer(PointerEvent::Magnify { point, .. }) => Some(*point),
2574            _ => None,
2575        }
2576    }
2577
2578    fn find_autofocus_node(ir: &CoreIR) -> Option<WidgetId> {
2579        fn walk(ir: &CoreIR, node_id: WidgetId) -> Option<WidgetId> {
2580            let node = ir.nodes.get(&node_id)?;
2581            if let Op::Semantics(semantics) = &node.op {
2582                if semantics.autofocus && semantics.focusable && !semantics.disabled {
2583                    return Some(node_id);
2584                }
2585            }
2586            for child_id in &node.children {
2587                if let Some(found) = walk(ir, *child_id) {
2588                    return Some(found);
2589                }
2590            }
2591            None
2592        }
2593
2594        ir.root.and_then(|root| walk(ir, root))
2595    }
2596
2597    pub fn reconcile_resources(
2598        &mut self,
2599        declarations: Vec<RuntimeResourceDeclaration>,
2600    ) -> Result<()> {
2601        let now = self.clock().current_time();
2602        let mut existing = std::mem::take(&mut self.active_resources);
2603        let mut next = HashMap::new();
2604
2605        for declaration in declarations {
2606            let key = declaration.key.clone();
2607            match existing.remove(&key) {
2608                Some(current)
2609                    if current.policy == declaration.policy
2610                        && current.deps == declaration.deps
2611                        && current.matches_kind(&declaration.kind) =>
2612                {
2613                    next.insert(key, current);
2614                }
2615                Some(current) if declaration.policy == ResourcePolicy::PreserveOnChange => {
2616                    next.insert(key, current);
2617                }
2618                Some(current) => {
2619                    self.stop_resource(&key, &current);
2620                    let replacement = self.start_resource(declaration, now);
2621                    next.insert(key, replacement);
2622                }
2623                None => {
2624                    let resource = self.start_resource(declaration, now);
2625                    next.insert(key, resource);
2626                }
2627            }
2628        }
2629
2630        for (key, resource) in existing {
2631            self.stop_resource(&key, &resource);
2632        }
2633
2634        self.active_resources = next;
2635        Ok(())
2636    }
2637
2638    pub fn resource_generation(&self, key: &str) -> Option<u64> {
2639        self.active_resources
2640            .get(key)
2641            .map(|resource| resource.generation)
2642    }
2643
2644    pub fn is_resource_current(&self, resource: &ResourceExecutionContext) -> bool {
2645        self.resource_generation(&resource.key) == Some(resource.generation)
2646    }
2647
2648    fn start_resource(
2649        &mut self,
2650        declaration: RuntimeResourceDeclaration,
2651        now: CurrentTime,
2652    ) -> ActiveResource {
2653        let generation = self.next_resource_generation;
2654        self.next_resource_generation += 1;
2655
2656        let context = ResourceExecutionContext {
2657            key: declaration.key.clone(),
2658            generation,
2659        };
2660
2661        let kind = match declaration.kind {
2662            RuntimeResourceKind::Job(mut job) => {
2663                job.effect.resource = Some(context);
2664                self.enqueue_effect(job.effect);
2665                ActiveResourceKind::Job
2666            }
2667            RuntimeResourceKind::Service(mut service) => {
2668                service.effect.resource = Some(context);
2669                let (service_name, slot_key) = match &service.effect.effect {
2670                    crate::Effect::StartService(payload) => {
2671                        (payload.service_name.clone(), payload.slot_key.clone())
2672                    }
2673                    _ => unreachable!("service resource must lower to StartService"),
2674                };
2675                self.enqueue_effect(service.effect);
2676                ActiveResourceKind::Service {
2677                    service_name,
2678                    slot_key,
2679                }
2680            }
2681            RuntimeResourceKind::Timer(timer) => self.start_timer_resource(timer, now),
2682        };
2683
2684        ActiveResource {
2685            generation,
2686            deps: declaration.deps,
2687            policy: declaration.policy,
2688            kind,
2689        }
2690    }
2691
2692    fn start_timer_resource(&self, timer: TimerResource, now: CurrentTime) -> ActiveResourceKind {
2693        let interval_ms = timer.interval_ms.max(1);
2694        ActiveResourceKind::Timer {
2695            interval_ms,
2696            payload: timer.payload,
2697            on_tick: timer.on_tick,
2698            next_fire_at: if timer.immediate {
2699                now
2700            } else {
2701                now.saturating_add(interval_ms)
2702            },
2703        }
2704    }
2705
2706    fn stop_resource(&mut self, key: &str, resource: &ActiveResource) {
2707        if let ActiveResourceKind::Service {
2708            service_name,
2709            slot_key,
2710        } = &resource.kind
2711        {
2712            self.enqueue_effect(EffectEnvelope {
2713                req_id: 0,
2714                effect: crate::Effect::StopService(ServiceStopPayload {
2715                    service_name: service_name.clone(),
2716                    slot_key: slot_key.clone(),
2717                }),
2718                on_ok: None,
2719                on_err: None,
2720                service_bindings: None,
2721                resource: Some(ResourceExecutionContext {
2722                    key: key.to_string(),
2723                    generation: resource.generation,
2724                }),
2725            });
2726        }
2727    }
2728}
2729
2730impl ActiveResource {
2731    fn matches_kind(&self, kind: &RuntimeResourceKind) -> bool {
2732        matches!(
2733            (&self.kind, kind),
2734            (ActiveResourceKind::Job, RuntimeResourceKind::Job(_))
2735                | (
2736                    ActiveResourceKind::Timer { .. },
2737                    RuntimeResourceKind::Timer(_)
2738                )
2739                | (
2740                    ActiveResourceKind::Service { .. },
2741                    RuntimeResourceKind::Service(_)
2742                )
2743        )
2744    }
2745}