Skip to main content

fission_core/
runtime.rs

1use crate::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, 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    /// Effects emitted by reducers, awaiting platform execution.
131    pub pending_effects: Vec<EffectEnvelope>,
132    /// Post-layout scroll requests that need computed geometry before applying.
133    pending_scroll_into_view: Vec<PendingScrollIntoView>,
134    /// Active focus barriers and the targets restored as each barrier closes.
135    focus_barriers: Vec<FocusBarrierFrame>,
136    /// Monotonically increasing counter for deterministic request id generation.
137    pub next_req_id: u64,
138    /// Declarative runtime resources that currently exist.
139    active_resources: HashMap<String, ActiveResource>,
140    /// Monotonically increasing generation counter for runtime resources.
141    next_resource_generation: u64,
142}
143
144impl Default for Runtime {
145    fn default() -> Self {
146        let mut runtime = Self {
147            reducers: HashMap::new(),
148            persistent_reducers: HashMap::new(),
149            effect_callbacks: Arc::new(EffectCallbackRegistry::new()),
150            app_states: HashMap::new(),
151            runtime_state: RuntimeState::default(),
152            measurer: None,
153            clipboard_backend: None,
154            ime_handler: None,
155            pending_effects: Vec::new(),
156            pending_scroll_into_view: Vec::new(),
157            focus_barriers: Vec::new(),
158            next_req_id: 0,
159            active_resources: HashMap::new(),
160            next_resource_generation: 1,
161        };
162
163        runtime
164            .add_global_state(Box::new(runtime.runtime_state.local_widget_state.clone()))
165            .expect("Failed to add local widget state store");
166
167        runtime
168            .add_global_state(Box::new(Clock::default()))
169            .expect("Failed to add Clock state");
170
171        runtime.register_base_reducers();
172
173        runtime
174    }
175}
176
177impl Runtime {
178    pub fn with_measurer(mut self, measurer: Arc<dyn TextMeasurer>) -> Self {
179        self.measurer = Some(measurer);
180        self
181    }
182
183    pub fn with_clipboard(mut self, backend: Arc<dyn Clipboard>) -> Self {
184        self.clipboard_backend = Some(backend);
185        self
186    }
187
188    pub fn with_ime_handler(mut self, handler: Arc<dyn ImeHandler>) -> Self {
189        self.ime_handler = Some(handler);
190        self
191    }
192
193    /// Reconciles keyboard focus with the focus barriers in a newly lowered IR.
194    ///
195    /// New barriers capture focus, nested barriers retain a restoration chain,
196    /// and closing barriers restores the most recent valid target.
197    pub fn reconcile_focus(&mut self, ir: &CoreIR) -> Result<bool> {
198        use crate::hit_test::{
199            focus_barriers_in_tree_order, get_all_focusable_nodes, is_descendant_or_self,
200            is_enabled_focus_node, preferred_focus_node_in_scope,
201        };
202
203        let active_barriers = focus_barriers_in_tree_order(ir);
204        let common_prefix = self
205            .focus_barriers
206            .iter()
207            .map(|frame| frame.id)
208            .zip(active_barriers.iter().copied())
209            .take_while(|(tracked, active)| tracked == active)
210            .count();
211        let popped_any = self.focus_barriers.len() > common_prefix;
212        let mut restore_target = None;
213        while self.focus_barriers.len() > common_prefix {
214            restore_target = self
215                .focus_barriers
216                .pop()
217                .and_then(|frame| frame.restore_target);
218        }
219
220        for (index, barrier_id) in active_barriers
221            .iter()
222            .copied()
223            .enumerate()
224            .skip(common_prefix)
225        {
226            let restore = if index == 0 {
227                restore_target
228                    .filter(|id| is_enabled_focus_node(ir, *id))
229                    .or_else(|| {
230                        self.runtime_state
231                            .interaction
232                            .focused
233                            .filter(|id| is_enabled_focus_node(ir, *id))
234                    })
235            } else {
236                let parent_barrier_id = active_barriers[index - 1];
237                self.runtime_state
238                    .interaction
239                    .focused
240                    .filter(|id| {
241                        is_enabled_focus_node(ir, *id)
242                            && is_descendant_or_self(ir, *id, parent_barrier_id)
243                            && !is_descendant_or_self(ir, *id, barrier_id)
244                    })
245                    .or_else(|| preferred_focus_node_in_scope(ir, parent_barrier_id))
246            };
247            self.focus_barriers.push(FocusBarrierFrame {
248                id: barrier_id,
249                restore_target: restore,
250            });
251        }
252
253        let current = self.runtime_state.interaction.focused;
254        let next = if let Some(barrier_id) = active_barriers.last().copied() {
255            current
256                .filter(|id| {
257                    is_enabled_focus_node(ir, *id) && is_descendant_or_self(ir, *id, barrier_id)
258                })
259                .or_else(|| {
260                    restore_target.filter(|id| {
261                        is_enabled_focus_node(ir, *id) && is_descendant_or_self(ir, *id, barrier_id)
262                    })
263                })
264                .or_else(|| preferred_focus_node_in_scope(ir, barrier_id))
265        } else if popped_any {
266            restore_target
267                .filter(|id| is_enabled_focus_node(ir, *id))
268                .or_else(|| {
269                    let nodes = get_all_focusable_nodes(ir);
270                    nodes
271                        .iter()
272                        .copied()
273                        .find(|id| {
274                            matches!(
275                                ir.nodes.get(id).map(|node| &node.op),
276                                Some(Op::Semantics(semantics)) if semantics.autofocus
277                            )
278                        })
279                        .or_else(|| nodes.first().copied())
280                })
281        } else {
282            current.filter(|id| is_enabled_focus_node(ir, *id))
283        };
284
285        if current == next {
286            return Ok(false);
287        }
288
289        self.clear_text_pending_on_blur(current, next);
290        self.dispatch_custom_blur_actions(ir, current)?;
291        self.runtime_state.interaction.set_focused(next);
292        if let Some(ime_handler) = &self.ime_handler {
293            let accepts_text = next.is_some_and(|id| {
294                matches!(
295                    ir.nodes.get(&id).map(|node| &node.op),
296                    Some(Op::Semantics(semantics))
297                        if semantics.role == fission_ir::semantics::Role::TextInput
298                ) || ir
299                    .custom_render_objects
300                    .get(&id)
301                    .and_then(downcast_render_object)
302                    .is_some_and(|render_object| render_object.accepts_text_input())
303            });
304            ime_handler.set_ime_allowed(accepts_text);
305        }
306        Ok(true)
307    }
308
309    pub fn caret_from_point_in_text(
310        &self,
311        value: &str,
312        font_size: f32,
313        viewport_x: f32,
314        viewport_w: f32,
315        content_w: f32,
316        scroll_offset: f32,
317        point_x: f32,
318    ) -> usize {
319        crate::input::text::caret_from_point_in_text(
320            self.measurer.as_ref(),
321            value,
322            font_size,
323            viewport_x,
324            viewport_w,
325            content_w,
326            scroll_offset,
327            point_x,
328        )
329    }
330
331    // Helper for manual reducer registration (internal use)
332    pub fn register_reducer<S: GlobalState + 'static>(
333        &mut self,
334        action_id: ActionId,
335        reducer_fn: crate::action::Reducer<S>,
336    ) -> Result<()> {
337        let state_type_id = TypeId::of::<S>();
338
339        // Wrap legacy 3-arg reducer into 5-arg BoxedReducer
340        let boxed_reducer: BoxedReducer = Box::new(
341            move |app_states: &mut HashMap<TypeId, Box<dyn GlobalState>>,
342                  action: &ActionEnvelope,
343                  target: WidgetId,
344                  _effects: &mut Vec<EffectEnvelope>,
345                  _input: &ActionInput,
346                  _callback_registry|
347                  -> Result<()> {
348                if let Some(state_box) = app_states.get_mut(&state_type_id) {
349                    let concrete_state = state_box.downcast_mut::<S>().ok_or_else(|| {
350                        anyhow!("Failed to downcast GlobalState to concrete type for reducer")
351                    })?;
352                    reducer_fn(concrete_state, action, target.into())
353                } else {
354                    anyhow::bail!("Target GlobalState for reducer not found in runtime.");
355                }
356            },
357        );
358
359        self.reducers
360            .entry(action_id)
361            .or_default()
362            .push(boxed_reducer);
363        Ok(())
364    }
365
366    pub fn register_base_reducers(&mut self) {
367        use crate::{AdvanceTo, Tick, ADVANCE_TO_ACTION_ID, TICK_ACTION_ID};
368
369        self.register_reducer::<Clock>(
370            *TICK_ACTION_ID,
371            |state: &mut Clock, action: &ActionEnvelope, _target| {
372                let tick_action: Tick = serde_json::from_slice(&action.payload)
373                    .map_err(|e| anyhow!("Failed to deserialize Tick: {}", e))?;
374                state.advance_by(tick_action.dt)
375            },
376        )
377        .expect("Failed to register Tick reducer");
378
379        self.register_reducer::<Clock>(
380            *ADVANCE_TO_ACTION_ID,
381            |state: &mut Clock, action: &ActionEnvelope, _target| {
382                let advance_action: AdvanceTo = serde_json::from_slice(&action.payload)
383                    .map_err(|e| anyhow!("Failed to deserialize AdvanceTo: {}", e))?;
384                state.set_to(advance_action.time)
385            },
386        )
387        .expect("Failed to register AdvanceTo reducer");
388    }
389
390    pub fn clear_reducers(&mut self) {
391        self.reducers.clear();
392        self.register_base_reducers();
393    }
394
395    pub fn absorb_registry<S: GlobalState>(&mut self, registry: ActionRegistry<S>) {
396        let new_reducers = registry.into_runtime_reducers();
397        for (id, mut list) in new_reducers {
398            self.reducers.entry(id).or_default().append(&mut list);
399        }
400    }
401
402    /// Registers reducers that should survive `clear_reducers()` calls.
403    ///
404    /// This is intended for app-level "global" handlers (e.g. system effects) that
405    /// are installed once at app startup, while per-frame widget handlers are
406    /// regenerated every frame via `BuildCtx` and `absorb_registry`.
407    pub fn absorb_persistent_registry<S: GlobalState>(&mut self, registry: ActionRegistry<S>) {
408        let new_reducers = registry.into_runtime_reducers();
409        for (id, mut list) in new_reducers {
410            self.persistent_reducers
411                .entry(id)
412                .or_default()
413                .append(&mut list);
414        }
415    }
416
417    pub fn clock(&self) -> &Clock {
418        self.get_global_state::<Clock>()
419            .expect("Clock state must always be present")
420    }
421
422    pub fn get_global_state<S: GlobalState + 'static>(&self) -> Option<&S> {
423        self.app_states
424            .get(&TypeId::of::<S>())
425            .and_then(|s_box| s_box.downcast_ref::<S>())
426    }
427
428    pub fn get_global_state_mut<S: GlobalState + 'static>(&mut self) -> Option<&mut S> {
429        self.app_states
430            .get_mut(&TypeId::of::<S>())
431            .and_then(|s_box| s_box.downcast_mut::<S>())
432    }
433
434    pub fn add_global_state<S: GlobalState + 'static>(&mut self, state: Box<S>) -> Result<()> {
435        let type_id = TypeId::of::<S>();
436        if self.app_states.insert(type_id, state).is_some() {
437            anyhow::bail!("Global state of this type already registered.");
438        }
439        Ok(())
440    }
441
442    pub fn with_global_state<S: GlobalState + 'static>(mut self, state: S) -> Self {
443        self.app_states.insert(TypeId::of::<S>(), Box::new(state));
444        self
445    }
446
447    #[doc(hidden)]
448    pub fn get_app_state<S: GlobalState + 'static>(&self) -> Option<&S> {
449        self.get_global_state::<S>()
450    }
451
452    #[doc(hidden)]
453    pub fn get_app_state_mut<S: GlobalState + 'static>(&mut self) -> Option<&mut S> {
454        self.get_global_state_mut::<S>()
455    }
456
457    #[doc(hidden)]
458    pub fn add_app_state<S: GlobalState + 'static>(&mut self, state: Box<S>) -> Result<()> {
459        self.add_global_state(state)
460    }
461
462    pub fn dispatch(&mut self, action: ActionEnvelope, target: WidgetId) -> Result<()> {
463        self.dispatch_with_input(action, target, &ActionInput::None)
464    }
465
466    fn enqueue_effect(&mut self, mut envelope: EffectEnvelope) {
467        envelope.req_id = self.next_req_id;
468        self.next_req_id += 1;
469        self.pending_effects.push(envelope);
470    }
471
472    /// Rejects every queued host effect and its one-shot completion callbacks.
473    ///
474    /// This is only for shells that intentionally do not execute any effects
475    /// produced by a dispatch. Executing or retaining an effect after calling
476    /// this method would make its completion callback unavailable.
477    ///
478    /// Returns the total number of discarded effect envelopes and callbacks.
479    #[doc(hidden)]
480    pub fn discard_pending_effects(&mut self) -> usize {
481        let discarded = self.pending_effects.len() + self.effect_callbacks.clear();
482        self.pending_effects.clear();
483        discarded
484    }
485
486    pub fn dispatch_with_input(
487        &mut self,
488        action: ActionEnvelope,
489        target: WidgetId,
490        input: &ActionInput,
491    ) -> Result<()> {
492        self.dispatch_node_with_input(action, target.into(), input)
493    }
494
495    fn dispatch_node(&mut self, action: ActionEnvelope, target: WidgetId) -> Result<()> {
496        self.dispatch_node_with_input(action, target, &ActionInput::None)
497    }
498
499    fn dispatch_node_with_input(
500        &mut self,
501        action: ActionEnvelope,
502        target: WidgetId,
503        input: &ActionInput,
504    ) -> Result<()> {
505        let action_id = action.id;
506        let result = self.try_dispatch_node_with_input(action, target, input);
507        if let Err(error) = &result {
508            crate::registry::emit_action_dispatch_failure(action_id, target, error);
509        }
510        result
511    }
512
513    fn try_dispatch_node_with_input(
514        &mut self,
515        action: ActionEnvelope,
516        target: WidgetId,
517        input: &ActionInput,
518    ) -> Result<()> {
519        diag::emit(
520            diag::DiagCategory::Input,
521            diag::DiagLevel::Debug,
522            diag::DiagEventKind::InputEvent {
523                kind: "dispatch_start".into(),
524                target: Some(target.as_u128()),
525                position: None,
526            },
527        );
528
529        // Delegate video actions to media module
530        if crate::media::handle_video_action(&mut self.runtime_state.video, &action)? {
531            return Ok(());
532        }
533
534        let action_id = action.id;
535
536        if crate::scoped_action_handlers::dispatch_scoped_action_handler(&action, target, input)? {
537            return Ok(());
538        }
539
540        // Collect effects from this dispatch (both persistent and per-frame reducers).
541        let mut effects = Vec::new();
542        let callback_registry = self.effect_callbacks.clone();
543
544        let mut callback_reducers = callback_registry.take(action_id);
545        for reducer_wrapper in callback_reducers.iter_mut() {
546            reducer_wrapper(
547                &mut self.app_states,
548                &action,
549                target,
550                &mut effects,
551                input,
552                &callback_registry,
553            )?;
554        }
555
556        if let Some(reducers) = self.persistent_reducers.get_mut(&action_id) {
557            diag::emit(
558                diag::DiagCategory::Input,
559                diag::DiagLevel::Debug,
560                diag::DiagEventKind::InputEvent {
561                    kind: format!("persistent_reducers:{}", reducers.len()),
562                    target: Some(target.as_u128()),
563                    position: None,
564                },
565            );
566
567            let mut temp_reducers: Vec<BoxedReducer> = reducers.drain(..).collect();
568            let dispatch_result = temp_reducers.iter_mut().try_for_each(|reducer_wrapper| {
569                reducer_wrapper(
570                    &mut self.app_states,
571                    &action,
572                    target,
573                    &mut effects,
574                    input,
575                    &callback_registry,
576                )
577            });
578            reducers.extend(temp_reducers);
579            dispatch_result?;
580        }
581
582        if let Some(reducers) = self.reducers.get_mut(&action_id) {
583            diag::emit(
584                diag::DiagCategory::Input,
585                diag::DiagLevel::Debug,
586                diag::DiagEventKind::InputEvent {
587                    kind: format!("reducers:{}", reducers.len()),
588                    target: Some(target.as_u128()),
589                    position: None,
590                },
591            );
592
593            let mut temp_reducers: Vec<BoxedReducer> = reducers.drain(..).collect();
594            let dispatch_result = temp_reducers.iter_mut().try_for_each(|reducer_wrapper| {
595                reducer_wrapper(
596                    &mut self.app_states,
597                    &action,
598                    target,
599                    &mut effects,
600                    input,
601                    &callback_registry,
602                )
603            });
604            reducers.extend(temp_reducers);
605            dispatch_result?;
606        }
607
608        for envelope in effects {
609            self.enqueue_effect(envelope);
610        }
611
612        diag::emit(
613            diag::DiagCategory::Input,
614            diag::DiagLevel::Debug,
615            diag::DiagEventKind::InputEvent {
616                kind: "dispatch_end".into(),
617                target: Some(target.as_u128()),
618                position: None,
619            },
620        );
621        Ok(())
622    }
623
624    pub fn tick(&mut self, dt: CurrentTime) -> Result<TickResult> {
625        use crate::Tick;
626        let action = Tick { dt };
627        let envelope: ActionEnvelope = action.into();
628        self.dispatch_node(envelope, WidgetId::derived(0, &[0]))?;
629
630        let resource_actions_dispatched = self.tick_resource_timers()?;
631
632        let current_time = self.clock().current_time();
633        let changed_motions =
634            crate::motion::tick_motion(&mut self.runtime_state.motion, current_time);
635        Ok(TickResult {
636            changed_motions,
637            resource_actions_dispatched,
638        })
639    }
640
641    fn tick_resource_timers(&mut self) -> Result<usize> {
642        let now = self.clock().current_time();
643        let mut ticks = Vec::new();
644
645        for resource in self.active_resources.values_mut() {
646            if let ActiveResourceKind::Timer {
647                interval_ms,
648                payload,
649                on_tick,
650                next_fire_at,
651            } = &mut resource.kind
652            {
653                let Some(action) = on_tick.clone() else {
654                    continue;
655                };
656
657                let interval_ms = (*interval_ms).max(1);
658                while now >= *next_fire_at {
659                    ticks.push((action.clone(), payload.clone()));
660                    *next_fire_at = next_fire_at.saturating_add(interval_ms);
661                }
662            }
663        }
664
665        let dispatched = ticks.len();
666        for (action, payload) in ticks {
667            self.dispatch_node_with_input(
668                action,
669                WidgetId::derived(0, &[0]),
670                &ActionInput::TimerTick { payload },
671            )?;
672        }
673
674        Ok(dispatched)
675    }
676
677    pub fn sync_motion_declarations(
678        &mut self,
679        declarations: &[crate::MotionDeclaration],
680        layout: Option<&LayoutSnapshot>,
681    ) -> Vec<(WidgetId, crate::MotionPropertyId)> {
682        let current_time = self.clock().current_time();
683        let snapshot = self.runtime_state.clone();
684        let result = crate::motion::sync_motion_declarations(
685            &mut self.runtime_state.motion,
686            declarations,
687            &snapshot,
688            layout,
689            current_time,
690        );
691        result.changed
692    }
693
694    pub fn sync_video_nodes(&mut self, registrations: &[VideoRegistration]) {
695        let mut seen: HashSet<WidgetId> = HashSet::new();
696
697        for reg in registrations {
698            seen.insert(reg.node_id);
699            let entry = self
700                .runtime_state
701                .video
702                .states
703                .entry(reg.node_id)
704                .or_insert_with(crate::env::VideoState::default);
705            entry.asset_source = reg.source.clone();
706            entry.looped = reg.loop_playback;
707            entry.audio = reg.audio.clone();
708            if reg.autoplay && entry.status == VideoStatus::Stopped {
709                entry.status = VideoStatus::Playing;
710            }
711        }
712
713        self.runtime_state
714            .video
715            .states
716            .retain(|node_id, _| seen.contains(node_id));
717    }
718
719    pub fn sync_web_nodes(&mut self, registrations: &[crate::registry::WebRegistration]) {
720        let mut seen: HashSet<WidgetId> = HashSet::new();
721
722        for reg in registrations {
723            seen.insert(reg.node_id);
724            let entry = self
725                .runtime_state
726                .web
727                .states
728                .entry(reg.node_id)
729                .or_insert_with(crate::env::WebState::default);
730
731            // Only update URL if it changes to avoid reload loops
732            if entry.url != reg.url {
733                entry.url = reg.url.clone();
734                entry.loading = true; // Assume loading starts
735            }
736            entry.user_agent = reg.user_agent.clone();
737        }
738
739        self.runtime_state
740            .web
741            .states
742            .retain(|node_id, _| seen.contains(node_id));
743    }
744
745    /// Queues a runtime effect that must be resolved by the core runtime.
746    ///
747    /// Shells call this for effects that require runtime-owned state or a
748    /// post-layout pass instead of a host capability executor.
749    pub fn queue_runtime_effect(&mut self, effect: RuntimeEffect) -> bool {
750        match effect {
751            RuntimeEffect::ScrollIntoView(request) => {
752                self.queue_scroll_into_view(request);
753                true
754            }
755            RuntimeEffect::Cancel { .. } | RuntimeEffect::ReleaseResource { .. } => false,
756        }
757    }
758
759    /// Queues a post-layout request to reveal a widget in a scroll container.
760    pub fn queue_scroll_into_view(&mut self, request: ScrollIntoViewRequest) {
761        self.pending_scroll_into_view.push(PendingScrollIntoView {
762            request,
763            retries_remaining: 1,
764        });
765    }
766
767    fn drain_scroll_into_view_effects(&mut self) {
768        let pending = std::mem::take(&mut self.pending_effects);
769
770        for env in pending {
771            let EffectEnvelope {
772                req_id,
773                effect,
774                on_ok,
775                on_err,
776                service_bindings,
777                resource,
778            } = env;
779
780            match effect {
781                Effect::Runtime(RuntimeEffect::ScrollIntoView(request)) => {
782                    self.queue_scroll_into_view(request);
783                }
784                retained => self.pending_effects.push(EffectEnvelope {
785                    req_id,
786                    effect: retained,
787                    on_ok,
788                    on_err,
789                    service_bindings,
790                    resource,
791                }),
792            }
793        }
794    }
795
796    fn apply_pending_scroll_into_view(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) -> bool {
797        self.drain_scroll_into_view_effects();
798
799        let mut needs_follow_up_frame = false;
800        let pending = std::mem::take(&mut self.pending_scroll_into_view);
801
802        for mut pending_request in pending {
803            match self.apply_scroll_into_view(&pending_request.request, ir, layout) {
804                ScrollIntoViewOutcome::Applied { changed } => {
805                    needs_follow_up_frame |= changed;
806                }
807                ScrollIntoViewOutcome::Retry if pending_request.retries_remaining > 0 => {
808                    pending_request.retries_remaining -= 1;
809                    self.pending_scroll_into_view.push(pending_request);
810                    needs_follow_up_frame = true;
811                }
812                ScrollIntoViewOutcome::Retry | ScrollIntoViewOutcome::Ignored => {}
813            }
814        }
815
816        needs_follow_up_frame
817    }
818
819    fn apply_scroll_into_view(
820        &mut self,
821        request: &ScrollIntoViewRequest,
822        ir: &CoreIR,
823        layout: &LayoutSnapshot,
824    ) -> ScrollIntoViewOutcome {
825        let Some(target_geom) = layout.get_node_geometry(request.target) else {
826            Self::emit_scroll_into_view_diag("missing_target", request, None);
827            return ScrollIntoViewOutcome::Retry;
828        };
829
830        let Some(container_id) = self.resolve_scroll_container(request, ir, layout) else {
831            Self::emit_scroll_into_view_diag("missing_container", request, None);
832            return ScrollIntoViewOutcome::Retry;
833        };
834
835        if !Self::is_descendant_or_self(ir, request.target, container_id) {
836            Self::emit_scroll_into_view_diag("target_not_descendant", request, Some(container_id));
837            return ScrollIntoViewOutcome::Ignored;
838        }
839
840        let Some(container_geom) = layout.get_node_geometry(container_id) else {
841            Self::emit_scroll_into_view_diag(
842                "missing_container_layout",
843                request,
844                Some(container_id),
845            );
846            return ScrollIntoViewOutcome::Retry;
847        };
848
849        let Some(direction) = Self::scroll_direction(ir, container_id) else {
850            Self::emit_scroll_into_view_diag("not_scroll_container", request, Some(container_id));
851            return ScrollIntoViewOutcome::Ignored;
852        };
853
854        if !Self::axis_matches(request.axis, direction) {
855            Self::emit_scroll_into_view_diag("axis_mismatch", request, Some(container_id));
856            return ScrollIntoViewOutcome::Ignored;
857        }
858
859        if matches!(request.behavior, ScrollBehavior::Smooth) {
860            Self::emit_scroll_into_view_diag(
861                "smooth_resolved_as_instant",
862                request,
863                Some(container_id),
864            );
865        }
866
867        let current_offset = self.runtime_state.scroll.get_offset(container_id);
868        let new_offset = match direction {
869            FlexDirection::Column => Self::compute_scroll_offset(
870                current_offset,
871                target_geom.rect.y() - container_geom.rect.y(),
872                target_geom.rect.height(),
873                container_geom.rect.height(),
874                container_geom.content_size.height,
875                request.padding[2],
876                request.padding[3],
877                request.alignment,
878                request.if_needed,
879            ),
880            FlexDirection::Row => Self::compute_scroll_offset(
881                current_offset,
882                target_geom.rect.x() - container_geom.rect.x(),
883                target_geom.rect.width(),
884                container_geom.rect.width(),
885                container_geom.content_size.width,
886                request.padding[0],
887                request.padding[1],
888                request.alignment,
889                request.if_needed,
890            ),
891        };
892
893        if (new_offset - current_offset).abs() > f32::EPSILON {
894            self.runtime_state
895                .scroll
896                .set_offset(container_id, new_offset);
897            ScrollIntoViewOutcome::Applied { changed: true }
898        } else {
899            ScrollIntoViewOutcome::Applied { changed: false }
900        }
901    }
902
903    fn resolve_scroll_container(
904        &self,
905        request: &ScrollIntoViewRequest,
906        ir: &CoreIR,
907        layout: &LayoutSnapshot,
908    ) -> Option<WidgetId> {
909        if let Some(container) = request.container {
910            return ir
911                .nodes
912                .contains_key(&container)
913                .then_some(container)
914                .filter(|id| layout.get_node_geometry(*id).is_some());
915        }
916
917        let mut current = ir.nodes.get(&request.target)?.parent;
918        while let Some(node_id) = current {
919            if let Some(direction) = Self::scroll_direction(ir, node_id) {
920                if Self::axis_matches(request.axis, direction)
921                    && layout.get_node_geometry(node_id).is_some()
922                {
923                    return Some(node_id);
924                }
925            }
926            current = ir.nodes.get(&node_id).and_then(|node| node.parent);
927        }
928
929        None
930    }
931
932    fn scroll_direction(ir: &CoreIR, node_id: WidgetId) -> Option<FlexDirection> {
933        match ir.nodes.get(&node_id).map(|node| &node.op) {
934            Some(Op::Layout(LayoutOp::Scroll { direction, .. })) => Some(*direction),
935            _ => None,
936        }
937    }
938
939    fn axis_matches(axis: ScrollAxis, direction: FlexDirection) -> bool {
940        matches!(
941            (axis, direction),
942            (ScrollAxis::Both, _)
943                | (ScrollAxis::Vertical, FlexDirection::Column)
944                | (ScrollAxis::Horizontal, FlexDirection::Row)
945        )
946    }
947
948    fn is_descendant_or_self(ir: &CoreIR, target: WidgetId, ancestor: WidgetId) -> bool {
949        let mut current = Some(target);
950        while let Some(node_id) = current {
951            if node_id == ancestor {
952                return true;
953            }
954            current = ir.nodes.get(&node_id).and_then(|node| node.parent);
955        }
956        false
957    }
958
959    fn compute_scroll_offset(
960        current_offset: f32,
961        target_content_start: f32,
962        target_size: f32,
963        viewport_size: f32,
964        content_size: f32,
965        padding_start: f32,
966        padding_end: f32,
967        alignment: ScrollAlignment,
968        if_needed: bool,
969    ) -> f32 {
970        let current_offset = Self::finite_or_zero(current_offset).max(0.0);
971        let viewport_size = Self::finite_or_zero(viewport_size).max(0.0);
972        let content_size = Self::finite_or_zero(content_size).max(0.0);
973        let target_size = Self::finite_or_zero(target_size).max(0.0);
974        let padding_start = Self::finite_or_zero(padding_start).max(0.0);
975        let padding_end = Self::finite_or_zero(padding_end).max(0.0);
976        let max_offset = (content_size - viewport_size).max(0.0);
977
978        if viewport_size <= f32::EPSILON || max_offset <= f32::EPSILON {
979            return 0.0;
980        }
981
982        let target_start = Self::finite_or_zero(target_content_start);
983        let target_end = target_start + target_size;
984        let reveal_start = target_start - padding_start;
985        let reveal_end = target_end + padding_end;
986        let viewport_start = current_offset;
987        let viewport_end = current_offset + viewport_size;
988
989        if if_needed && reveal_start >= viewport_start && reveal_end <= viewport_end {
990            return current_offset.min(max_offset);
991        }
992
993        let desired = match alignment {
994            ScrollAlignment::Start => reveal_start,
995            ScrollAlignment::Center => {
996                let padded_viewport = (viewport_size - padding_start - padding_end).max(0.0);
997                target_start - padding_start - (padded_viewport - target_size) * 0.5
998            }
999            ScrollAlignment::End => reveal_end - viewport_size,
1000            ScrollAlignment::Nearest => {
1001                if reveal_end - reveal_start > viewport_size {
1002                    reveal_start
1003                } else if reveal_start < viewport_start {
1004                    reveal_start
1005                } else if reveal_end > viewport_end {
1006                    reveal_end - viewport_size
1007                } else {
1008                    current_offset
1009                }
1010            }
1011            ScrollAlignment::Fraction(fraction) => {
1012                let fraction = Self::finite_or_zero(fraction).clamp(0.0, 1.0);
1013                let padded_viewport = (viewport_size - padding_start - padding_end).max(0.0);
1014                target_start - padding_start - (padded_viewport - target_size) * fraction
1015            }
1016        };
1017
1018        Self::finite_or_zero(desired).clamp(0.0, max_offset)
1019    }
1020
1021    fn finite_or_zero(value: f32) -> f32 {
1022        if value.is_finite() {
1023            value
1024        } else {
1025            0.0
1026        }
1027    }
1028
1029    fn emit_scroll_into_view_diag(
1030        kind: &'static str,
1031        request: &ScrollIntoViewRequest,
1032        container: Option<WidgetId>,
1033    ) {
1034        diag::emit(
1035            diag::DiagCategory::Input,
1036            diag::DiagLevel::Debug,
1037            diag::DiagEventKind::InputEvent {
1038                kind: format!(
1039                    "scroll_into_view:{kind}:target={:?}:container={:?}",
1040                    request.target,
1041                    container.or(request.container)
1042                ),
1043                target: Some(request.target.as_u128()),
1044                position: None,
1045            },
1046        );
1047    }
1048
1049    /// Runs runtime work that depends on a freshly computed layout snapshot.
1050    ///
1051    /// Returns `true` when the hook changed runtime state and the shell should
1052    /// schedule another frame.
1053    pub fn post_layout_hook(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) -> bool {
1054        let needs_follow_up_frame = self.apply_pending_scroll_into_view(ir, layout);
1055        let active_scroll_nodes: HashSet<WidgetId> = ir
1056            .nodes
1057            .iter()
1058            .filter_map(|(id, node)| match node.op {
1059                Op::Layout(LayoutOp::Scroll { .. }) => Some(*id),
1060                _ => None,
1061            })
1062            .collect();
1063        self.runtime_state
1064            .scroll
1065            .retain_active(&active_scroll_nodes);
1066        if self
1067            .runtime_state
1068            .gesture
1069            .scrollbar_drag
1070            .is_some_and(|drag| !active_scroll_nodes.contains(&drag.node_id))
1071        {
1072            self.runtime_state.gesture.scrollbar_drag = None;
1073        }
1074
1075        let mut current_heroes = HashMap::new();
1076
1077        for (id, node) in &ir.nodes {
1078            if let Op::Semantics(s) = &node.op {
1079                if let Some(tag) = &s.hero_tag {
1080                    if let Some(geom) = layout.get_node_geometry(*id) {
1081                        current_heroes.insert(tag.clone(), (*id, geom.rect));
1082                    }
1083                }
1084            }
1085        }
1086
1087        // Detection logic for future flight motions
1088        for (tag, (_new_id, new_rect)) in &current_heroes {
1089            if let Some((_old_id, old_rect)) = self.runtime_state.hero.positions.get(tag) {
1090                if *new_rect != *old_rect {
1091                    // Logic to spawn overlay flight ghost would go here
1092                    diag::emit(
1093                        diag::DiagCategory::Layout,
1094                        diag::DiagLevel::Debug,
1095                        diag::DiagEventKind::AnchorPlacement {
1096                            widget: 0,
1097                            node: 0,
1098                            rect_x: old_rect.origin.x,
1099                            rect_y: old_rect.origin.y,
1100                            rect_w: old_rect.size.width,
1101                            rect_h: old_rect.size.height,
1102                            place_left: new_rect.origin.x,
1103                            place_top: new_rect.origin.y,
1104                            note: Some(format!("Hero flight: {}", tag)),
1105                        },
1106                    );
1107                }
1108            }
1109        }
1110
1111        self.runtime_state.hero.positions = current_heroes;
1112        needs_follow_up_frame
1113    }
1114
1115    pub fn handle_input(
1116        &mut self,
1117        event: InputEvent,
1118        ir: &CoreIR,
1119        layout: &LayoutSnapshot,
1120    ) -> Result<()> {
1121        use crate::hit_test::{
1122            find_neighbor_focus_node, find_next_focus_node, hit_test_with_scroll, FocusDirection,
1123        };
1124        use crate::input::gesture::GestureController;
1125        use crate::input::hover::HoverController;
1126        use crate::input::selectable_text::SelectableTextController;
1127        use crate::input::slider::SliderController;
1128        use crate::input::text::TextInputController;
1129        use crate::input::{ControllerContext, InputController};
1130        use crate::scrollbar::scrollbar_hit_test;
1131        use crate::ui::custom_render::downcast_render_object;
1132
1133        self.reconcile_focus(ir)?;
1134
1135        if self.runtime_state.interaction.focused.is_none() {
1136            if let Some(autofocus_id) = Self::find_autofocus_node(ir) {
1137                self.runtime_state
1138                    .interaction
1139                    .set_focused(Some(autofocus_id));
1140                if let Some(ime_handler) = &self.ime_handler {
1141                    let accepts_text = ir
1142                        .nodes
1143                        .get(&autofocus_id)
1144                        .and_then(|node| match &node.op {
1145                            Op::Semantics(semantics) => {
1146                                Some(semantics.role == fission_ir::semantics::Role::TextInput)
1147                            }
1148                            _ => None,
1149                        })
1150                        .unwrap_or(false);
1151                    ime_handler.set_ime_allowed(accepts_text);
1152                }
1153            }
1154        }
1155
1156        if matches!(event, InputEvent::Pointer(_)) {
1157            let dispatched_actions = {
1158                let mut ctx = ControllerContext {
1159                    ir,
1160                    layout,
1161                    text_edit: &mut self.runtime_state.text_edit,
1162                    selectable_text: &mut self.runtime_state.selectable_text,
1163                    context_menu: &mut self.runtime_state.context_menu,
1164                    interaction: &mut self.runtime_state.interaction,
1165                    scroll: &mut self.runtime_state.scroll,
1166                    gesture: &mut self.runtime_state.gesture,
1167                    clipboard: self.clipboard_backend.as_ref(),
1168                    measurer: self.measurer.as_ref(),
1169                    dispatched_actions: Vec::new(),
1170                };
1171                let mut hover_controller = HoverController;
1172                let _ = hover_controller.handle_event(&mut ctx, &event);
1173                ctx.dispatched_actions
1174            };
1175            self.dispatch_input_actions(dispatched_actions)?;
1176        }
1177
1178        // --- Custom render object event handling (runs first) ----------------
1179        // For pointer events we hit-test, then walk up from the hit node to
1180        // check whether any ancestor carries a custom render object.  The
1181        // first one that returns `handled = true` short-circuits the entire
1182        // standard controller chain.
1183        let pointer_targets_scrollbar = match &event {
1184            InputEvent::Pointer(PointerEvent::Down { point, button, .. })
1185                if matches!(button, PointerButton::Primary) =>
1186            {
1187                scrollbar_hit_test(ir, layout, &self.runtime_state.scroll, *point).is_some()
1188            }
1189            InputEvent::Pointer(PointerEvent::Move { .. })
1190            | InputEvent::Pointer(PointerEvent::Up { .. }) => {
1191                self.runtime_state.gesture.scrollbar_drag.is_some()
1192            }
1193            InputEvent::Pointer(PointerEvent::Scroll { point, .. }) => {
1194                scrollbar_hit_test(ir, layout, &self.runtime_state.scroll, *point).is_some()
1195            }
1196            _ => false,
1197        };
1198
1199        if !pointer_targets_scrollbar {
1200            if let Some(point) = Self::event_point(&event) {
1201                if let Some(hit_node_id) =
1202                    hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
1203                {
1204                    // Find the custom render object for this click.  Walk up from the
1205                    // hit node first; if not found, check all registered render objects
1206                    // by rect containment (the hit may be on a wrapper node above the
1207                    // InternalRenderNode's lowered subtree).
1208                    let mut target_ro: Option<(WidgetId, &fission_ir::AnyRenderObject)> = None;
1209                    {
1210                        let mut walk = Some(hit_node_id);
1211                        while let Some(nid) = walk {
1212                            if let Some(ro) = ir.custom_render_objects.get(&nid) {
1213                                target_ro = Some((nid, ro));
1214                                break;
1215                            }
1216                            walk = ir.nodes.get(&nid).and_then(|n| n.parent);
1217                        }
1218                    }
1219                    if target_ro.is_none() {
1220                        for (ro_nid, ro) in &ir.custom_render_objects {
1221                            if let Some(rect) = layout.get_node_rect(*ro_nid) {
1222                                if rect.contains(point) {
1223                                    target_ro = Some((*ro_nid, ro));
1224                                    break;
1225                                }
1226                            }
1227                        }
1228                    }
1229
1230                    if let Some((nid, any_ro)) = target_ro {
1231                        if let Some(render_obj) = downcast_render_object(any_ro) {
1232                            let mut node_rect = layout
1233                                .get_node_rect(nid)
1234                                .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
1235                            // Adjust node_rect by ancestor scroll offsets so it reflects
1236                            // the VISUAL position, matching the screen-coordinate click.
1237                            {
1238                                let mut walk = ir.nodes.get(&nid).and_then(|n| n.parent);
1239                                while let Some(pid) = walk {
1240                                    if let Some(pnode) = ir.nodes.get(&pid) {
1241                                        if let fission_ir::Op::Layout(
1242                                            fission_ir::LayoutOp::Scroll { direction, .. },
1243                                        ) = &pnode.op
1244                                        {
1245                                            let off = self.runtime_state.scroll.get_offset(pid);
1246                                            match direction {
1247                                                fission_ir::FlexDirection::Row => {
1248                                                    node_rect.origin.x -= off
1249                                                }
1250                                                fission_ir::FlexDirection::Column => {
1251                                                    node_rect.origin.y -= off
1252                                                }
1253                                            }
1254                                        }
1255                                        walk = pnode.parent;
1256                                    } else {
1257                                        break;
1258                                    }
1259                                }
1260                            }
1261                            let result = render_obj.handle_event(nid, &event, node_rect);
1262                            if result.handled {
1263                                // Set focus to this node so keyboard events route here
1264                                if matches!(event, InputEvent::Pointer(PointerEvent::Down { .. })) {
1265                                    let old_focused_id = self.runtime_state.interaction.focused;
1266                                    if Some(nid) != old_focused_id {
1267                                        self.clear_text_pending_on_blur(old_focused_id, Some(nid));
1268                                        self.dispatch_custom_blur_actions(ir, old_focused_id)?;
1269                                    }
1270                                    self.runtime_state.interaction.set_focused(Some(nid));
1271                                    if let Some(ime_handler) = &self.ime_handler {
1272                                        let accepts_text = render_obj.accepts_text_input();
1273                                        ime_handler.set_ime_allowed(accepts_text);
1274                                        if accepts_text {
1275                                            if let Some(rect) =
1276                                                render_obj.ime_cursor_area(node_rect)
1277                                            {
1278                                                ime_handler.set_ime_cursor_area(rect);
1279                                            }
1280                                        }
1281                                    }
1282                                }
1283                                // Dispatch any actions the render object produced.
1284                                for (target, envelope) in result.actions {
1285                                    self.dispatch_node(envelope, target)?;
1286                                }
1287                                self.update_focused_ime_state(ir, layout);
1288                                return Ok(());
1289                            }
1290                        }
1291                    }
1292                }
1293            }
1294        }
1295
1296        // --- Keyboard events → focused node's custom render object -----------
1297        // Keyboard events have no point, so we route them to the focused node
1298        // (if any) and walk up its ancestor chain looking for a custom render
1299        // object.  This allows custom editor nodes to handle arrow keys,
1300        // typing, etc. before the framework's default focus-navigation logic.
1301        if matches!(event, InputEvent::Keyboard(_) | InputEvent::Ime(_)) {
1302            if let Some(focused_id) = self.runtime_state.interaction.focused {
1303                let mut walk_id = Some(focused_id);
1304                while let Some(nid) = walk_id {
1305                    if let Some(any_ro) = ir.custom_render_objects.get(&nid) {
1306                        if let Some(render_obj) = downcast_render_object(any_ro) {
1307                            let node_rect = layout
1308                                .get_node_rect(nid)
1309                                .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
1310                            let result = render_obj.handle_event(nid, &event, node_rect);
1311                            if result.handled {
1312                                for (target, envelope) in result.actions {
1313                                    self.dispatch_node(envelope, target)?;
1314                                }
1315                                self.update_focused_ime_state(ir, layout);
1316                                return Ok(());
1317                            }
1318                        }
1319                    }
1320                    walk_id = ir.nodes.get(&nid).and_then(|n| n.parent);
1321                }
1322            }
1323        }
1324
1325        let (handled, dispatched_actions) = {
1326            let mut ctx = ControllerContext {
1327                ir,
1328                layout,
1329                text_edit: &mut self.runtime_state.text_edit,
1330                selectable_text: &mut self.runtime_state.selectable_text,
1331                context_menu: &mut self.runtime_state.context_menu,
1332                interaction: &mut self.runtime_state.interaction,
1333                scroll: &mut self.runtime_state.scroll,
1334                gesture: &mut self.runtime_state.gesture,
1335                clipboard: self.clipboard_backend.as_ref(),
1336                measurer: self.measurer.as_ref(),
1337                dispatched_actions: Vec::new(),
1338            };
1339
1340            let mut hover_controller = HoverController;
1341            let _ = hover_controller.handle_event(&mut ctx, &event);
1342
1343            let mut selectable_text_controller = SelectableTextController;
1344            let handled = if selectable_text_controller.handle_event(&mut ctx, &event) {
1345                true
1346            } else {
1347                let mut gesture_controller = GestureController;
1348                if gesture_controller.handle_event(&mut ctx, &event) {
1349                    true
1350                } else {
1351                    let mut text_controller = TextInputController;
1352                    if text_controller.handle_event(&mut ctx, &event) {
1353                        true
1354                    } else {
1355                        let mut slider_controller = SliderController;
1356                        slider_controller.handle_event(&mut ctx, &event)
1357                    }
1358                }
1359            };
1360            (handled, ctx.dispatched_actions)
1361        };
1362
1363        self.dispatch_input_actions(dispatched_actions)?;
1364
1365        if handled {
1366            if matches!(event, InputEvent::Pointer(PointerEvent::Up { .. })) {
1367                self.runtime_state.interaction.pressed.clear();
1368                self.runtime_state.interaction.last_down_point = None;
1369            }
1370            self.update_focused_ime_state(ir, layout);
1371            return Ok(());
1372        }
1373
1374        match event {
1375            InputEvent::Pointer(PointerEvent::Scroll { point, delta, .. }) => {
1376                let trace_scroll =
1377                    std::env::var("FISSION_SCROLL_TRACE").ok().as_deref() == Some("1");
1378                if trace_scroll {
1379                    eprintln!(
1380                        "[scroll-trace] event point=({:.1},{:.1}) delta=({:.1},{:.1})",
1381                        point.x, point.y, delta.x, delta.y
1382                    );
1383                }
1384                let hit_node_id = scrollbar_hit_test(ir, layout, &self.runtime_state.scroll, point)
1385                    .map(|hit| hit.geometry.node_id)
1386                    .or_else(|| {
1387                        hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
1388                    });
1389                if let Some(hit_node_id) = hit_node_id {
1390                    if trace_scroll {
1391                        eprintln!("[scroll-trace] hit_node={}", hit_node_id.as_u128());
1392                    }
1393                    let mut current_id = Some(hit_node_id);
1394                    while let Some(node_id) = current_id {
1395                        if let Some(node) = ir.nodes.get(&node_id) {
1396                            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
1397                                let current_offset = self.runtime_state.scroll.get_offset(node_id);
1398                                let delta_val = match direction {
1399                                    FlexDirection::Row => delta.x,
1400                                    FlexDirection::Column => delta.y,
1401                                };
1402                                let mut new_offset = current_offset + delta_val;
1403
1404                                let mut max_offset = 0.0f32;
1405                                let mut viewport_w = 0.0f32;
1406                                let mut viewport_h = 0.0f32;
1407                                let mut content_w = 0.0f32;
1408                                let mut content_h = 0.0f32;
1409                                if let Some(geom) = layout.get_node_geometry(node_id) {
1410                                    viewport_w = geom.rect.width();
1411                                    viewport_h = geom.rect.height();
1412                                    content_w = geom.content_size.width;
1413                                    content_h = geom.content_size.height;
1414                                    max_offset = if matches!(direction, FlexDirection::Row) {
1415                                        (geom.content_size.width - geom.rect.width()).max(0.0)
1416                                    } else {
1417                                        (geom.content_size.height - geom.rect.height()).max(0.0)
1418                                    };
1419                                    new_offset = new_offset.clamp(0.0, max_offset);
1420                                }
1421
1422                                if trace_scroll {
1423                                    eprintln!(
1424                                        "[scroll-trace] scroll_node={} axis={} offset={:.1}->{:.1} max={:.1} viewport=({:.1},{:.1}) content=({:.1},{:.1})",
1425                                        node_id.as_u128(),
1426                                        match direction { FlexDirection::Row => "x", FlexDirection::Column => "y" },
1427                                        current_offset,
1428                                        new_offset,
1429                                        max_offset,
1430                                        viewport_w,
1431                                        viewport_h,
1432                                        content_w,
1433                                        content_h
1434                                    );
1435                                }
1436
1437                                {
1438                                    use fission_diagnostics::prelude as diag;
1439                                    diag::emit(
1440                                        diag::DiagCategory::Input,
1441                                        diag::DiagLevel::Debug,
1442                                        diag::DiagEventKind::ScrollUpdate {
1443                                            node: node_id.as_u128(),
1444                                            axis: match direction {
1445                                                FlexDirection::Row => "x".into(),
1446                                                FlexDirection::Column => "y".into(),
1447                                            },
1448                                            point_x: point.x,
1449                                            point_y: point.y,
1450                                            delta: delta_val,
1451                                            old_offset: current_offset,
1452                                            new_offset,
1453                                            max_offset,
1454                                            viewport_w,
1455                                            viewport_h,
1456                                            content_w,
1457                                            content_h,
1458                                        },
1459                                    );
1460                                }
1461
1462                                self.runtime_state.scroll.set_offset(node_id, new_offset);
1463                                // If scroll actually changed, consume the event.
1464                                // If it didn't (clamped to same value, e.g. max_offset==0),
1465                                // propagate to parent scroll nodes.
1466                                if (new_offset - current_offset).abs() > 0.001 {
1467                                    break;
1468                                }
1469                                // Fall through to parent
1470                            }
1471                            current_id = node.parent;
1472                        } else {
1473                            break;
1474                        }
1475                    }
1476                } else if trace_scroll {
1477                    eprintln!("[scroll-trace] hit_test: no node");
1478                }
1479            }
1480            InputEvent::Keyboard(KeyEvent::Down {
1481                key_code,
1482                modifiers,
1483            }) => match key_code {
1484                KeyCode::Tab => {
1485                    let reverse = (modifiers & 1) != 0;
1486                    let old_focus = self.runtime_state.interaction.focused;
1487                    let next =
1488                        find_next_focus_node(ir, self.runtime_state.interaction.focused, reverse);
1489                    if next != old_focus {
1490                        self.clear_text_pending_on_blur(old_focus, next);
1491                        self.dispatch_custom_blur_actions(ir, old_focus)?;
1492                    }
1493                    self.runtime_state.interaction.set_focused(next);
1494                }
1495                KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => {
1496                    let reverse = matches!(key_code, KeyCode::Up | KeyCode::Left);
1497                    let old_focus = self.runtime_state.interaction.focused;
1498                    let next = if let Some(focused) = old_focus {
1499                        let dir = match key_code {
1500                            KeyCode::Up => FocusDirection::Up,
1501                            KeyCode::Down => FocusDirection::Down,
1502                            KeyCode::Left => FocusDirection::Left,
1503                            KeyCode::Right => FocusDirection::Right,
1504                            _ => unreachable!(),
1505                        };
1506                        find_neighbor_focus_node(ir, layout, focused, dir)
1507                            .or_else(|| find_next_focus_node(ir, Some(focused), reverse))
1508                    } else {
1509                        find_next_focus_node(ir, None, reverse)
1510                    };
1511                    if next != old_focus {
1512                        self.clear_text_pending_on_blur(old_focus, next);
1513                        self.dispatch_custom_blur_actions(ir, old_focus)?;
1514                        self.runtime_state.interaction.set_focused(next);
1515                    }
1516                }
1517                KeyCode::Enter | KeyCode::Space => {
1518                    if let Some(focused_id) = self.runtime_state.interaction.focused {
1519                        let mut current_id = Some(focused_id);
1520                        while let Some(node_id) = current_id {
1521                            if let Some(node) = ir.nodes.get(&node_id) {
1522                                if let Op::Semantics(semantics) = &node.op {
1523                                    if let Some(action_entry) =
1524                                        semantics.actions.entries.iter().find(|entry| {
1525                                            entry.trigger
1526                                                == fission_ir::semantics::ActionTrigger::Default
1527                                        })
1528                                    {
1529                                        if let Some(payload) = &action_entry.payload_data {
1530                                            let envelope = ActionEnvelope {
1531                                                id: ActionId::from_u128(action_entry.action_id),
1532                                                payload: payload.clone(),
1533                                            };
1534                                            let input = crate::input::scoped_action_input(
1535                                                ir,
1536                                                node_id,
1537                                                ActionInput::None,
1538                                            );
1539                                            return self.dispatch_node_with_input(
1540                                                envelope, node_id, &input,
1541                                            );
1542                                        }
1543                                    }
1544                                }
1545                                current_id = node.parent;
1546                            } else {
1547                                break;
1548                            }
1549                        }
1550                    }
1551                }
1552                _ => {}
1553            },
1554            InputEvent::Pointer(PointerEvent::Down { point, .. }) => {
1555                if let Some(hit_node_id) =
1556                    hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
1557                {
1558                    diag::emit(
1559                        diag::DiagCategory::Input,
1560                        diag::DiagLevel::Debug,
1561                        diag::DiagEventKind::InputEvent {
1562                            kind: "pointer_down_hit".into(),
1563                            target: Some(hit_node_id.as_u128()),
1564                            position: Some((point.x, point.y)),
1565                        },
1566                    );
1567                    let mut focus_candidate = Some(hit_node_id);
1568                    while let Some(node_id) = focus_candidate {
1569                        if let Some(node) = ir.nodes.get(&node_id) {
1570                            if let Op::Semantics(s) = &node.op {
1571                                if s.focusable {
1572                                    if s.focus_policy == FocusPolicy::PreserveCurrentOnPointer {
1573                                        break;
1574                                    }
1575                                    let old_focused_id = self.runtime_state.interaction.focused;
1576                                    if Some(node_id) != old_focused_id {
1577                                        self.clear_text_pending_on_blur(
1578                                            old_focused_id,
1579                                            Some(node_id),
1580                                        );
1581                                        self.dispatch_custom_blur_actions(ir, old_focused_id)?;
1582
1583                                        if s.role == fission_ir::semantics::Role::TextInput {
1584                                            if let Some(ime_handler) = &self.ime_handler {
1585                                                ime_handler.set_ime_allowed(true);
1586                                            }
1587                                        } else if let Some(ime_handler) = &self.ime_handler {
1588                                            ime_handler.set_ime_allowed(false);
1589                                        }
1590                                    }
1591                                    self.runtime_state.interaction.set_focused(Some(node_id));
1592                                    break;
1593                                }
1594                            }
1595                            focus_candidate = node.parent;
1596                        } else {
1597                            break;
1598                        }
1599                    }
1600                    if focus_candidate.is_none() {
1601                        let old_focused_id = self.runtime_state.interaction.focused;
1602                        if let Some(old_focused_id) = self.runtime_state.interaction.focused {
1603                            if let Some(old_node) = ir.nodes.get(&old_focused_id) {
1604                                if let Op::Semantics(s) = &old_node.op {
1605                                    if s.role == fission_ir::semantics::Role::TextInput {
1606                                        if let Some(ime_handler) = &self.ime_handler {
1607                                            ime_handler.set_ime_allowed(false);
1608                                        }
1609                                    }
1610                                }
1611                            }
1612                        }
1613                        self.clear_text_pending_on_blur(old_focused_id, None);
1614                        self.dispatch_custom_blur_actions(ir, old_focused_id)?;
1615                        self.runtime_state.interaction.set_focused(None);
1616                    }
1617
1618                    let mut current_pressed_id = Some(hit_node_id);
1619                    while let Some(node_id) = current_pressed_id {
1620                        self.runtime_state.interaction.set_pressed(node_id, true);
1621                        if let Some(node) = ir.nodes.get(&node_id) {
1622                            current_pressed_id = node.parent;
1623                        } else {
1624                            break;
1625                        }
1626                    }
1627                    self.runtime_state.interaction.last_down_point = Some(point);
1628
1629                    if let Some(focused_id) = self.runtime_state.interaction.focused {
1630                        if let Some(node) = ir.nodes.get(&focused_id) {
1631                            if let Op::Semantics(s) = &node.op {
1632                                if s.role == fission_ir::semantics::Role::TextInput {
1633                                    if let Some(ime_handler) = &self.ime_handler {
1634                                        ime_handler.set_ime_cursor_area(LayoutRect::new(
1635                                            point.x, point.y, 2.0, 16.0,
1636                                        ));
1637                                    }
1638                                }
1639                            }
1640                        }
1641                    }
1642                } else {
1643                    let old_focused_id = self.runtime_state.interaction.focused;
1644                    if let Some(old_focused_id) = self.runtime_state.interaction.focused {
1645                        if let Some(old_node) = ir.nodes.get(&old_focused_id) {
1646                            if let Op::Semantics(s) = &old_node.op {
1647                                if s.role == fission_ir::semantics::Role::TextInput {
1648                                    if let Some(ime_handler) = &self.ime_handler {
1649                                        ime_handler.set_ime_allowed(false);
1650                                    }
1651                                }
1652                            }
1653                        }
1654                    }
1655                    self.clear_text_pending_on_blur(old_focused_id, None);
1656                    self.dispatch_custom_blur_actions(ir, old_focused_id)?;
1657                    self.runtime_state.interaction.set_focused(None);
1658                }
1659            }
1660            InputEvent::Pointer(PointerEvent::Up { point, .. }) => {
1661                self.runtime_state.interaction.pressed.clear();
1662                self.runtime_state.interaction.last_down_point = None;
1663                if let Some(hit_node_id) =
1664                    hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
1665                {
1666                    let mut current_id = Some(hit_node_id);
1667                    while let Some(node_id) = current_id {
1668                        if let Some(node) = ir.nodes.get(&node_id) {
1669                            if let Op::Semantics(semantics) = &node.op {
1670                                if semantics.role == fission_ir::semantics::Role::TextInput {
1671                                    // No action
1672                                } else if let Some(action_entry) =
1673                                    semantics.actions.entries.iter().find(|entry| {
1674                                        entry.trigger
1675                                            == fission_ir::semantics::ActionTrigger::Default
1676                                    })
1677                                {
1678                                    if let Some(payload) = &action_entry.payload_data {
1679                                        let envelope = ActionEnvelope {
1680                                            id: ActionId::from_u128(action_entry.action_id),
1681                                            payload: payload.clone(),
1682                                        };
1683                                        diag::emit(
1684                                            diag::DiagCategory::Input,
1685                                            diag::DiagLevel::Debug,
1686                                            diag::DiagEventKind::InputEvent {
1687                                                kind: "pointer_up_dispatch".into(),
1688                                                target: Some(node_id.as_u128()),
1689                                                position: Some((point.x, point.y)),
1690                                            },
1691                                        );
1692                                        let input = crate::input::scoped_action_input(
1693                                            ir,
1694                                            node_id,
1695                                            ActionInput::None,
1696                                        );
1697                                        return self
1698                                            .dispatch_node_with_input(envelope, node_id, &input);
1699                                    }
1700                                }
1701                            }
1702                            current_id = node.parent;
1703                        } else {
1704                            break;
1705                        }
1706                    }
1707                }
1708            }
1709            _ => {}
1710        }
1711        self.update_focused_ime_state(ir, layout);
1712        Ok(())
1713    }
1714
1715    pub fn clear_hover_state(&mut self, ir: &CoreIR, point: Option<LayoutPoint>) -> Result<bool> {
1716        use crate::input::hover::HoverController;
1717        use crate::input::ControllerContext;
1718
1719        let dispatched_actions = {
1720            let layout = &LayoutSnapshot::new(LayoutSize::ZERO);
1721            let mut ctx = ControllerContext {
1722                ir,
1723                layout,
1724                text_edit: &mut self.runtime_state.text_edit,
1725                selectable_text: &mut self.runtime_state.selectable_text,
1726                context_menu: &mut self.runtime_state.context_menu,
1727                interaction: &mut self.runtime_state.interaction,
1728                scroll: &mut self.runtime_state.scroll,
1729                gesture: &mut self.runtime_state.gesture,
1730                clipboard: self.clipboard_backend.as_ref(),
1731                measurer: self.measurer.as_ref(),
1732                dispatched_actions: Vec::new(),
1733            };
1734            let changed = HoverController::clear(&mut ctx, point);
1735            (changed, ctx.dispatched_actions)
1736        };
1737        self.dispatch_input_actions(dispatched_actions.1)?;
1738        Ok(dispatched_actions.0)
1739    }
1740
1741    fn dispatch_input_actions(
1742        &mut self,
1743        dispatched_actions: Vec<(WidgetId, ActionEnvelope, ActionInput)>,
1744    ) -> Result<()> {
1745        for (target, action, input) in dispatched_actions {
1746            self.dispatch_node_with_input(action, target, &input)?;
1747        }
1748        Ok(())
1749    }
1750
1751    fn update_focused_ime_state(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) {
1752        let Some(ime_handler) = self.ime_handler.clone() else {
1753            return;
1754        };
1755        let Some(focused_id) = self.runtime_state.interaction.focused else {
1756            ime_handler.set_ime_allowed(false);
1757            return;
1758        };
1759
1760        let mut walk = Some(focused_id);
1761        while let Some(node_id) = walk {
1762            if let Some(any_ro) = ir.custom_render_objects.get(&node_id) {
1763                if let Some(render_obj) = crate::ui::custom_render::downcast_render_object(any_ro) {
1764                    let accepts_text = render_obj.accepts_text_input();
1765                    ime_handler.set_ime_allowed(accepts_text);
1766                    if accepts_text {
1767                        let rect =
1768                            Self::visual_node_rect(ir, layout, &self.runtime_state.scroll, node_id)
1769                                .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
1770                        if let Some(cursor_area) = render_obj.ime_cursor_area(rect) {
1771                            ime_handler.set_ime_cursor_area(cursor_area);
1772                        }
1773                    }
1774                    return;
1775                }
1776            }
1777            walk = ir.nodes.get(&node_id).and_then(|node| node.parent);
1778        }
1779
1780        let accepts_text = ir
1781            .nodes
1782            .get(&focused_id)
1783            .and_then(|node| match &node.op {
1784                Op::Semantics(semantics) => {
1785                    Some(semantics.role == fission_ir::semantics::Role::TextInput)
1786                }
1787                _ => None,
1788            })
1789            .unwrap_or(false);
1790        ime_handler.set_ime_allowed(accepts_text);
1791
1792        if accepts_text {
1793            let cursor_area = {
1794                let mut ctx = crate::input::ControllerContext {
1795                    ir,
1796                    layout,
1797                    text_edit: &mut self.runtime_state.text_edit,
1798                    selectable_text: &mut self.runtime_state.selectable_text,
1799                    context_menu: &mut self.runtime_state.context_menu,
1800                    interaction: &mut self.runtime_state.interaction,
1801                    scroll: &mut self.runtime_state.scroll,
1802                    gesture: &mut self.runtime_state.gesture,
1803                    clipboard: self.clipboard_backend.as_ref(),
1804                    measurer: self.measurer.as_ref(),
1805                    dispatched_actions: Vec::new(),
1806                };
1807                crate::input::text::TextInputController::ime_cursor_area(&mut ctx, focused_id)
1808            };
1809            if let Some(cursor_area) = cursor_area {
1810                ime_handler.set_ime_cursor_area(cursor_area);
1811            }
1812        }
1813    }
1814
1815    fn visual_node_rect(
1816        ir: &CoreIR,
1817        layout: &LayoutSnapshot,
1818        scroll: &crate::env::ScrollStateMap,
1819        node_id: WidgetId,
1820    ) -> Option<LayoutRect> {
1821        let mut rect = layout.get_node_rect(node_id)?;
1822        let mut walk = ir.nodes.get(&node_id).and_then(|node| node.parent);
1823        while let Some(parent_id) = walk {
1824            let Some(parent) = ir.nodes.get(&parent_id) else {
1825                break;
1826            };
1827            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
1828                let offset = scroll.get_offset(parent_id);
1829                match direction {
1830                    FlexDirection::Row => rect.origin.x -= offset,
1831                    FlexDirection::Column => rect.origin.y -= offset,
1832                }
1833            }
1834            walk = parent.parent;
1835        }
1836        Some(rect)
1837    }
1838
1839    fn clear_text_pending_on_blur(
1840        &mut self,
1841        old_focus: Option<WidgetId>,
1842        new_focus: Option<WidgetId>,
1843    ) {
1844        if old_focus == new_focus {
1845            return;
1846        }
1847        if let Some(old_id) = old_focus {
1848            if let Some(st) = self.runtime_state.text_edit.states.get_mut(&old_id) {
1849                st.pending_model_sync = false;
1850                st.clear_preedit();
1851            }
1852        }
1853    }
1854
1855    fn dispatch_custom_blur_actions(
1856        &mut self,
1857        ir: &CoreIR,
1858        old_focus: Option<WidgetId>,
1859    ) -> Result<()> {
1860        if let Some(old_id) = old_focus {
1861            if let Some(any_ro) = ir.custom_render_objects.get(&old_id) {
1862                if let Some(render_obj) = crate::ui::custom_render::downcast_render_object(any_ro) {
1863                    if render_obj.accepts_text_input() {
1864                        if let Some(ime_handler) = &self.ime_handler {
1865                            ime_handler.set_ime_allowed(false);
1866                        }
1867                    }
1868                    for (target, envelope) in render_obj.blur_actions(old_id) {
1869                        self.dispatch_node(envelope, target)?;
1870                    }
1871                }
1872            }
1873        }
1874        Ok(())
1875    }
1876
1877    pub fn hit_test(
1878        &self,
1879        point: LayoutPoint,
1880        ir: &CoreIR,
1881        snapshot: &LayoutSnapshot,
1882    ) -> Option<WidgetId> {
1883        if let Some(root) = ir.root {
1884            return self.hit_test_recursive(root, point, ir, snapshot);
1885        }
1886        None
1887    }
1888
1889    fn hit_test_recursive(
1890        &self,
1891        node_id: WidgetId,
1892        point: LayoutPoint,
1893        ir: &CoreIR,
1894        snapshot: &LayoutSnapshot,
1895    ) -> Option<WidgetId> {
1896        if let Some(geom) = snapshot.nodes.get(&node_id) {
1897            if geom.rect.contains(point) {
1898                if let Some(node) = ir.nodes.get(&node_id) {
1899                    for child in node.children.iter().rev() {
1900                        let mut child_point = point;
1901
1902                        if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
1903                            if !geom.rect.contains(point) {
1904                                continue;
1905                            }
1906                            let offset = self.runtime_state.scroll.get_offset(node_id);
1907                            match direction {
1908                                FlexDirection::Row => child_point.x += offset,
1909                                FlexDirection::Column => child_point.y += offset,
1910                            }
1911                        }
1912
1913                        if let Op::Layout(LayoutOp::Transform { transform }) = &node.op {
1914                            let mat = Mat4::from_cols_array(transform);
1915                            // We need to transform the point relative to the node's origin?
1916                            // Layout coordinates are relative to the parent.
1917                            // In hit_test_recursive, `point` is relative to current `node_id`?
1918                            // No, `point` is relative to the `geom.rect.origin` of `node_id`?
1919                            // Let's check recursion.
1920
1921                            // hit_test starts at root with absolute point.
1922                            // recursion: `child_point = point`.
1923                            // wait, `hit_test_recursive` doesn't subtract location?
1924                            // Ah, I see: `if geom.rect.contains(point)`.
1925                            // This implies `point` is ABSOLUTE.
1926
1927                            // If `point` is absolute, and we want to transform into child local space:
1928                            // 1. Move point to node local space: `point - node_pos`.
1929                            // 2. Apply inverse transform.
1930                            // 3. (Implicitly) Move back or keep local?
1931                            // Recursive call expects absolute point?
1932                            // No, `hit_test_recursive` calls itself with `child_point`.
1933                            // If it expects absolute point, then `Transform` node doesn't work well with absolute recursion.
1934
1935                            // Actually, my `hit_test_recursive` impl seems to assume absolute points for all nodes?
1936                            // `if geom.rect.contains(point)` confirms it.
1937
1938                            // So if I have a Transform, I MUST return a point that looks "absolute" to the child
1939                            // but is logically transformed.
1940                            // Absolute child rect is NOT transformed by LayoutEngine.
1941
1942                            // This means `geom.rect` for children of a Transform is WRONG if they are visually moved.
1943                            // BUT LayoutEngine doesn't know about Matrix4.
1944                            // So the children think they are at `(0,0)` relative to parent.
1945
1946                            // To make hit test work:
1947                            // 1. Convert absolute `point` to `node_local_point`.
1948                            // 2. Apply inverse transform to `node_local_point` -> `transformed_local_point`.
1949                            // 3. Convert `transformed_local_point` back to absolute for children -> `transformed_absolute_point`.
1950
1951                            let local_x = point.x - geom.rect.origin.x;
1952                            let local_y = point.y - geom.rect.origin.y;
1953
1954                            let p = Vec4::new(local_x, local_y, 0.0, 1.0);
1955                            let inv = mat.inverse();
1956                            let transformed = inv * p;
1957
1958                            child_point = LayoutPoint::new(
1959                                transformed.x + geom.rect.origin.x,
1960                                transformed.y + geom.rect.origin.y,
1961                            );
1962                        }
1963
1964                        if let Some(hit) =
1965                            self.hit_test_recursive(*child, child_point, ir, snapshot)
1966                        {
1967                            return Some(hit);
1968                        }
1969                    }
1970
1971                    match &node.op {
1972                        Op::Paint(_)
1973                        | Op::Layout(LayoutOp::Scroll { .. })
1974                        | Op::Layout(LayoutOp::Embed { .. }) => return Some(node_id),
1975                        _ => return None,
1976                    }
1977                }
1978                return None;
1979            }
1980        }
1981        None
1982    }
1983
1984    /// Extract the pointer position from an input event, if applicable.
1985    ///
1986    /// Used by the custom-render-object event dispatch to perform a hit-test
1987    /// before delegating to render objects.  Returns `None` for keyboard and
1988    /// other non-positional events.
1989    fn event_point(event: &InputEvent) -> Option<LayoutPoint> {
1990        match event {
1991            InputEvent::Pointer(PointerEvent::Down { point, .. })
1992            | InputEvent::Pointer(PointerEvent::Up { point, .. })
1993            | InputEvent::Pointer(PointerEvent::Move { point, .. })
1994            | InputEvent::Pointer(PointerEvent::Scroll { point, .. }) => Some(*point),
1995            _ => None,
1996        }
1997    }
1998
1999    fn find_autofocus_node(ir: &CoreIR) -> Option<WidgetId> {
2000        fn walk(ir: &CoreIR, node_id: WidgetId) -> Option<WidgetId> {
2001            let node = ir.nodes.get(&node_id)?;
2002            if let Op::Semantics(semantics) = &node.op {
2003                if semantics.autofocus && semantics.focusable && !semantics.disabled {
2004                    return Some(node_id);
2005                }
2006            }
2007            for child_id in &node.children {
2008                if let Some(found) = walk(ir, *child_id) {
2009                    return Some(found);
2010                }
2011            }
2012            None
2013        }
2014
2015        ir.root.and_then(|root| walk(ir, root))
2016    }
2017
2018    pub fn reconcile_resources(
2019        &mut self,
2020        declarations: Vec<RuntimeResourceDeclaration>,
2021    ) -> Result<()> {
2022        let now = self.clock().current_time();
2023        let mut existing = std::mem::take(&mut self.active_resources);
2024        let mut next = HashMap::new();
2025
2026        for declaration in declarations {
2027            let key = declaration.key.clone();
2028            match existing.remove(&key) {
2029                Some(current)
2030                    if current.policy == declaration.policy
2031                        && current.deps == declaration.deps
2032                        && current.matches_kind(&declaration.kind) =>
2033                {
2034                    next.insert(key, current);
2035                }
2036                Some(current) if declaration.policy == ResourcePolicy::PreserveOnChange => {
2037                    next.insert(key, current);
2038                }
2039                Some(current) => {
2040                    self.stop_resource(&key, &current);
2041                    let replacement = self.start_resource(declaration, now);
2042                    next.insert(key, replacement);
2043                }
2044                None => {
2045                    let resource = self.start_resource(declaration, now);
2046                    next.insert(key, resource);
2047                }
2048            }
2049        }
2050
2051        for (key, resource) in existing {
2052            self.stop_resource(&key, &resource);
2053        }
2054
2055        self.active_resources = next;
2056        Ok(())
2057    }
2058
2059    pub fn resource_generation(&self, key: &str) -> Option<u64> {
2060        self.active_resources
2061            .get(key)
2062            .map(|resource| resource.generation)
2063    }
2064
2065    pub fn is_resource_current(&self, resource: &ResourceExecutionContext) -> bool {
2066        self.resource_generation(&resource.key) == Some(resource.generation)
2067    }
2068
2069    fn start_resource(
2070        &mut self,
2071        declaration: RuntimeResourceDeclaration,
2072        now: CurrentTime,
2073    ) -> ActiveResource {
2074        let generation = self.next_resource_generation;
2075        self.next_resource_generation += 1;
2076
2077        let context = ResourceExecutionContext {
2078            key: declaration.key.clone(),
2079            generation,
2080        };
2081
2082        let kind = match declaration.kind {
2083            RuntimeResourceKind::Job(mut job) => {
2084                job.effect.resource = Some(context);
2085                self.enqueue_effect(job.effect);
2086                ActiveResourceKind::Job
2087            }
2088            RuntimeResourceKind::Service(mut service) => {
2089                service.effect.resource = Some(context);
2090                let (service_name, slot_key) = match &service.effect.effect {
2091                    crate::Effect::StartService(payload) => {
2092                        (payload.service_name.clone(), payload.slot_key.clone())
2093                    }
2094                    _ => unreachable!("service resource must lower to StartService"),
2095                };
2096                self.enqueue_effect(service.effect);
2097                ActiveResourceKind::Service {
2098                    service_name,
2099                    slot_key,
2100                }
2101            }
2102            RuntimeResourceKind::Timer(timer) => self.start_timer_resource(timer, now),
2103        };
2104
2105        ActiveResource {
2106            generation,
2107            deps: declaration.deps,
2108            policy: declaration.policy,
2109            kind,
2110        }
2111    }
2112
2113    fn start_timer_resource(&self, timer: TimerResource, now: CurrentTime) -> ActiveResourceKind {
2114        let interval_ms = timer.interval_ms.max(1);
2115        ActiveResourceKind::Timer {
2116            interval_ms,
2117            payload: timer.payload,
2118            on_tick: timer.on_tick,
2119            next_fire_at: if timer.immediate {
2120                now
2121            } else {
2122                now.saturating_add(interval_ms)
2123            },
2124        }
2125    }
2126
2127    fn stop_resource(&mut self, key: &str, resource: &ActiveResource) {
2128        if let ActiveResourceKind::Service {
2129            service_name,
2130            slot_key,
2131        } = &resource.kind
2132        {
2133            self.enqueue_effect(EffectEnvelope {
2134                req_id: 0,
2135                effect: crate::Effect::StopService(ServiceStopPayload {
2136                    service_name: service_name.clone(),
2137                    slot_key: slot_key.clone(),
2138                }),
2139                on_ok: None,
2140                on_err: None,
2141                service_bindings: None,
2142                resource: Some(ResourceExecutionContext {
2143                    key: key.to_string(),
2144                    generation: resource.generation,
2145                }),
2146            });
2147        }
2148    }
2149}
2150
2151impl ActiveResource {
2152    fn matches_kind(&self, kind: &RuntimeResourceKind) -> bool {
2153        matches!(
2154            (&self.kind, kind),
2155            (ActiveResourceKind::Job, RuntimeResourceKind::Job(_))
2156                | (
2157                    ActiveResourceKind::Timer { .. },
2158                    RuntimeResourceKind::Timer(_)
2159                )
2160                | (
2161                    ActiveResourceKind::Service { .. },
2162                    RuntimeResourceKind::Service(_)
2163                )
2164        )
2165    }
2166}