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