Skip to main content

fission_core/
runtime.rs

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