Skip to main content

fission_core/
runtime.rs

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