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