Skip to main content

fission_core/
runtime.rs

1use crate::action::{Action, ActionEnvelope, ActionId, AppState};
2use crate::effect::{ActionInput, EffectEnvelope};
3use crate::env::{
4    ActiveAnimation, AnimationStateMap, Env, InteractionStateMap, RuntimeState, ScrollStateMap,
5    VideoStateMap, VideoStatus,
6};
7use crate::registry::{ActionRegistry, AnimationRequest, AnimationStartValue, VideoRegistration};
8use crate::BoxedReducer;
9use crate::{
10    Clipboard, Clock, CurrentTime, ImeHandler, InputEvent, KeyCode, KeyEvent, PointerButton,
11    PointerEvent,
12};
13use anyhow::{anyhow, Result};
14use fission_diagnostics::prelude as diag;
15use fission_ir::{CoreIR, FlexDirection, LayoutOp, NodeId, Op, WidgetNodeId};
16use fission_layout::{LayoutPoint, LayoutRect, LayoutSnapshot, LayoutUnit, TextMeasurer};
17use glam::{Mat4, Vec4};
18use serde_json;
19use std::any::{Any, TypeId};
20use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22
23/// The core runtime that owns application state, reducers, and the effect queue.
24///
25/// `Runtime` is the single entry point for the action/reducer pipeline. Platform
26/// shells create one `Runtime`, register their `AppState`, build the widget tree
27/// each frame, absorb the resulting [`ActionRegistry`], and call
28/// [`Runtime::handle_input`] to process user events.
29///
30/// # Lifecycle
31///
32/// ```text
33/// 1. runtime = Runtime::default()
34///        .with_measurer(measurer)
35///        .with_clipboard(clipboard);
36/// 2. runtime.add_app_state(Box::new(MyState::default()))?;
37/// 3. loop {
38///        let mut ctx = BuildCtx::new();
39///        let tree = my_widget.build(&mut ctx, &view);
40///        runtime.clear_reducers();
41///        runtime.absorb_registry(ctx.registry);
42///        // lower tree -> IR -> layout -> render
43///        runtime.handle_input(event, &ir, &layout)?;
44///        runtime.tick(dt)?;
45///    }
46/// ```
47///
48/// # Example
49///
50/// ```rust,ignore
51/// let mut runtime = Runtime::default();
52/// runtime.add_app_state(Box::new(Counter { count: 0 }))?;
53/// runtime.tick(16)?;
54/// ```
55pub struct Runtime {
56    /// Per-frame reducers, cleared and re-populated every frame via
57    /// [`absorb_registry`](Runtime::absorb_registry).
58    pub reducers: HashMap<ActionId, Vec<BoxedReducer>>,
59    /// Persistent reducers that survive [`clear_reducers`](Runtime::clear_reducers)
60    /// calls, installed once at app startup.
61    pub persistent_reducers: HashMap<ActionId, Vec<BoxedReducer>>,
62    /// Type-indexed application state store.
63    pub app_states: HashMap<TypeId, Box<dyn AppState>>,
64    /// Mutable runtime state (interaction, scroll, text editing, animations).
65    pub runtime_state: RuntimeState,
66    /// Platform-provided text measurer for layout.
67    pub measurer: Option<Arc<dyn TextMeasurer>>,
68    /// Platform-provided clipboard backend.
69    pub clipboard_backend: Option<Arc<dyn Clipboard>>,
70    /// Platform-provided IME (Input Method Editor) handler.
71    pub ime_handler: Option<Arc<dyn ImeHandler>>,
72    /// Effects emitted by reducers, awaiting platform execution.
73    pub pending_effects: Vec<EffectEnvelope>,
74    /// Monotonically increasing counter for deterministic request id generation.
75    pub next_req_id: u64,
76}
77
78impl Default for Runtime {
79    fn default() -> Self {
80        let mut runtime = Self {
81            reducers: HashMap::new(),
82            persistent_reducers: HashMap::new(),
83            app_states: HashMap::new(),
84            runtime_state: RuntimeState::default(),
85            measurer: None,
86            clipboard_backend: None,
87            ime_handler: None,
88            pending_effects: Vec::new(),
89            next_req_id: 0,
90        };
91
92        runtime
93            .add_app_state(Box::new(Clock::default()))
94            .expect("Failed to add Clock state");
95
96        runtime.register_base_reducers();
97
98        runtime
99    }
100}
101
102impl Runtime {
103    pub fn with_measurer(mut self, measurer: Arc<dyn TextMeasurer>) -> Self {
104        self.measurer = Some(measurer);
105        self
106    }
107
108    pub fn with_clipboard(mut self, backend: Arc<dyn Clipboard>) -> Self {
109        self.clipboard_backend = Some(backend);
110        self
111    }
112
113    pub fn with_ime_handler(mut self, handler: Arc<dyn ImeHandler>) -> Self {
114        self.ime_handler = Some(handler);
115        self
116    }
117
118    pub fn caret_from_point_in_text(
119        &self,
120        value: &str,
121        font_size: f32,
122        viewport_x: f32,
123        viewport_w: f32,
124        content_w: f32,
125        scroll_offset: f32,
126        point_x: f32,
127    ) -> usize {
128        crate::input::text::caret_from_point_in_text(
129            self.measurer.as_ref(),
130            value,
131            font_size,
132            viewport_x,
133            viewport_w,
134            content_w,
135            scroll_offset,
136            point_x,
137        )
138    }
139
140    // Helper for manual reducer registration (internal use)
141    pub fn register_reducer<S: AppState + 'static>(
142        &mut self,
143        action_id: ActionId,
144        reducer_fn: fn(&mut S, &ActionEnvelope, NodeId) -> Result<()>,
145    ) -> Result<()> {
146        let state_type_id = TypeId::of::<S>();
147
148        // Wrap legacy 3-arg reducer into 5-arg BoxedReducer
149        let boxed_reducer: BoxedReducer = Box::new(
150            move |app_states: &mut HashMap<TypeId, Box<dyn AppState>>,
151                  action: &ActionEnvelope,
152                  target: NodeId,
153                  _effects: &mut Vec<EffectEnvelope>,
154                  _input: &ActionInput|
155                  -> Result<()> {
156                if let Some(state_box) = app_states.get_mut(&state_type_id) {
157                    let concrete_state = state_box.downcast_mut::<S>().ok_or_else(|| {
158                        anyhow!("Failed to downcast AppState to concrete type for reducer")
159                    })?;
160                    reducer_fn(concrete_state, action, target)
161                } else {
162                    anyhow::bail!("Target AppState for reducer not found in runtime.");
163                }
164            },
165        );
166
167        self.reducers
168            .entry(action_id)
169            .or_default()
170            .push(boxed_reducer);
171        Ok(())
172    }
173
174    pub fn register_base_reducers(&mut self) {
175        use crate::{AdvanceTo, Tick, ADVANCE_TO_ACTION_ID, TICK_ACTION_ID};
176
177        self.register_reducer::<Clock>(
178            *TICK_ACTION_ID,
179            |state: &mut Clock, action: &ActionEnvelope, _target| {
180                let tick_action: Tick = serde_json::from_slice(&action.payload)
181                    .map_err(|e| anyhow!("Failed to deserialize Tick: {}", e))?;
182                state.advance_by(tick_action.dt)
183            },
184        )
185        .expect("Failed to register Tick reducer");
186
187        self.register_reducer::<Clock>(
188            *ADVANCE_TO_ACTION_ID,
189            |state: &mut Clock, action: &ActionEnvelope, _target| {
190                let advance_action: AdvanceTo = serde_json::from_slice(&action.payload)
191                    .map_err(|e| anyhow!("Failed to deserialize AdvanceTo: {}", e))?;
192                state.set_to(advance_action.time)
193            },
194        )
195        .expect("Failed to register AdvanceTo reducer");
196    }
197
198    pub fn clear_reducers(&mut self) {
199        self.reducers.clear();
200        self.register_base_reducers();
201    }
202
203    pub fn absorb_registry<S: AppState>(&mut self, registry: ActionRegistry<S>) {
204        let new_reducers = registry.into_runtime_reducers();
205        for (id, mut list) in new_reducers {
206            self.reducers.entry(id).or_default().append(&mut list);
207        }
208    }
209
210    /// Registers reducers that should survive `clear_reducers()` calls.
211    ///
212    /// This is intended for app-level "global" handlers (e.g. system effects) that
213    /// are installed once at app startup, while per-frame widget handlers are
214    /// regenerated every frame via `BuildCtx` and `absorb_registry`.
215    pub fn absorb_persistent_registry<S: AppState>(&mut self, registry: ActionRegistry<S>) {
216        let new_reducers = registry.into_runtime_reducers();
217        for (id, mut list) in new_reducers {
218            self.persistent_reducers
219                .entry(id)
220                .or_default()
221                .append(&mut list);
222        }
223    }
224
225    pub fn clock(&self) -> &Clock {
226        self.get_app_state::<Clock>()
227            .expect("Clock state must always be present")
228    }
229
230    pub fn get_app_state<S: AppState + 'static>(&self) -> Option<&S> {
231        self.app_states
232            .get(&TypeId::of::<S>())
233            .and_then(|s_box| s_box.downcast_ref::<S>())
234    }
235
236    pub fn get_app_state_mut<S: AppState + 'static>(&mut self) -> Option<&mut S> {
237        self.app_states
238            .get_mut(&TypeId::of::<S>())
239            .and_then(|s_box| s_box.downcast_mut::<S>())
240    }
241
242    pub fn add_app_state<S: AppState + 'static>(&mut self, state: Box<S>) -> Result<()> {
243        let type_id = TypeId::of::<S>();
244        if self.app_states.insert(type_id, state).is_some() {
245            anyhow::bail!("App state of this type already registered.");
246        }
247        Ok(())
248    }
249
250    pub fn dispatch(&mut self, action: ActionEnvelope, target: NodeId) -> Result<()> {
251        self.dispatch_with_input(action, target, &ActionInput::None)
252    }
253
254    pub fn dispatch_with_input(
255        &mut self,
256        action: ActionEnvelope,
257        target: NodeId,
258        input: &ActionInput,
259    ) -> Result<()> {
260        diag::emit(
261            diag::DiagCategory::Input,
262            diag::DiagLevel::Debug,
263            diag::DiagEventKind::InputEvent {
264                kind: "dispatch_start".into(),
265                target: Some(target.as_u128()),
266                position: None,
267            },
268        );
269
270        // Delegate video actions to media module
271        if crate::media::handle_video_action(&mut self.runtime_state.video, &action)? {
272            return Ok(());
273        }
274
275        let action_id = action.id;
276
277        // Collect effects from this dispatch (both persistent and per-frame reducers).
278        let mut effects = Vec::new();
279
280        if let Some(reducers) = self.persistent_reducers.get_mut(&action_id) {
281            diag::emit(
282                diag::DiagCategory::Input,
283                diag::DiagLevel::Debug,
284                diag::DiagEventKind::InputEvent {
285                    kind: format!("persistent_reducers:{}", reducers.len()),
286                    target: Some(target.as_u128()),
287                    position: None,
288                },
289            );
290
291            let mut temp_reducers: Vec<BoxedReducer> = reducers.drain(..).collect();
292            for reducer_wrapper in temp_reducers.iter_mut() {
293                reducer_wrapper(&mut self.app_states, &action, target, &mut effects, input)?;
294            }
295            reducers.extend(temp_reducers);
296        }
297
298        if let Some(reducers) = self.reducers.get_mut(&action_id) {
299            diag::emit(
300                diag::DiagCategory::Input,
301                diag::DiagLevel::Debug,
302                diag::DiagEventKind::InputEvent {
303                    kind: format!("reducers:{}", reducers.len()),
304                    target: Some(target.as_u128()),
305                    position: None,
306                },
307            );
308
309            let mut temp_reducers: Vec<BoxedReducer> = reducers.drain(..).collect();
310            for reducer_wrapper in temp_reducers.iter_mut() {
311                reducer_wrapper(&mut self.app_states, &action, target, &mut effects, input)?;
312            }
313            reducers.extend(temp_reducers);
314        }
315
316        // Process effects: Assign ReqIds and queue them
317        for mut envelope in effects {
318            // Assign deterministic ReqId
319            envelope.req_id = self.next_req_id;
320            self.next_req_id += 1;
321            self.pending_effects.push(envelope);
322        }
323
324        diag::emit(
325            diag::DiagCategory::Input,
326            diag::DiagLevel::Debug,
327            diag::DiagEventKind::InputEvent {
328                kind: "dispatch_end".into(),
329                target: Some(target.as_u128()),
330                position: None,
331            },
332        );
333        Ok(())
334    }
335
336    pub fn tick(&mut self, dt: CurrentTime) -> Result<()> {
337        use crate::Tick;
338        let action = Tick { dt };
339        let envelope: ActionEnvelope = action.into();
340        self.dispatch(envelope, NodeId::derived(0, &[0]))?;
341
342        let current_time = self.clock().current_time();
343
344        let mut finished = Vec::new();
345        let mut has_animation_changes = false;
346        for ((target, property), anim) in self.runtime_state.animation.active.iter_mut() {
347            let elapsed = current_time.saturating_sub(anim.start_time);
348            let mut progress = if anim.duration == 0 {
349                1.0
350            } else {
351                (elapsed as f32 / anim.duration as f32)
352            };
353
354            if anim.repeat && progress >= 1.0 {
355                progress = progress % 1.0;
356            } else {
357                progress = progress.clamp(0.0, 1.0);
358            }
359
360            if !anim.repeat && (elapsed >= anim.duration || anim.duration == 0) {
361                finished.push((*target, property.clone()));
362            }
363
364            let value = anim.start_value + (anim.end_value - anim.start_value) * progress;
365            
366            // Only update and mark dirty if the value actually changed
367            let current_val = self.runtime_state.animation.values.get(&(*target, property.clone())).copied();
368            if current_val != Some(value) {
369                self.runtime_state
370                    .animation
371                    .values
372                    .insert((*target, property.clone()), value);
373                has_animation_changes = true;
374            }
375        }
376
377        for key in finished {
378            self.runtime_state.animation.active.remove(&key);
379            has_animation_changes = true;
380        }
381
382        let _ = has_animation_changes;
383
384        Ok(())
385    }
386
387    pub fn enqueue_animation(&mut self, target: WidgetNodeId, request: AnimationRequest) {
388        let key = (target, request.property.clone());
389
390        // Declarative deduplication: If we are already animating to this target, ignore the new request.
391        if let Some(active) = self.runtime_state.animation.active.get(&key) {
392            // Fuzzy float comparison
393            if (active.end_value - request.to).abs() < 0.001
394                && active.duration == request.duration_ms
395                && active.repeat == request.repeat
396            {
397                // Continue existing animation
398                return;
399            }
400        }
401
402        let current_value = self
403            .runtime_state
404            .animation
405            .values
406            .get(&key)
407            .copied()
408            .unwrap_or_else(|| request.property.default_value());
409
410        // If we are already at the target value and no animation is running, do we need to start one?
411        // Yes, because we might want to ensure it's "set" or trigger completion events (if we had them).
412        // But if start == end and duration > 0, it's a no-op animation?
413        // Optimization: if current == to, maybe skip?
414        // But if we want to "hold" the value, active animation keeps it?
415        // Let's simpler logic: Start new if target changed.
416
417        let start_value = match request.from {
418            AnimationStartValue::Explicit(v) => v,
419            AnimationStartValue::Current => current_value,
420        };
421
422        let anim = ActiveAnimation {
423            target,
424            property: request.property.clone(),
425            start_value,
426            end_value: request.to,
427            start_time: self.clock().current_time() + request.delay_ms,
428            duration: request.duration_ms,
429            repeat: request.repeat,
430        };
431
432        self.runtime_state
433            .animation
434            .values
435            .insert(key.clone(), start_value);
436        self.runtime_state.animation.active.insert(key, anim);
437    }
438
439    pub fn sync_video_nodes(&mut self, registrations: &[VideoRegistration]) {
440        let mut seen: HashSet<WidgetNodeId> = HashSet::new();
441
442        for reg in registrations {
443            seen.insert(reg.node_id);
444            let entry = self
445                .runtime_state
446                .video
447                .states
448                .entry(reg.node_id)
449                .or_insert_with(crate::env::VideoState::default);
450            entry.asset_source = reg.source.clone();
451            entry.looped = reg.loop_playback;
452            if reg.autoplay && entry.status == VideoStatus::Stopped {
453                entry.status = VideoStatus::Playing;
454            }
455        }
456
457        self.runtime_state
458            .video
459            .states
460            .retain(|node_id, _| seen.contains(node_id));
461    }
462
463    pub fn sync_web_nodes(&mut self, registrations: &[crate::registry::WebRegistration]) {
464        let mut seen: HashSet<WidgetNodeId> = HashSet::new();
465
466        for reg in registrations {
467            seen.insert(reg.node_id);
468            let entry = self
469                .runtime_state
470                .web
471                .states
472                .entry(reg.node_id)
473                .or_insert_with(crate::env::WebState::default);
474
475            // Only update URL if it changes to avoid reload loops
476            if entry.url != reg.url {
477                entry.url = reg.url.clone();
478                entry.loading = true; // Assume loading starts
479            }
480            entry.user_agent = reg.user_agent.clone();
481        }
482
483        self.runtime_state
484            .web
485            .states
486            .retain(|node_id, _| seen.contains(node_id));
487    }
488
489    pub fn post_layout_hook(&mut self, ir: &CoreIR, layout: &LayoutSnapshot) {
490        let mut current_heroes = HashMap::new();
491
492        for (id, node) in &ir.nodes {
493            if let Op::Semantics(s) = &node.op {
494                if let Some(tag) = &s.hero_tag {
495                    if let Some(geom) = layout.get_node_geometry(*id) {
496                        current_heroes.insert(tag.clone(), (*id, geom.rect));
497                    }
498                }
499            }
500        }
501
502        // Detection logic for future flight animations
503        for (tag, (_new_id, new_rect)) in &current_heroes {
504            if let Some((_old_id, old_rect)) = self.runtime_state.hero.positions.get(tag) {
505                if *new_rect != *old_rect {
506                    // Logic to spawn overlay flight ghost would go here
507                    diag::emit(
508                        diag::DiagCategory::Layout,
509                        diag::DiagLevel::Debug,
510                        diag::DiagEventKind::AnchorPlacement {
511                            widget: 0,
512                            node: 0,
513                            rect_x: old_rect.origin.x,
514                            rect_y: old_rect.origin.y,
515                            rect_w: old_rect.size.width,
516                            rect_h: old_rect.size.height,
517                            place_left: new_rect.origin.x,
518                            place_top: new_rect.origin.y,
519                            note: Some(format!("Hero flight: {}", tag)),
520                        },
521                    );
522                }
523            }
524        }
525
526        self.runtime_state.hero.positions = current_heroes;
527    }
528
529    pub fn handle_input(
530        &mut self,
531        event: InputEvent,
532        ir: &CoreIR,
533        layout: &LayoutSnapshot,
534    ) -> Result<()> {
535        use crate::hit_test::{
536            find_neighbor_focus_node, find_next_focus_node, hit_test_with_scroll, FocusDirection,
537        };
538        use crate::input::gesture::GestureController;
539        use crate::input::slider::SliderController;
540        use crate::input::text::TextInputController;
541        use crate::input::{ControllerContext, InputController};
542
543        let mut dispatched_actions = Vec::new();
544        let mut handled = false;
545
546        {
547            let mut ctx = ControllerContext {
548                ir,
549                layout,
550                text_edit: &mut self.runtime_state.text_edit,
551                interaction: &mut self.runtime_state.interaction,
552                scroll: &mut self.runtime_state.scroll,
553                ime_preedit: &mut self.runtime_state.ime_preedit,
554                gesture: &mut self.runtime_state.gesture,
555                clipboard: self.clipboard_backend.as_ref(),
556                measurer: self.measurer.as_ref(),
557                dispatched_actions: Vec::new(),
558            };
559
560            let mut gesture_controller = GestureController;
561            if gesture_controller.handle_event(&mut ctx, &event) {
562                handled = true;
563            } else {
564                let mut text_controller = TextInputController;
565                if text_controller.handle_event(&mut ctx, &event) {
566                    handled = true;
567                } else {
568                    let mut slider_controller = SliderController;
569                    if slider_controller.handle_event(&mut ctx, &event) {
570                        handled = true;
571                    }
572                }
573            }
574            dispatched_actions = ctx.dispatched_actions;
575        }
576
577        for (target, action, input) in dispatched_actions {
578            self.dispatch_with_input(action, target, &input)?;
579        }
580
581        if handled {
582            if matches!(event, InputEvent::Pointer(PointerEvent::Up { .. })) {
583                self.runtime_state.interaction.pressed.clear();
584                self.runtime_state.interaction.last_down_point = None;
585            }
586            return Ok(());
587        }
588
589        match event {
590            InputEvent::Pointer(PointerEvent::Scroll { point, delta }) => {
591                let trace_scroll =
592                    std::env::var("FISSION_SCROLL_TRACE").ok().as_deref() == Some("1");
593                if trace_scroll {
594                    eprintln!(
595                        "[scroll-trace] event point=({:.1},{:.1}) delta=({:.1},{:.1})",
596                        point.x, point.y, delta.x, delta.y
597                    );
598                }
599                if let Some(hit_node_id) =
600                    hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
601                {
602                    if trace_scroll {
603                        eprintln!("[scroll-trace] hit_node={}", hit_node_id.as_u128());
604                    }
605                    let mut current_id = Some(hit_node_id);
606                    while let Some(node_id) = current_id {
607                        if let Some(node) = ir.nodes.get(&node_id) {
608                            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
609                                let current_offset = self.runtime_state.scroll.get_offset(node_id);
610                                let delta_val = match direction {
611                                    FlexDirection::Row => delta.x,
612                                    FlexDirection::Column => delta.y,
613                                };
614                                let mut new_offset = current_offset + delta_val;
615
616                                let mut max_offset = 0.0f32;
617                                let mut viewport_w = 0.0f32;
618                                let mut viewport_h = 0.0f32;
619                                let mut content_w = 0.0f32;
620                                let mut content_h = 0.0f32;
621                                if let Some(geom) = layout.get_node_geometry(node_id) {
622                                    viewport_w = geom.rect.width();
623                                    viewport_h = geom.rect.height();
624                                    content_w = geom.content_size.width;
625                                    content_h = geom.content_size.height;
626                                    max_offset = if matches!(direction, FlexDirection::Row) {
627                                        (geom.content_size.width - geom.rect.width()).max(0.0)
628                                    } else {
629                                        (geom.content_size.height - geom.rect.height()).max(0.0)
630                                    };
631                                    new_offset = new_offset.clamp(0.0, max_offset);
632                                }
633
634                                if trace_scroll {
635                                    eprintln!(
636                                        "[scroll-trace] scroll_node={} axis={} offset={:.1}->{:.1} max={:.1} viewport=({:.1},{:.1}) content=({:.1},{:.1})",
637                                        node_id.as_u128(),
638                                        match direction { FlexDirection::Row => "x", FlexDirection::Column => "y" },
639                                        current_offset,
640                                        new_offset,
641                                        max_offset,
642                                        viewport_w,
643                                        viewport_h,
644                                        content_w,
645                                        content_h
646                                    );
647                                }
648
649                                {
650                                    use fission_diagnostics::prelude as diag;
651                                    diag::emit(
652                                        diag::DiagCategory::Input,
653                                        diag::DiagLevel::Debug,
654                                        diag::DiagEventKind::ScrollUpdate {
655                                            node: node_id.as_u128(),
656                                            axis: match direction {
657                                                FlexDirection::Row => "x".into(),
658                                                FlexDirection::Column => "y".into(),
659                                            },
660                                            point_x: point.x,
661                                            point_y: point.y,
662                                            delta: delta_val,
663                                            old_offset: current_offset,
664                                            new_offset,
665                                            max_offset,
666                                            viewport_w,
667                                            viewport_h,
668                                            content_w,
669                                            content_h,
670                                        },
671                                    );
672                                }
673
674                                self.runtime_state.scroll.set_offset(node_id, new_offset);
675                                break;
676                            }
677                            current_id = node.parent;
678                        } else {
679                            break;
680                        }
681                    }
682                } else if trace_scroll {
683                    eprintln!("[scroll-trace] hit_test: no node");
684                }
685            }
686            InputEvent::Keyboard(KeyEvent::Down {
687                key_code,
688                modifiers,
689            }) => match key_code {
690                KeyCode::Tab => {
691                    let reverse = (modifiers & 1) != 0;
692                    let old_focus = self.runtime_state.interaction.focused;
693                    let next =
694                        find_next_focus_node(ir, self.runtime_state.interaction.focused, reverse);
695                    if next != old_focus {
696                        self.runtime_state.ime_preedit = None;
697                        self.clear_text_pending_on_blur(old_focus, next);
698                    }
699                    self.runtime_state.interaction.set_focused(next);
700                }
701                KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => {
702                    if let Some(focused) = self.runtime_state.interaction.focused {
703                        let dir = match key_code {
704                            KeyCode::Up => FocusDirection::Up,
705                            KeyCode::Down => FocusDirection::Down,
706                            KeyCode::Left => FocusDirection::Left,
707                            KeyCode::Right => FocusDirection::Right,
708                            _ => unreachable!(),
709                        };
710                        if let Some(next) = find_neighbor_focus_node(ir, layout, focused, dir) {
711                            self.runtime_state.ime_preedit = None;
712                            self.clear_text_pending_on_blur(Some(focused), Some(next));
713                            self.runtime_state.interaction.set_focused(Some(next));
714                        }
715                    }
716                }
717                KeyCode::Enter | KeyCode::Space => {
718                    if let Some(focused_id) = self.runtime_state.interaction.focused {
719                        let mut current_id = Some(focused_id);
720                        while let Some(node_id) = current_id {
721                            if let Some(node) = ir.nodes.get(&node_id) {
722                                if let Op::Semantics(semantics) = &node.op {
723                                    if let Some(action_entry) = semantics.actions.entries.first() {
724                                        if let Some(payload) = &action_entry.payload_data {
725                                            let envelope = ActionEnvelope {
726                                                id: ActionId::from_u128(action_entry.action_id),
727                                                payload: payload.clone(),
728                                            };
729                                            return self.dispatch(envelope, node_id);
730                                        }
731                                    }
732                                }
733                                current_id = node.parent;
734                            } else {
735                                break;
736                            }
737                        }
738                    }
739                }
740                _ => {}
741            },
742            InputEvent::Pointer(PointerEvent::Down { point, .. }) => {
743                if let Some(hit_node_id) =
744                    hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
745                {
746                    diag::emit(
747                        diag::DiagCategory::Input,
748                        diag::DiagLevel::Debug,
749                        diag::DiagEventKind::InputEvent {
750                            kind: "pointer_down_hit".into(),
751                            target: Some(hit_node_id.as_u128()),
752                            position: Some((point.x, point.y)),
753                        },
754                    );
755                    let mut focus_candidate = Some(hit_node_id);
756                    while let Some(node_id) = focus_candidate {
757                        if let Some(node) = ir.nodes.get(&node_id) {
758                            if let Op::Semantics(s) = &node.op {
759                                if s.focusable {
760                                    let old_focused_id = self.runtime_state.interaction.focused;
761                                    if Some(node_id) != old_focused_id {
762                                        self.runtime_state.ime_preedit = None;
763                                        self.clear_text_pending_on_blur(
764                                            old_focused_id,
765                                            Some(node_id),
766                                        );
767
768                                        if s.role == fission_ir::semantics::Role::TextInput {
769                                            if let Some(ime_handler) = &self.ime_handler {
770                                                ime_handler.set_ime_allowed(true);
771                                            }
772                                        } else if let Some(ime_handler) = &self.ime_handler {
773                                            ime_handler.set_ime_allowed(false);
774                                        }
775                                    }
776                                    self.runtime_state.interaction.set_focused(Some(node_id));
777                                    break;
778                                }
779                            }
780                            focus_candidate = node.parent;
781                        } else {
782                            break;
783                        }
784                    }
785                    if focus_candidate.is_none() {
786                        let old_focused_id = self.runtime_state.interaction.focused;
787                        if let Some(old_focused_id) = self.runtime_state.interaction.focused {
788                            if let Some(old_node) = ir.nodes.get(&old_focused_id) {
789                                if let Op::Semantics(s) = &old_node.op {
790                                    if s.role == fission_ir::semantics::Role::TextInput {
791                                        if let Some(ime_handler) = &self.ime_handler {
792                                            ime_handler.set_ime_allowed(false);
793                                        }
794                                    }
795                                }
796                            }
797                        }
798                        self.clear_text_pending_on_blur(old_focused_id, None);
799                        self.runtime_state.interaction.set_focused(None);
800                    }
801
802                    let mut current_pressed_id = Some(hit_node_id);
803                    while let Some(node_id) = current_pressed_id {
804                        self.runtime_state.interaction.set_pressed(node_id, true);
805                        if let Some(node) = ir.nodes.get(&node_id) {
806                            current_pressed_id = node.parent;
807                        } else {
808                            break;
809                        }
810                    }
811                    self.runtime_state.interaction.last_down_point = Some(point);
812
813                    if let Some(focused_id) = self.runtime_state.interaction.focused {
814                        if let Some(node) = ir.nodes.get(&focused_id) {
815                            if let Op::Semantics(s) = &node.op {
816                                if s.role == fission_ir::semantics::Role::TextInput {
817                                    if let Some(ime_handler) = &self.ime_handler {
818                                        ime_handler.set_ime_cursor_area(LayoutRect::new(
819                                            point.x, point.y, 2.0, 16.0,
820                                        ));
821                                    }
822                                }
823                            }
824                        }
825                    }
826                } else {
827                    let old_focused_id = self.runtime_state.interaction.focused;
828                    if let Some(old_focused_id) = self.runtime_state.interaction.focused {
829                        if let Some(old_node) = ir.nodes.get(&old_focused_id) {
830                            if let Op::Semantics(s) = &old_node.op {
831                                if s.role == fission_ir::semantics::Role::TextInput {
832                                    if let Some(ime_handler) = &self.ime_handler {
833                                        ime_handler.set_ime_allowed(false);
834                                    }
835                                }
836                            }
837                        }
838                    }
839                    self.clear_text_pending_on_blur(old_focused_id, None);
840                    self.runtime_state.interaction.set_focused(None);
841                }
842            }
843            InputEvent::Pointer(PointerEvent::Up { point, .. }) => {
844                self.runtime_state.interaction.pressed.clear();
845                self.runtime_state.interaction.last_down_point = None;
846                if let Some(hit_node_id) =
847                    hit_test_with_scroll(ir, layout, &self.runtime_state.scroll, point)
848                {
849                    let mut current_id = Some(hit_node_id);
850                    while let Some(node_id) = current_id {
851                        if let Some(node) = ir.nodes.get(&node_id) {
852                            if let Op::Semantics(semantics) = &node.op {
853                                if semantics.role == fission_ir::semantics::Role::TextInput {
854                                    // No action
855                                } else if let Some(action_entry) = semantics.actions.entries.first()
856                                {
857                                    if let Some(payload) = &action_entry.payload_data {
858                                        let envelope = ActionEnvelope {
859                                            id: ActionId::from_u128(action_entry.action_id),
860                                            payload: payload.clone(),
861                                        };
862                                        diag::emit(
863                                            diag::DiagCategory::Input,
864                                            diag::DiagLevel::Debug,
865                                            diag::DiagEventKind::InputEvent {
866                                                kind: "pointer_up_dispatch".into(),
867                                                target: Some(node_id.as_u128()),
868                                                position: Some((point.x, point.y)),
869                                            },
870                                        );
871                                        return self.dispatch(envelope, node_id);
872                                    }
873                                }
874                            }
875                            current_id = node.parent;
876                        } else {
877                            break;
878                        }
879                    }
880                }
881            }
882            _ => {}
883        }
884        Ok(())
885    }
886
887    fn clear_text_pending_on_blur(&mut self, old_focus: Option<NodeId>, new_focus: Option<NodeId>) {
888        if old_focus == new_focus {
889            return;
890        }
891        if let Some(old_id) = old_focus {
892            if let Some(st) = self.runtime_state.text_edit.states.get_mut(&old_id) {
893                st.pending_model_sync = false;
894            }
895        }
896    }
897
898    pub fn hit_test(
899        &self,
900        point: LayoutPoint,
901        ir: &CoreIR,
902        snapshot: &LayoutSnapshot,
903    ) -> Option<NodeId> {
904        if let Some(root) = ir.root {
905            return self.hit_test_recursive(root, point, ir, snapshot);
906        }
907        None
908    }
909
910    fn hit_test_recursive(
911        &self,
912        node_id: NodeId,
913        point: LayoutPoint,
914        ir: &CoreIR,
915        snapshot: &LayoutSnapshot,
916    ) -> Option<NodeId> {
917        if let Some(geom) = snapshot.nodes.get(&node_id) {
918            if geom.rect.contains(point) {
919                if let Some(node) = ir.nodes.get(&node_id) {
920                    for child in node.children.iter().rev() {
921                        let mut child_point = point;
922
923                        if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
924                            if !geom.rect.contains(point) {
925                                continue;
926                            }
927                            let offset = self.runtime_state.scroll.get_offset(node_id);
928                            match direction {
929                                FlexDirection::Row => child_point.x += offset,
930                                FlexDirection::Column => child_point.y += offset,
931                            }
932                        }
933
934                        if let Op::Layout(LayoutOp::Transform { transform }) = &node.op {
935                            let mat = Mat4::from_cols_array(transform);
936                            // We need to transform the point relative to the node's origin?
937                            // Layout coordinates are relative to the parent.
938                            // In hit_test_recursive, `point` is relative to current `node_id`?
939                            // No, `point` is relative to the `geom.rect.origin` of `node_id`?
940                            // Let's check recursion.
941
942                            // hit_test starts at root with absolute point.
943                            // recursion: `child_point = point`.
944                            // wait, `hit_test_recursive` doesn't subtract location?
945                            // Ah, I see: `if geom.rect.contains(point)`.
946                            // This implies `point` is ABSOLUTE.
947
948                            // If `point` is absolute, and we want to transform into child local space:
949                            // 1. Move point to node local space: `point - node_pos`.
950                            // 2. Apply inverse transform.
951                            // 3. (Implicitly) Move back or keep local?
952                            // Recursive call expects absolute point?
953                            // No, `hit_test_recursive` calls itself with `child_point`.
954                            // If it expects absolute point, then `Transform` node doesn't work well with absolute recursion.
955
956                            // Actually, my `hit_test_recursive` impl seems to assume absolute points for all nodes?
957                            // `if geom.rect.contains(point)` confirms it.
958
959                            // So if I have a Transform, I MUST return a point that looks "absolute" to the child
960                            // but is logically transformed.
961                            // Absolute child rect is NOT transformed by LayoutEngine.
962
963                            // This means `geom.rect` for children of a Transform is WRONG if they are visually moved.
964                            // BUT LayoutEngine doesn't know about Matrix4.
965                            // So the children think they are at `(0,0)` relative to parent.
966
967                            // To make hit test work:
968                            // 1. Convert absolute `point` to `node_local_point`.
969                            // 2. Apply inverse transform to `node_local_point` -> `transformed_local_point`.
970                            // 3. Convert `transformed_local_point` back to absolute for children -> `transformed_absolute_point`.
971
972                            let local_x = point.x - geom.rect.origin.x;
973                            let local_y = point.y - geom.rect.origin.y;
974
975                            let p = Vec4::new(local_x, local_y, 0.0, 1.0);
976                            let inv = mat.inverse();
977                            let transformed = inv * p;
978
979                            child_point = LayoutPoint::new(
980                                transformed.x + geom.rect.origin.x,
981                                transformed.y + geom.rect.origin.y,
982                            );
983                        }
984
985                        if let Some(hit) =
986                            self.hit_test_recursive(*child, child_point, ir, snapshot)
987                        {
988                            return Some(hit);
989                        }
990                    }
991
992                    match &node.op {
993                        Op::Paint(_)
994                        | Op::Layout(LayoutOp::Scroll { .. })
995                        | Op::Layout(LayoutOp::Embed { .. }) => return Some(node_id),
996                        _ => return None,
997                    }
998                }
999                return None;
1000            }
1001        }
1002        None
1003    }
1004}