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