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