Skip to main content

fission_core/
runtime.rs

1use crate::action::{ActionEnvelope, ActionId, GlobalState};
2use crate::async_runtime::ServiceStopPayload;
3use crate::effect::{ActionInput, EffectEnvelope};
4use crate::env::{RuntimeState, VideoStatus};
5use crate::registry::{
6    ActionRegistry, ResourcePolicy, RuntimeResourceDeclaration, RuntimeResourceKind, TimerResource,
7    VideoRegistration,
8};
9use crate::BoxedReducer;
10use crate::{
11    Clipboard, Clock, CurrentTime, ImeHandler, InputEvent, KeyCode, KeyEvent, PointerButton,
12    PointerEvent, ResourceExecutionContext,
13};
14use anyhow::{anyhow, Result};
15use fission_diagnostics::prelude as diag;
16use fission_ir::{CoreIR, FlexDirection, FocusPolicy, LayoutOp, Op, WidgetId};
17use fission_layout::{LayoutPoint, LayoutRect, LayoutSize, LayoutSnapshot, TextMeasurer};
18use glam::{Mat4, Vec4};
19use serde_json;
20use std::any::TypeId;
21use std::collections::{HashMap, HashSet};
22use std::sync::Arc;
23
24#[derive(Debug, Default, Clone)]
25pub struct TickResult {
26    pub changed_motions: Vec<(WidgetId, crate::MotionPropertyId)>,
27}
28
29#[derive(Debug, Clone)]
30enum ActiveResourceKind {
31    Job,
32    Service {
33        service_name: String,
34        slot_key: String,
35    },
36    Timer {
37        interval_ms: u64,
38        payload: Vec<u8>,
39        on_tick: Option<ActionEnvelope>,
40        next_fire_at: CurrentTime,
41    },
42}
43
44#[derive(Debug, Clone)]
45struct ActiveResource {
46    generation: u64,
47    deps: Option<Vec<u8>>,
48    policy: ResourcePolicy,
49    kind: ActiveResourceKind,
50}
51
52/// The core runtime that owns application state, reducers, and the effect queue.
53///
54/// `Runtime` is the single entry point for the action/reducer pipeline. Platform
55/// shells create one `Runtime`, register their `GlobalState`, build the widget tree
56/// each frame, absorb the resulting [`ActionRegistry`], and call
57/// [`Runtime::handle_input`] to process user events.
58///
59/// # Lifecycle
60///
61/// ```text
62/// 1. runtime = Runtime::default()
63///        .with_measurer(measurer)
64///        .with_clipboard(clipboard);
65/// 2. runtime.add_global_state(Box::new(MyState::default()))?;
66/// 3. loop {
67///        let mut ctx = BuildCtx::new();
68///        let tree = fission_core::build::enter(&mut ctx, &view, || MyRoot.into());
69///        runtime.clear_reducers();
70///        runtime.absorb_registry(ctx.registry);
71///        // lower tree -> IR -> layout -> render
72///        runtime.handle_input(event, &ir, &layout)?;
73///        runtime.tick(dt)?;
74///    }
75/// ```
76///
77/// # Example
78///
79/// ```rust,ignore
80/// let mut runtime = Runtime::default();
81/// runtime.add_global_state(Box::new(Counter { count: 0 }))?;
82/// runtime.tick(16)?;
83/// ```
84pub struct Runtime {
85    /// Per-frame reducers, cleared and re-populated every frame via
86    /// [`absorb_registry`](Runtime::absorb_registry).
87    pub(crate) reducers: HashMap<ActionId, Vec<BoxedReducer>>,
88    /// Persistent reducers that survive [`clear_reducers`](Runtime::clear_reducers)
89    /// calls, installed once at app startup.
90    pub(crate) persistent_reducers: HashMap<ActionId, Vec<BoxedReducer>>,
91    /// Type-indexed application state store.
92    pub app_states: HashMap<TypeId, Box<dyn GlobalState>>,
93    /// Mutable runtime state (interaction, scroll, text editing, motions).
94    pub runtime_state: RuntimeState,
95    /// Platform-provided text measurer for layout.
96    pub measurer: Option<Arc<dyn TextMeasurer>>,
97    /// Platform-provided clipboard backend.
98    pub clipboard_backend: Option<Arc<dyn Clipboard>>,
99    /// Platform-provided IME (Input Method Editor) handler.
100    pub ime_handler: Option<Arc<dyn ImeHandler>>,
101    /// Effects emitted by reducers, awaiting platform execution.
102    pub pending_effects: Vec<EffectEnvelope>,
103    /// Monotonically increasing counter for deterministic request id generation.
104    pub next_req_id: u64,
105    /// Declarative runtime resources that currently exist.
106    active_resources: HashMap<String, ActiveResource>,
107    /// Monotonically increasing generation counter for runtime resources.
108    next_resource_generation: u64,
109}
110
111impl Default for Runtime {
112    fn default() -> Self {
113        let mut runtime = Self {
114            reducers: HashMap::new(),
115            persistent_reducers: HashMap::new(),
116            app_states: HashMap::new(),
117            runtime_state: RuntimeState::default(),
118            measurer: None,
119            clipboard_backend: None,
120            ime_handler: None,
121            pending_effects: Vec::new(),
122            next_req_id: 0,
123            active_resources: HashMap::new(),
124            next_resource_generation: 1,
125        };
126
127        runtime
128            .add_global_state(Box::new(runtime.runtime_state.local_widget_state.clone()))
129            .expect("Failed to add local widget state store");
130
131        runtime
132            .add_global_state(Box::new(Clock::default()))
133            .expect("Failed to add Clock state");
134
135        runtime.register_base_reducers();
136
137        runtime
138    }
139}
140
141impl Runtime {
142    pub fn with_measurer(mut self, measurer: Arc<dyn TextMeasurer>) -> Self {
143        self.measurer = Some(measurer);
144        self
145    }
146
147    pub fn with_clipboard(mut self, backend: Arc<dyn Clipboard>) -> Self {
148        self.clipboard_backend = Some(backend);
149        self
150    }
151
152    pub fn with_ime_handler(mut self, handler: Arc<dyn ImeHandler>) -> Self {
153        self.ime_handler = Some(handler);
154        self
155    }
156
157    pub fn caret_from_point_in_text(
158        &self,
159        value: &str,
160        font_size: f32,
161        viewport_x: f32,
162        viewport_w: f32,
163        content_w: f32,
164        scroll_offset: f32,
165        point_x: f32,
166    ) -> usize {
167        crate::input::text::caret_from_point_in_text(
168            self.measurer.as_ref(),
169            value,
170            font_size,
171            viewport_x,
172            viewport_w,
173            content_w,
174            scroll_offset,
175            point_x,
176        )
177    }
178
179    // Helper for manual reducer registration (internal use)
180    pub fn register_reducer<S: GlobalState + 'static>(
181        &mut self,
182        action_id: ActionId,
183        reducer_fn: crate::action::Reducer<S>,
184    ) -> Result<()> {
185        let state_type_id = TypeId::of::<S>();
186
187        // Wrap legacy 3-arg reducer into 5-arg BoxedReducer
188        let boxed_reducer: BoxedReducer = Box::new(
189            move |app_states: &mut HashMap<TypeId, Box<dyn GlobalState>>,
190                  action: &ActionEnvelope,
191                  target: WidgetId,
192                  _effects: &mut Vec<EffectEnvelope>,
193                  _input: &ActionInput|
194                  -> Result<()> {
195                if let Some(state_box) = app_states.get_mut(&state_type_id) {
196                    let concrete_state = state_box.downcast_mut::<S>().ok_or_else(|| {
197                        anyhow!("Failed to downcast GlobalState to concrete type for reducer")
198                    })?;
199                    reducer_fn(concrete_state, action, target.into())
200                } else {
201                    anyhow::bail!("Target GlobalState for reducer not found in runtime.");
202                }
203            },
204        );
205
206        self.reducers
207            .entry(action_id)
208            .or_default()
209            .push(boxed_reducer);
210        Ok(())
211    }
212
213    pub fn register_base_reducers(&mut self) {
214        use crate::{AdvanceTo, Tick, ADVANCE_TO_ACTION_ID, TICK_ACTION_ID};
215
216        self.register_reducer::<Clock>(
217            *TICK_ACTION_ID,
218            |state: &mut Clock, action: &ActionEnvelope, _target| {
219                let tick_action: Tick = serde_json::from_slice(&action.payload)
220                    .map_err(|e| anyhow!("Failed to deserialize Tick: {}", e))?;
221                state.advance_by(tick_action.dt)
222            },
223        )
224        .expect("Failed to register Tick reducer");
225
226        self.register_reducer::<Clock>(
227            *ADVANCE_TO_ACTION_ID,
228            |state: &mut Clock, action: &ActionEnvelope, _target| {
229                let advance_action: AdvanceTo = serde_json::from_slice(&action.payload)
230                    .map_err(|e| anyhow!("Failed to deserialize AdvanceTo: {}", e))?;
231                state.set_to(advance_action.time)
232            },
233        )
234        .expect("Failed to register AdvanceTo reducer");
235    }
236
237    pub fn clear_reducers(&mut self) {
238        self.reducers.clear();
239        self.register_base_reducers();
240    }
241
242    pub fn absorb_registry<S: GlobalState>(&mut self, registry: ActionRegistry<S>) {
243        let new_reducers = registry.into_runtime_reducers();
244        for (id, mut list) in new_reducers {
245            self.reducers.entry(id).or_default().append(&mut list);
246        }
247    }
248
249    /// Registers reducers that should survive `clear_reducers()` calls.
250    ///
251    /// This is intended for app-level "global" handlers (e.g. system effects) that
252    /// are installed once at app startup, while per-frame widget handlers are
253    /// regenerated every frame via `BuildCtx` and `absorb_registry`.
254    pub fn absorb_persistent_registry<S: GlobalState>(&mut self, registry: ActionRegistry<S>) {
255        let new_reducers = registry.into_runtime_reducers();
256        for (id, mut list) in new_reducers {
257            self.persistent_reducers
258                .entry(id)
259                .or_default()
260                .append(&mut list);
261        }
262    }
263
264    pub fn clock(&self) -> &Clock {
265        self.get_global_state::<Clock>()
266            .expect("Clock state must always be present")
267    }
268
269    pub fn get_global_state<S: GlobalState + 'static>(&self) -> Option<&S> {
270        self.app_states
271            .get(&TypeId::of::<S>())
272            .and_then(|s_box| s_box.downcast_ref::<S>())
273    }
274
275    pub fn get_global_state_mut<S: GlobalState + 'static>(&mut self) -> Option<&mut S> {
276        self.app_states
277            .get_mut(&TypeId::of::<S>())
278            .and_then(|s_box| s_box.downcast_mut::<S>())
279    }
280
281    pub fn add_global_state<S: GlobalState + 'static>(&mut self, state: Box<S>) -> Result<()> {
282        let type_id = TypeId::of::<S>();
283        if self.app_states.insert(type_id, state).is_some() {
284            anyhow::bail!("Global state of this type already registered.");
285        }
286        Ok(())
287    }
288
289    pub fn with_global_state<S: GlobalState + 'static>(mut self, state: S) -> Self {
290        self.app_states.insert(TypeId::of::<S>(), Box::new(state));
291        self
292    }
293
294    #[doc(hidden)]
295    pub fn get_app_state<S: GlobalState + 'static>(&self) -> Option<&S> {
296        self.get_global_state::<S>()
297    }
298
299    #[doc(hidden)]
300    pub fn get_app_state_mut<S: GlobalState + 'static>(&mut self) -> Option<&mut S> {
301        self.get_global_state_mut::<S>()
302    }
303
304    #[doc(hidden)]
305    pub fn add_app_state<S: GlobalState + 'static>(&mut self, state: Box<S>) -> Result<()> {
306        self.add_global_state(state)
307    }
308
309    pub fn dispatch(&mut self, action: ActionEnvelope, target: WidgetId) -> Result<()> {
310        self.dispatch_with_input(action, target, &ActionInput::None)
311    }
312
313    fn enqueue_effect(&mut self, mut envelope: EffectEnvelope) {
314        envelope.req_id = self.next_req_id;
315        self.next_req_id += 1;
316        self.pending_effects.push(envelope);
317    }
318
319    pub fn dispatch_with_input(
320        &mut self,
321        action: ActionEnvelope,
322        target: WidgetId,
323        input: &ActionInput,
324    ) -> Result<()> {
325        self.dispatch_node_with_input(action, target.into(), input)
326    }
327
328    fn dispatch_node(&mut self, action: ActionEnvelope, target: WidgetId) -> Result<()> {
329        self.dispatch_node_with_input(action, target, &ActionInput::None)
330    }
331
332    fn dispatch_node_with_input(
333        &mut self,
334        action: ActionEnvelope,
335        target: WidgetId,
336        input: &ActionInput,
337    ) -> Result<()> {
338        diag::emit(
339            diag::DiagCategory::Input,
340            diag::DiagLevel::Debug,
341            diag::DiagEventKind::InputEvent {
342                kind: "dispatch_start".into(),
343                target: Some(target.as_u128()),
344                position: None,
345            },
346        );
347
348        // Delegate video actions to media module
349        if crate::media::handle_video_action(&mut self.runtime_state.video, &action)? {
350            return Ok(());
351        }
352
353        let action_id = action.id;
354
355        if crate::scoped_action_handlers::dispatch_scoped_action_handler(&action, target, input)? {
356            return Ok(());
357        }
358
359        // Collect effects from this dispatch (both persistent and per-frame reducers).
360        let mut effects = Vec::new();
361
362        if let Some(reducers) = self.persistent_reducers.get_mut(&action_id) {
363            diag::emit(
364                diag::DiagCategory::Input,
365                diag::DiagLevel::Debug,
366                diag::DiagEventKind::InputEvent {
367                    kind: format!("persistent_reducers:{}", reducers.len()),
368                    target: Some(target.as_u128()),
369                    position: None,
370                },
371            );
372
373            let mut temp_reducers: Vec<BoxedReducer> = reducers.drain(..).collect();
374            for reducer_wrapper in temp_reducers.iter_mut() {
375                reducer_wrapper(&mut self.app_states, &action, target, &mut effects, input)?;
376            }
377            reducers.extend(temp_reducers);
378        }
379
380        if let Some(reducers) = self.reducers.get_mut(&action_id) {
381            diag::emit(
382                diag::DiagCategory::Input,
383                diag::DiagLevel::Debug,
384                diag::DiagEventKind::InputEvent {
385                    kind: format!("reducers:{}", reducers.len()),
386                    target: Some(target.as_u128()),
387                    position: None,
388                },
389            );
390
391            let mut temp_reducers: Vec<BoxedReducer> = reducers.drain(..).collect();
392            for reducer_wrapper in temp_reducers.iter_mut() {
393                reducer_wrapper(&mut self.app_states, &action, target, &mut effects, input)?;
394            }
395            reducers.extend(temp_reducers);
396        }
397
398        for envelope in effects {
399            self.enqueue_effect(envelope);
400        }
401
402        diag::emit(
403            diag::DiagCategory::Input,
404            diag::DiagLevel::Debug,
405            diag::DiagEventKind::InputEvent {
406                kind: "dispatch_end".into(),
407                target: Some(target.as_u128()),
408                position: None,
409            },
410        );
411        Ok(())
412    }
413
414    pub fn tick(&mut self, dt: CurrentTime) -> Result<TickResult> {
415        use crate::Tick;
416        let action = Tick { dt };
417        let envelope: ActionEnvelope = action.into();
418        self.dispatch_node(envelope, WidgetId::derived(0, &[0]))?;
419
420        self.tick_resource_timers()?;
421
422        let current_time = self.clock().current_time();
423        let changed_motions =
424            crate::motion::tick_motion(&mut self.runtime_state.motion, current_time);
425        Ok(TickResult { changed_motions })
426    }
427
428    fn tick_resource_timers(&mut self) -> Result<()> {
429        let now = self.clock().current_time();
430        let mut ticks = Vec::new();
431
432        for resource in self.active_resources.values_mut() {
433            if let ActiveResourceKind::Timer {
434                interval_ms,
435                payload,
436                on_tick,
437                next_fire_at,
438            } = &mut resource.kind
439            {
440                let Some(action) = on_tick.clone() else {
441                    continue;
442                };
443
444                let interval_ms = (*interval_ms).max(1);
445                while now >= *next_fire_at {
446                    ticks.push((action.clone(), payload.clone()));
447                    *next_fire_at = next_fire_at.saturating_add(interval_ms);
448                }
449            }
450        }
451
452        for (action, payload) in ticks {
453            self.dispatch_node_with_input(
454                action,
455                WidgetId::derived(0, &[0]),
456                &ActionInput::TimerTick { payload },
457            )?;
458        }
459
460        Ok(())
461    }
462
463    pub fn sync_motion_declarations(
464        &mut self,
465        declarations: &[crate::MotionDeclaration],
466        layout: Option<&LayoutSnapshot>,
467    ) -> Vec<(WidgetId, crate::MotionPropertyId)> {
468        let current_time = self.clock().current_time();
469        let snapshot = self.runtime_state.clone();
470        let result = crate::motion::sync_motion_declarations(
471            &mut self.runtime_state.motion,
472            declarations,
473            &snapshot,
474            layout,
475            current_time,
476        );
477        result.changed
478    }
479
480    pub fn sync_video_nodes(&mut self, registrations: &[VideoRegistration]) {
481        let mut seen: HashSet<WidgetId> = HashSet::new();
482
483        for reg in registrations {
484            seen.insert(reg.node_id);
485            let entry = self
486                .runtime_state
487                .video
488                .states
489                .entry(reg.node_id)
490                .or_insert_with(crate::env::VideoState::default);
491            entry.asset_source = reg.source.clone();
492            entry.looped = reg.loop_playback;
493            if reg.autoplay && entry.status == VideoStatus::Stopped {
494                entry.status = VideoStatus::Playing;
495            }
496        }
497
498        self.runtime_state
499            .video
500            .states
501            .retain(|node_id, _| seen.contains(node_id));
502    }
503
504    pub fn sync_web_nodes(&mut self, registrations: &[crate::registry::WebRegistration]) {
505        let mut seen: HashSet<WidgetId> = HashSet::new();
506
507        for reg in registrations {
508            seen.insert(reg.node_id);
509            let entry = self
510                .runtime_state
511                .web
512                .states
513                .entry(reg.node_id)
514                .or_insert_with(crate::env::WebState::default);
515
516            // Only update URL if it changes to avoid reload loops
517            if entry.url != reg.url {
518                entry.url = reg.url.clone();
519                entry.loading = true; // Assume loading starts
520            }
521            entry.user_agent = reg.user_agent.clone();
522        }
523
524        self.runtime_state
525            .web
526            .states
527            .retain(|node_id, _| seen.contains(node_id));
528    }
529
530    pub fn post_layout_hook(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) {
531        let mut current_heroes = HashMap::new();
532
533        for (id, node) in &ir.nodes {
534            if let Op::Semantics(s) = &node.op {
535                if let Some(tag) = &s.hero_tag {
536                    if let Some(geom) = layout.get_node_geometry(*id) {
537                        current_heroes.insert(tag.clone(), (*id, geom.rect));
538                    }
539                }
540            }
541        }
542
543        // Detection logic for future flight motions
544        for (tag, (_new_id, new_rect)) in &current_heroes {
545            if let Some((_old_id, old_rect)) = self.runtime_state.hero.positions.get(tag) {
546                if *new_rect != *old_rect {
547                    // Logic to spawn overlay flight ghost would go here
548                    diag::emit(
549                        diag::DiagCategory::Layout,
550                        diag::DiagLevel::Debug,
551                        diag::DiagEventKind::AnchorPlacement {
552                            widget: 0,
553                            node: 0,
554                            rect_x: old_rect.origin.x,
555                            rect_y: old_rect.origin.y,
556                            rect_w: old_rect.size.width,
557                            rect_h: old_rect.size.height,
558                            place_left: new_rect.origin.x,
559                            place_top: new_rect.origin.y,
560                            note: Some(format!("Hero flight: {}", tag)),
561                        },
562                    );
563                }
564            }
565        }
566
567        self.runtime_state.hero.positions = current_heroes;
568    }
569
570    pub fn handle_input(
571        &mut self,
572        event: InputEvent,
573        ir: &CoreIR,
574        layout: &LayoutSnapshot,
575    ) -> Result<()> {
576        use crate::hit_test::{
577            find_neighbor_focus_node, find_next_focus_node, hit_test_with_scroll, FocusDirection,
578        };
579        use crate::input::gesture::GestureController;
580        use crate::input::hover::HoverController;
581        use crate::input::slider::SliderController;
582        use crate::input::text::TextInputController;
583        use crate::input::{ControllerContext, InputController};
584        use crate::scrollbar::scrollbar_hit_test;
585        use crate::ui::custom_render::downcast_render_object;
586
587        if self.runtime_state.interaction.focused.is_none() {
588            if let Some(autofocus_id) = Self::find_autofocus_node(ir) {
589                self.runtime_state
590                    .interaction
591                    .set_focused(Some(autofocus_id));
592                if let Some(ime_handler) = &self.ime_handler {
593                    let accepts_text = ir
594                        .nodes
595                        .get(&autofocus_id)
596                        .and_then(|node| match &node.op {
597                            Op::Semantics(semantics) => {
598                                Some(semantics.role == fission_ir::semantics::Role::TextInput)
599                            }
600                            _ => None,
601                        })
602                        .unwrap_or(false);
603                    ime_handler.set_ime_allowed(accepts_text);
604                }
605            }
606        }
607
608        if matches!(event, InputEvent::Pointer(_)) {
609            let dispatched_actions = {
610                let mut ctx = ControllerContext {
611                    ir,
612                    layout,
613                    text_edit: &mut self.runtime_state.text_edit,
614                    interaction: &mut self.runtime_state.interaction,
615                    scroll: &mut self.runtime_state.scroll,
616                    gesture: &mut self.runtime_state.gesture,
617                    clipboard: self.clipboard_backend.as_ref(),
618                    measurer: self.measurer.as_ref(),
619                    dispatched_actions: Vec::new(),
620                };
621                let mut hover_controller = HoverController;
622                let _ = hover_controller.handle_event(&mut ctx, &event);
623                ctx.dispatched_actions
624            };
625            self.dispatch_input_actions(dispatched_actions)?;
626        }
627
628        // --- Custom render object event handling (runs first) ----------------
629        // For pointer events we hit-test, then walk up from the hit node to
630        // check whether any ancestor carries a custom render object.  The
631        // first one that returns `handled = true` short-circuits the entire
632        // standard controller chain.
633        let pointer_targets_scrollbar = match &event {
634            InputEvent::Pointer(PointerEvent::Down { point, button, .. })
635                if matches!(button, PointerButton::Primary) =>
636            {
637                scrollbar_hit_test(ir, layout, &self.runtime_state.scroll, *point).is_some()
638            }
639            InputEvent::Pointer(PointerEvent::Move { .. })
640            | InputEvent::Pointer(PointerEvent::Up { .. }) => {
641                self.runtime_state.gesture.scrollbar_drag.is_some()
642            }
643            InputEvent::Pointer(PointerEvent::Scroll { point, .. }) => {
644                scrollbar_hit_test(ir, layout, &self.runtime_state.scroll, *point).is_some()
645            }
646            _ => false,
647        };
648
649        if !pointer_targets_scrollbar {
650            if let Some(point) = Self::event_point(&event) {
651                if let Some(hit_node_id) =
652                    hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
653                {
654                    // Find the custom render object for this click.  Walk up from the
655                    // hit node first; if not found, check all registered render objects
656                    // by rect containment (the hit may be on a wrapper node above the
657                    // InternalRenderNode's lowered subtree).
658                    let mut target_ro: Option<(WidgetId, &fission_ir::AnyRenderObject)> = None;
659                    {
660                        let mut walk = Some(hit_node_id);
661                        while let Some(nid) = walk {
662                            if let Some(ro) = ir.custom_render_objects.get(&nid) {
663                                target_ro = Some((nid, ro));
664                                break;
665                            }
666                            walk = ir.nodes.get(&nid).and_then(|n| n.parent);
667                        }
668                    }
669                    if target_ro.is_none() {
670                        for (ro_nid, ro) in &ir.custom_render_objects {
671                            if let Some(rect) = layout.get_node_rect(*ro_nid) {
672                                if rect.contains(point) {
673                                    target_ro = Some((*ro_nid, ro));
674                                    break;
675                                }
676                            }
677                        }
678                    }
679
680                    if let Some((nid, any_ro)) = target_ro {
681                        if let Some(render_obj) = downcast_render_object(any_ro) {
682                            let mut node_rect = layout
683                                .get_node_rect(nid)
684                                .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
685                            // Adjust node_rect by ancestor scroll offsets so it reflects
686                            // the VISUAL position, matching the screen-coordinate click.
687                            {
688                                let mut walk = ir.nodes.get(&nid).and_then(|n| n.parent);
689                                while let Some(pid) = walk {
690                                    if let Some(pnode) = ir.nodes.get(&pid) {
691                                        if let fission_ir::Op::Layout(
692                                            fission_ir::LayoutOp::Scroll { direction, .. },
693                                        ) = &pnode.op
694                                        {
695                                            let off = self.runtime_state.scroll.get_offset(pid);
696                                            match direction {
697                                                fission_ir::FlexDirection::Row => {
698                                                    node_rect.origin.x -= off
699                                                }
700                                                fission_ir::FlexDirection::Column => {
701                                                    node_rect.origin.y -= off
702                                                }
703                                            }
704                                        }
705                                        walk = pnode.parent;
706                                    } else {
707                                        break;
708                                    }
709                                }
710                            }
711                            let result = render_obj.handle_event(nid, &event, node_rect);
712                            if result.handled {
713                                // Set focus to this node so keyboard events route here
714                                if matches!(event, InputEvent::Pointer(PointerEvent::Down { .. })) {
715                                    let old_focused_id = self.runtime_state.interaction.focused;
716                                    if Some(nid) != old_focused_id {
717                                        self.clear_text_pending_on_blur(old_focused_id, Some(nid));
718                                        self.dispatch_custom_blur_actions(ir, old_focused_id)?;
719                                    }
720                                    self.runtime_state.interaction.set_focused(Some(nid));
721                                    if let Some(ime_handler) = &self.ime_handler {
722                                        let accepts_text = render_obj.accepts_text_input();
723                                        ime_handler.set_ime_allowed(accepts_text);
724                                        if accepts_text {
725                                            if let Some(rect) =
726                                                render_obj.ime_cursor_area(node_rect)
727                                            {
728                                                ime_handler.set_ime_cursor_area(rect);
729                                            }
730                                        }
731                                    }
732                                }
733                                // Dispatch any actions the render object produced.
734                                for (target, envelope) in result.actions {
735                                    self.dispatch_node(envelope, target)?;
736                                }
737                                self.update_focused_ime_state(ir, layout);
738                                return Ok(());
739                            }
740                        }
741                    }
742                }
743            }
744        }
745
746        // --- Keyboard events → focused node's custom render object -----------
747        // Keyboard events have no point, so we route them to the focused node
748        // (if any) and walk up its ancestor chain looking for a custom render
749        // object.  This allows custom editor nodes to handle arrow keys,
750        // typing, etc. before the framework's default focus-navigation logic.
751        if matches!(event, InputEvent::Keyboard(_) | InputEvent::Ime(_)) {
752            if let Some(focused_id) = self.runtime_state.interaction.focused {
753                let mut walk_id = Some(focused_id);
754                while let Some(nid) = walk_id {
755                    if let Some(any_ro) = ir.custom_render_objects.get(&nid) {
756                        if let Some(render_obj) = downcast_render_object(any_ro) {
757                            let node_rect = layout
758                                .get_node_rect(nid)
759                                .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
760                            let result = render_obj.handle_event(nid, &event, node_rect);
761                            if result.handled {
762                                for (target, envelope) in result.actions {
763                                    self.dispatch_node(envelope, target)?;
764                                }
765                                self.update_focused_ime_state(ir, layout);
766                                return Ok(());
767                            }
768                        }
769                    }
770                    walk_id = ir.nodes.get(&nid).and_then(|n| n.parent);
771                }
772            }
773        }
774
775        let (handled, dispatched_actions) = {
776            let mut ctx = ControllerContext {
777                ir,
778                layout,
779                text_edit: &mut self.runtime_state.text_edit,
780                interaction: &mut self.runtime_state.interaction,
781                scroll: &mut self.runtime_state.scroll,
782                gesture: &mut self.runtime_state.gesture,
783                clipboard: self.clipboard_backend.as_ref(),
784                measurer: self.measurer.as_ref(),
785                dispatched_actions: Vec::new(),
786            };
787
788            let mut hover_controller = HoverController;
789            let _ = hover_controller.handle_event(&mut ctx, &event);
790
791            let mut gesture_controller = GestureController;
792            let handled = if gesture_controller.handle_event(&mut ctx, &event) {
793                true
794            } else {
795                let mut text_controller = TextInputController;
796                if text_controller.handle_event(&mut ctx, &event) {
797                    true
798                } else {
799                    let mut slider_controller = SliderController;
800                    slider_controller.handle_event(&mut ctx, &event)
801                }
802            };
803            (handled, ctx.dispatched_actions)
804        };
805
806        self.dispatch_input_actions(dispatched_actions)?;
807
808        if handled {
809            if matches!(event, InputEvent::Pointer(PointerEvent::Up { .. })) {
810                self.runtime_state.interaction.pressed.clear();
811                self.runtime_state.interaction.last_down_point = None;
812            }
813            self.update_focused_ime_state(ir, layout);
814            return Ok(());
815        }
816
817        match event {
818            InputEvent::Pointer(PointerEvent::Scroll { point, delta, .. }) => {
819                let trace_scroll =
820                    std::env::var("FISSION_SCROLL_TRACE").ok().as_deref() == Some("1");
821                if trace_scroll {
822                    eprintln!(
823                        "[scroll-trace] event point=({:.1},{:.1}) delta=({:.1},{:.1})",
824                        point.x, point.y, delta.x, delta.y
825                    );
826                }
827                let hit_node_id = scrollbar_hit_test(ir, layout, &self.runtime_state.scroll, point)
828                    .map(|hit| hit.geometry.node_id)
829                    .or_else(|| {
830                        hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
831                    });
832                if let Some(hit_node_id) = hit_node_id {
833                    if trace_scroll {
834                        eprintln!("[scroll-trace] hit_node={}", hit_node_id.as_u128());
835                    }
836                    let mut current_id = Some(hit_node_id);
837                    while let Some(node_id) = current_id {
838                        if let Some(node) = ir.nodes.get(&node_id) {
839                            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
840                                let current_offset = self.runtime_state.scroll.get_offset(node_id);
841                                let delta_val = match direction {
842                                    FlexDirection::Row => delta.x,
843                                    FlexDirection::Column => delta.y,
844                                };
845                                let mut new_offset = current_offset + delta_val;
846
847                                let mut max_offset = 0.0f32;
848                                let mut viewport_w = 0.0f32;
849                                let mut viewport_h = 0.0f32;
850                                let mut content_w = 0.0f32;
851                                let mut content_h = 0.0f32;
852                                if let Some(geom) = layout.get_node_geometry(node_id) {
853                                    viewport_w = geom.rect.width();
854                                    viewport_h = geom.rect.height();
855                                    content_w = geom.content_size.width;
856                                    content_h = geom.content_size.height;
857                                    max_offset = if matches!(direction, FlexDirection::Row) {
858                                        (geom.content_size.width - geom.rect.width()).max(0.0)
859                                    } else {
860                                        (geom.content_size.height - geom.rect.height()).max(0.0)
861                                    };
862                                    new_offset = new_offset.clamp(0.0, max_offset);
863                                }
864
865                                if trace_scroll {
866                                    eprintln!(
867                                        "[scroll-trace] scroll_node={} axis={} offset={:.1}->{:.1} max={:.1} viewport=({:.1},{:.1}) content=({:.1},{:.1})",
868                                        node_id.as_u128(),
869                                        match direction { FlexDirection::Row => "x", FlexDirection::Column => "y" },
870                                        current_offset,
871                                        new_offset,
872                                        max_offset,
873                                        viewport_w,
874                                        viewport_h,
875                                        content_w,
876                                        content_h
877                                    );
878                                }
879
880                                {
881                                    use fission_diagnostics::prelude as diag;
882                                    diag::emit(
883                                        diag::DiagCategory::Input,
884                                        diag::DiagLevel::Debug,
885                                        diag::DiagEventKind::ScrollUpdate {
886                                            node: node_id.as_u128(),
887                                            axis: match direction {
888                                                FlexDirection::Row => "x".into(),
889                                                FlexDirection::Column => "y".into(),
890                                            },
891                                            point_x: point.x,
892                                            point_y: point.y,
893                                            delta: delta_val,
894                                            old_offset: current_offset,
895                                            new_offset,
896                                            max_offset,
897                                            viewport_w,
898                                            viewport_h,
899                                            content_w,
900                                            content_h,
901                                        },
902                                    );
903                                }
904
905                                self.runtime_state.scroll.set_offset(node_id, new_offset);
906                                // If scroll actually changed, consume the event.
907                                // If it didn't (clamped to same value, e.g. max_offset==0),
908                                // propagate to parent scroll nodes.
909                                if (new_offset - current_offset).abs() > 0.001 {
910                                    break;
911                                }
912                                // Fall through to parent
913                            }
914                            current_id = node.parent;
915                        } else {
916                            break;
917                        }
918                    }
919                } else if trace_scroll {
920                    eprintln!("[scroll-trace] hit_test: no node");
921                }
922            }
923            InputEvent::Keyboard(KeyEvent::Down {
924                key_code,
925                modifiers,
926            }) => match key_code {
927                KeyCode::Tab => {
928                    let reverse = (modifiers & 1) != 0;
929                    let old_focus = self.runtime_state.interaction.focused;
930                    let next =
931                        find_next_focus_node(ir, self.runtime_state.interaction.focused, reverse);
932                    if next != old_focus {
933                        self.clear_text_pending_on_blur(old_focus, next);
934                        self.dispatch_custom_blur_actions(ir, old_focus)?;
935                    }
936                    self.runtime_state.interaction.set_focused(next);
937                }
938                KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => {
939                    if let Some(focused) = self.runtime_state.interaction.focused {
940                        let dir = match key_code {
941                            KeyCode::Up => FocusDirection::Up,
942                            KeyCode::Down => FocusDirection::Down,
943                            KeyCode::Left => FocusDirection::Left,
944                            KeyCode::Right => FocusDirection::Right,
945                            _ => unreachable!(),
946                        };
947                        if let Some(next) = find_neighbor_focus_node(ir, layout, focused, dir) {
948                            self.clear_text_pending_on_blur(Some(focused), Some(next));
949                            self.dispatch_custom_blur_actions(ir, Some(focused))?;
950                            self.runtime_state.interaction.set_focused(Some(next));
951                        }
952                    }
953                }
954                KeyCode::Enter | KeyCode::Space => {
955                    if let Some(focused_id) = self.runtime_state.interaction.focused {
956                        let mut current_id = Some(focused_id);
957                        while let Some(node_id) = current_id {
958                            if let Some(node) = ir.nodes.get(&node_id) {
959                                if let Op::Semantics(semantics) = &node.op {
960                                    if let Some(action_entry) = semantics.actions.entries.first() {
961                                        if let Some(payload) = &action_entry.payload_data {
962                                            let envelope = ActionEnvelope {
963                                                id: ActionId::from_u128(action_entry.action_id),
964                                                payload: payload.clone(),
965                                            };
966                                            let input = crate::input::scoped_action_input(
967                                                ir,
968                                                node_id,
969                                                ActionInput::None,
970                                            );
971                                            return self.dispatch_node_with_input(
972                                                envelope, node_id, &input,
973                                            );
974                                        }
975                                    }
976                                }
977                                current_id = node.parent;
978                            } else {
979                                break;
980                            }
981                        }
982                    }
983                }
984                _ => {}
985            },
986            InputEvent::Pointer(PointerEvent::Down { point, .. }) => {
987                if let Some(hit_node_id) =
988                    hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
989                {
990                    diag::emit(
991                        diag::DiagCategory::Input,
992                        diag::DiagLevel::Debug,
993                        diag::DiagEventKind::InputEvent {
994                            kind: "pointer_down_hit".into(),
995                            target: Some(hit_node_id.as_u128()),
996                            position: Some((point.x, point.y)),
997                        },
998                    );
999                    let mut focus_candidate = Some(hit_node_id);
1000                    while let Some(node_id) = focus_candidate {
1001                        if let Some(node) = ir.nodes.get(&node_id) {
1002                            if let Op::Semantics(s) = &node.op {
1003                                if s.focusable {
1004                                    if s.focus_policy == FocusPolicy::PreserveCurrentOnPointer {
1005                                        break;
1006                                    }
1007                                    let old_focused_id = self.runtime_state.interaction.focused;
1008                                    if Some(node_id) != old_focused_id {
1009                                        self.clear_text_pending_on_blur(
1010                                            old_focused_id,
1011                                            Some(node_id),
1012                                        );
1013                                        self.dispatch_custom_blur_actions(ir, old_focused_id)?;
1014
1015                                        if s.role == fission_ir::semantics::Role::TextInput {
1016                                            if let Some(ime_handler) = &self.ime_handler {
1017                                                ime_handler.set_ime_allowed(true);
1018                                            }
1019                                        } else if let Some(ime_handler) = &self.ime_handler {
1020                                            ime_handler.set_ime_allowed(false);
1021                                        }
1022                                    }
1023                                    self.runtime_state.interaction.set_focused(Some(node_id));
1024                                    break;
1025                                }
1026                            }
1027                            focus_candidate = node.parent;
1028                        } else {
1029                            break;
1030                        }
1031                    }
1032                    if focus_candidate.is_none() {
1033                        let old_focused_id = self.runtime_state.interaction.focused;
1034                        if let Some(old_focused_id) = self.runtime_state.interaction.focused {
1035                            if let Some(old_node) = ir.nodes.get(&old_focused_id) {
1036                                if let Op::Semantics(s) = &old_node.op {
1037                                    if s.role == fission_ir::semantics::Role::TextInput {
1038                                        if let Some(ime_handler) = &self.ime_handler {
1039                                            ime_handler.set_ime_allowed(false);
1040                                        }
1041                                    }
1042                                }
1043                            }
1044                        }
1045                        self.clear_text_pending_on_blur(old_focused_id, None);
1046                        self.dispatch_custom_blur_actions(ir, old_focused_id)?;
1047                        self.runtime_state.interaction.set_focused(None);
1048                    }
1049
1050                    let mut current_pressed_id = Some(hit_node_id);
1051                    while let Some(node_id) = current_pressed_id {
1052                        self.runtime_state.interaction.set_pressed(node_id, true);
1053                        if let Some(node) = ir.nodes.get(&node_id) {
1054                            current_pressed_id = node.parent;
1055                        } else {
1056                            break;
1057                        }
1058                    }
1059                    self.runtime_state.interaction.last_down_point = Some(point);
1060
1061                    if let Some(focused_id) = self.runtime_state.interaction.focused {
1062                        if let Some(node) = ir.nodes.get(&focused_id) {
1063                            if let Op::Semantics(s) = &node.op {
1064                                if s.role == fission_ir::semantics::Role::TextInput {
1065                                    if let Some(ime_handler) = &self.ime_handler {
1066                                        ime_handler.set_ime_cursor_area(LayoutRect::new(
1067                                            point.x, point.y, 2.0, 16.0,
1068                                        ));
1069                                    }
1070                                }
1071                            }
1072                        }
1073                    }
1074                } else {
1075                    let old_focused_id = self.runtime_state.interaction.focused;
1076                    if let Some(old_focused_id) = self.runtime_state.interaction.focused {
1077                        if let Some(old_node) = ir.nodes.get(&old_focused_id) {
1078                            if let Op::Semantics(s) = &old_node.op {
1079                                if s.role == fission_ir::semantics::Role::TextInput {
1080                                    if let Some(ime_handler) = &self.ime_handler {
1081                                        ime_handler.set_ime_allowed(false);
1082                                    }
1083                                }
1084                            }
1085                        }
1086                    }
1087                    self.clear_text_pending_on_blur(old_focused_id, None);
1088                    self.dispatch_custom_blur_actions(ir, old_focused_id)?;
1089                    self.runtime_state.interaction.set_focused(None);
1090                }
1091            }
1092            InputEvent::Pointer(PointerEvent::Up { point, .. }) => {
1093                self.runtime_state.interaction.pressed.clear();
1094                self.runtime_state.interaction.last_down_point = None;
1095                if let Some(hit_node_id) =
1096                    hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
1097                {
1098                    let mut current_id = Some(hit_node_id);
1099                    while let Some(node_id) = current_id {
1100                        if let Some(node) = ir.nodes.get(&node_id) {
1101                            if let Op::Semantics(semantics) = &node.op {
1102                                if semantics.role == fission_ir::semantics::Role::TextInput {
1103                                    // No action
1104                                } else if let Some(action_entry) = semantics.actions.entries.first()
1105                                {
1106                                    if let Some(payload) = &action_entry.payload_data {
1107                                        let envelope = ActionEnvelope {
1108                                            id: ActionId::from_u128(action_entry.action_id),
1109                                            payload: payload.clone(),
1110                                        };
1111                                        diag::emit(
1112                                            diag::DiagCategory::Input,
1113                                            diag::DiagLevel::Debug,
1114                                            diag::DiagEventKind::InputEvent {
1115                                                kind: "pointer_up_dispatch".into(),
1116                                                target: Some(node_id.as_u128()),
1117                                                position: Some((point.x, point.y)),
1118                                            },
1119                                        );
1120                                        let input = crate::input::scoped_action_input(
1121                                            ir,
1122                                            node_id,
1123                                            ActionInput::None,
1124                                        );
1125                                        return self
1126                                            .dispatch_node_with_input(envelope, node_id, &input);
1127                                    }
1128                                }
1129                            }
1130                            current_id = node.parent;
1131                        } else {
1132                            break;
1133                        }
1134                    }
1135                }
1136            }
1137            _ => {}
1138        }
1139        self.update_focused_ime_state(ir, layout);
1140        Ok(())
1141    }
1142
1143    pub fn clear_hover_state(&mut self, ir: &CoreIR, point: Option<LayoutPoint>) -> Result<bool> {
1144        use crate::input::hover::HoverController;
1145        use crate::input::ControllerContext;
1146
1147        let dispatched_actions = {
1148            let layout = &LayoutSnapshot::new(LayoutSize::ZERO);
1149            let mut ctx = ControllerContext {
1150                ir,
1151                layout,
1152                text_edit: &mut self.runtime_state.text_edit,
1153                interaction: &mut self.runtime_state.interaction,
1154                scroll: &mut self.runtime_state.scroll,
1155                gesture: &mut self.runtime_state.gesture,
1156                clipboard: self.clipboard_backend.as_ref(),
1157                measurer: self.measurer.as_ref(),
1158                dispatched_actions: Vec::new(),
1159            };
1160            let changed = HoverController::clear(&mut ctx, point);
1161            (changed, ctx.dispatched_actions)
1162        };
1163        self.dispatch_input_actions(dispatched_actions.1)?;
1164        Ok(dispatched_actions.0)
1165    }
1166
1167    fn dispatch_input_actions(
1168        &mut self,
1169        dispatched_actions: Vec<(WidgetId, ActionEnvelope, ActionInput)>,
1170    ) -> Result<()> {
1171        for (target, action, input) in dispatched_actions {
1172            self.dispatch_node_with_input(action, target, &input)?;
1173        }
1174        Ok(())
1175    }
1176
1177    fn update_focused_ime_state(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) {
1178        let Some(ime_handler) = self.ime_handler.clone() else {
1179            return;
1180        };
1181        let Some(focused_id) = self.runtime_state.interaction.focused else {
1182            ime_handler.set_ime_allowed(false);
1183            return;
1184        };
1185
1186        let mut walk = Some(focused_id);
1187        while let Some(node_id) = walk {
1188            if let Some(any_ro) = ir.custom_render_objects.get(&node_id) {
1189                if let Some(render_obj) = crate::ui::custom_render::downcast_render_object(any_ro) {
1190                    let accepts_text = render_obj.accepts_text_input();
1191                    ime_handler.set_ime_allowed(accepts_text);
1192                    if accepts_text {
1193                        let rect =
1194                            Self::visual_node_rect(ir, layout, &self.runtime_state.scroll, node_id)
1195                                .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
1196                        if let Some(cursor_area) = render_obj.ime_cursor_area(rect) {
1197                            ime_handler.set_ime_cursor_area(cursor_area);
1198                        }
1199                    }
1200                    return;
1201                }
1202            }
1203            walk = ir.nodes.get(&node_id).and_then(|node| node.parent);
1204        }
1205
1206        let accepts_text = ir
1207            .nodes
1208            .get(&focused_id)
1209            .and_then(|node| match &node.op {
1210                Op::Semantics(semantics) => {
1211                    Some(semantics.role == fission_ir::semantics::Role::TextInput)
1212                }
1213                _ => None,
1214            })
1215            .unwrap_or(false);
1216        ime_handler.set_ime_allowed(accepts_text);
1217
1218        if accepts_text {
1219            let cursor_area = {
1220                let mut ctx = crate::input::ControllerContext {
1221                    ir,
1222                    layout,
1223                    text_edit: &mut self.runtime_state.text_edit,
1224                    interaction: &mut self.runtime_state.interaction,
1225                    scroll: &mut self.runtime_state.scroll,
1226                    gesture: &mut self.runtime_state.gesture,
1227                    clipboard: self.clipboard_backend.as_ref(),
1228                    measurer: self.measurer.as_ref(),
1229                    dispatched_actions: Vec::new(),
1230                };
1231                crate::input::text::TextInputController::ime_cursor_area(&mut ctx, focused_id)
1232            };
1233            if let Some(cursor_area) = cursor_area {
1234                ime_handler.set_ime_cursor_area(cursor_area);
1235            }
1236        }
1237    }
1238
1239    fn visual_node_rect(
1240        ir: &CoreIR,
1241        layout: &LayoutSnapshot,
1242        scroll: &crate::env::ScrollStateMap,
1243        node_id: WidgetId,
1244    ) -> Option<LayoutRect> {
1245        let mut rect = layout.get_node_rect(node_id)?;
1246        let mut walk = ir.nodes.get(&node_id).and_then(|node| node.parent);
1247        while let Some(parent_id) = walk {
1248            let Some(parent) = ir.nodes.get(&parent_id) else {
1249                break;
1250            };
1251            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
1252                let offset = scroll.get_offset(parent_id);
1253                match direction {
1254                    FlexDirection::Row => rect.origin.x -= offset,
1255                    FlexDirection::Column => rect.origin.y -= offset,
1256                }
1257            }
1258            walk = parent.parent;
1259        }
1260        Some(rect)
1261    }
1262
1263    fn clear_text_pending_on_blur(
1264        &mut self,
1265        old_focus: Option<WidgetId>,
1266        new_focus: Option<WidgetId>,
1267    ) {
1268        if old_focus == new_focus {
1269            return;
1270        }
1271        if let Some(old_id) = old_focus {
1272            if let Some(st) = self.runtime_state.text_edit.states.get_mut(&old_id) {
1273                st.pending_model_sync = false;
1274                st.clear_preedit();
1275            }
1276        }
1277    }
1278
1279    fn dispatch_custom_blur_actions(
1280        &mut self,
1281        ir: &CoreIR,
1282        old_focus: Option<WidgetId>,
1283    ) -> Result<()> {
1284        if let Some(old_id) = old_focus {
1285            if let Some(any_ro) = ir.custom_render_objects.get(&old_id) {
1286                if let Some(render_obj) = crate::ui::custom_render::downcast_render_object(any_ro) {
1287                    if render_obj.accepts_text_input() {
1288                        if let Some(ime_handler) = &self.ime_handler {
1289                            ime_handler.set_ime_allowed(false);
1290                        }
1291                    }
1292                    for (target, envelope) in render_obj.blur_actions(old_id) {
1293                        self.dispatch_node(envelope, target)?;
1294                    }
1295                }
1296            }
1297        }
1298        Ok(())
1299    }
1300
1301    pub fn hit_test(
1302        &self,
1303        point: LayoutPoint,
1304        ir: &CoreIR,
1305        snapshot: &LayoutSnapshot,
1306    ) -> Option<WidgetId> {
1307        if let Some(root) = ir.root {
1308            return self.hit_test_recursive(root, point, ir, snapshot);
1309        }
1310        None
1311    }
1312
1313    fn hit_test_recursive(
1314        &self,
1315        node_id: WidgetId,
1316        point: LayoutPoint,
1317        ir: &CoreIR,
1318        snapshot: &LayoutSnapshot,
1319    ) -> Option<WidgetId> {
1320        if let Some(geom) = snapshot.nodes.get(&node_id) {
1321            if geom.rect.contains(point) {
1322                if let Some(node) = ir.nodes.get(&node_id) {
1323                    for child in node.children.iter().rev() {
1324                        let mut child_point = point;
1325
1326                        if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
1327                            if !geom.rect.contains(point) {
1328                                continue;
1329                            }
1330                            let offset = self.runtime_state.scroll.get_offset(node_id);
1331                            match direction {
1332                                FlexDirection::Row => child_point.x += offset,
1333                                FlexDirection::Column => child_point.y += offset,
1334                            }
1335                        }
1336
1337                        if let Op::Layout(LayoutOp::Transform { transform }) = &node.op {
1338                            let mat = Mat4::from_cols_array(transform);
1339                            // We need to transform the point relative to the node's origin?
1340                            // Layout coordinates are relative to the parent.
1341                            // In hit_test_recursive, `point` is relative to current `node_id`?
1342                            // No, `point` is relative to the `geom.rect.origin` of `node_id`?
1343                            // Let's check recursion.
1344
1345                            // hit_test starts at root with absolute point.
1346                            // recursion: `child_point = point`.
1347                            // wait, `hit_test_recursive` doesn't subtract location?
1348                            // Ah, I see: `if geom.rect.contains(point)`.
1349                            // This implies `point` is ABSOLUTE.
1350
1351                            // If `point` is absolute, and we want to transform into child local space:
1352                            // 1. Move point to node local space: `point - node_pos`.
1353                            // 2. Apply inverse transform.
1354                            // 3. (Implicitly) Move back or keep local?
1355                            // Recursive call expects absolute point?
1356                            // No, `hit_test_recursive` calls itself with `child_point`.
1357                            // If it expects absolute point, then `Transform` node doesn't work well with absolute recursion.
1358
1359                            // Actually, my `hit_test_recursive` impl seems to assume absolute points for all nodes?
1360                            // `if geom.rect.contains(point)` confirms it.
1361
1362                            // So if I have a Transform, I MUST return a point that looks "absolute" to the child
1363                            // but is logically transformed.
1364                            // Absolute child rect is NOT transformed by LayoutEngine.
1365
1366                            // This means `geom.rect` for children of a Transform is WRONG if they are visually moved.
1367                            // BUT LayoutEngine doesn't know about Matrix4.
1368                            // So the children think they are at `(0,0)` relative to parent.
1369
1370                            // To make hit test work:
1371                            // 1. Convert absolute `point` to `node_local_point`.
1372                            // 2. Apply inverse transform to `node_local_point` -> `transformed_local_point`.
1373                            // 3. Convert `transformed_local_point` back to absolute for children -> `transformed_absolute_point`.
1374
1375                            let local_x = point.x - geom.rect.origin.x;
1376                            let local_y = point.y - geom.rect.origin.y;
1377
1378                            let p = Vec4::new(local_x, local_y, 0.0, 1.0);
1379                            let inv = mat.inverse();
1380                            let transformed = inv * p;
1381
1382                            child_point = LayoutPoint::new(
1383                                transformed.x + geom.rect.origin.x,
1384                                transformed.y + geom.rect.origin.y,
1385                            );
1386                        }
1387
1388                        if let Some(hit) =
1389                            self.hit_test_recursive(*child, child_point, ir, snapshot)
1390                        {
1391                            return Some(hit);
1392                        }
1393                    }
1394
1395                    match &node.op {
1396                        Op::Paint(_)
1397                        | Op::Layout(LayoutOp::Scroll { .. })
1398                        | Op::Layout(LayoutOp::Embed { .. }) => return Some(node_id),
1399                        _ => return None,
1400                    }
1401                }
1402                return None;
1403            }
1404        }
1405        None
1406    }
1407
1408    /// Extract the pointer position from an input event, if applicable.
1409    ///
1410    /// Used by the custom-render-object event dispatch to perform a hit-test
1411    /// before delegating to render objects.  Returns `None` for keyboard and
1412    /// other non-positional events.
1413    fn event_point(event: &InputEvent) -> Option<LayoutPoint> {
1414        match event {
1415            InputEvent::Pointer(PointerEvent::Down { point, .. })
1416            | InputEvent::Pointer(PointerEvent::Up { point, .. })
1417            | InputEvent::Pointer(PointerEvent::Move { point, .. })
1418            | InputEvent::Pointer(PointerEvent::Scroll { point, .. }) => Some(*point),
1419            _ => None,
1420        }
1421    }
1422
1423    fn find_autofocus_node(ir: &CoreIR) -> Option<WidgetId> {
1424        fn walk(ir: &CoreIR, node_id: WidgetId) -> Option<WidgetId> {
1425            let node = ir.nodes.get(&node_id)?;
1426            if let Op::Semantics(semantics) = &node.op {
1427                if semantics.autofocus && semantics.focusable && !semantics.disabled {
1428                    return Some(node_id);
1429                }
1430            }
1431            for child_id in &node.children {
1432                if let Some(found) = walk(ir, *child_id) {
1433                    return Some(found);
1434                }
1435            }
1436            None
1437        }
1438
1439        ir.root.and_then(|root| walk(ir, root))
1440    }
1441
1442    pub fn reconcile_resources(
1443        &mut self,
1444        declarations: Vec<RuntimeResourceDeclaration>,
1445    ) -> Result<()> {
1446        let now = self.clock().current_time();
1447        let mut existing = std::mem::take(&mut self.active_resources);
1448        let mut next = HashMap::new();
1449
1450        for declaration in declarations {
1451            let key = declaration.key.clone();
1452            match existing.remove(&key) {
1453                Some(current)
1454                    if current.policy == declaration.policy
1455                        && current.deps == declaration.deps
1456                        && current.matches_kind(&declaration.kind) =>
1457                {
1458                    next.insert(key, current);
1459                }
1460                Some(current) if declaration.policy == ResourcePolicy::PreserveOnChange => {
1461                    next.insert(key, current);
1462                }
1463                Some(current) => {
1464                    self.stop_resource(&key, &current);
1465                    let replacement = self.start_resource(declaration, now);
1466                    next.insert(key, replacement);
1467                }
1468                None => {
1469                    let resource = self.start_resource(declaration, now);
1470                    next.insert(key, resource);
1471                }
1472            }
1473        }
1474
1475        for (key, resource) in existing {
1476            self.stop_resource(&key, &resource);
1477        }
1478
1479        self.active_resources = next;
1480        Ok(())
1481    }
1482
1483    pub fn resource_generation(&self, key: &str) -> Option<u64> {
1484        self.active_resources
1485            .get(key)
1486            .map(|resource| resource.generation)
1487    }
1488
1489    pub fn is_resource_current(&self, resource: &ResourceExecutionContext) -> bool {
1490        self.resource_generation(&resource.key) == Some(resource.generation)
1491    }
1492
1493    fn start_resource(
1494        &mut self,
1495        declaration: RuntimeResourceDeclaration,
1496        now: CurrentTime,
1497    ) -> ActiveResource {
1498        let generation = self.next_resource_generation;
1499        self.next_resource_generation += 1;
1500
1501        let context = ResourceExecutionContext {
1502            key: declaration.key.clone(),
1503            generation,
1504        };
1505
1506        let kind = match declaration.kind {
1507            RuntimeResourceKind::Job(mut job) => {
1508                job.effect.resource = Some(context);
1509                self.enqueue_effect(job.effect);
1510                ActiveResourceKind::Job
1511            }
1512            RuntimeResourceKind::Service(mut service) => {
1513                service.effect.resource = Some(context);
1514                let (service_name, slot_key) = match &service.effect.effect {
1515                    crate::Effect::StartService(payload) => {
1516                        (payload.service_name.clone(), payload.slot_key.clone())
1517                    }
1518                    _ => unreachable!("service resource must lower to StartService"),
1519                };
1520                self.enqueue_effect(service.effect);
1521                ActiveResourceKind::Service {
1522                    service_name,
1523                    slot_key,
1524                }
1525            }
1526            RuntimeResourceKind::Timer(timer) => self.start_timer_resource(timer, now),
1527        };
1528
1529        ActiveResource {
1530            generation,
1531            deps: declaration.deps,
1532            policy: declaration.policy,
1533            kind,
1534        }
1535    }
1536
1537    fn start_timer_resource(&self, timer: TimerResource, now: CurrentTime) -> ActiveResourceKind {
1538        let interval_ms = timer.interval_ms.max(1);
1539        ActiveResourceKind::Timer {
1540            interval_ms,
1541            payload: timer.payload,
1542            on_tick: timer.on_tick,
1543            next_fire_at: if timer.immediate {
1544                now
1545            } else {
1546                now.saturating_add(interval_ms)
1547            },
1548        }
1549    }
1550
1551    fn stop_resource(&mut self, key: &str, resource: &ActiveResource) {
1552        if let ActiveResourceKind::Service {
1553            service_name,
1554            slot_key,
1555        } = &resource.kind
1556        {
1557            self.enqueue_effect(EffectEnvelope {
1558                req_id: 0,
1559                effect: crate::Effect::StopService(ServiceStopPayload {
1560                    service_name: service_name.clone(),
1561                    slot_key: slot_key.clone(),
1562                }),
1563                on_ok: None,
1564                on_err: None,
1565                service_bindings: None,
1566                resource: Some(ResourceExecutionContext {
1567                    key: key.to_string(),
1568                    generation: resource.generation,
1569                }),
1570            });
1571        }
1572    }
1573}
1574
1575impl ActiveResource {
1576    fn matches_kind(&self, kind: &RuntimeResourceKind) -> bool {
1577        matches!(
1578            (&self.kind, kind),
1579            (ActiveResourceKind::Job, RuntimeResourceKind::Job(_))
1580                | (
1581                    ActiveResourceKind::Timer { .. },
1582                    RuntimeResourceKind::Timer(_)
1583                )
1584                | (
1585                    ActiveResourceKind::Service { .. },
1586                    RuntimeResourceKind::Service(_)
1587                )
1588        )
1589    }
1590}