Skip to main content

fenestra_core/
events.rs

1//! Input events and dispatch: hit testing against the laid-out frame,
2//! hover/active/focus bookkeeping with active capture, keyboard focus
3//! cycling, and message extraction from element handlers.
4
5use std::collections::HashMap;
6
7use kurbo::Point;
8
9use crate::element::{Cursor, Element, Kind, SwipeDir};
10use crate::frame::Frame;
11use crate::frame_state::FrameState;
12use crate::id::WidgetId;
13use crate::input;
14use crate::text::Fonts;
15
16/// A logical keyboard key (expanded for text editing in M5).
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Key {
19    /// Enter / Return.
20    Enter,
21    /// Space bar.
22    Space,
23    /// Escape.
24    Escape,
25    /// Left arrow.
26    ArrowLeft,
27    /// Right arrow.
28    ArrowRight,
29    /// Up arrow.
30    ArrowUp,
31    /// Down arrow.
32    ArrowDown,
33    /// Home.
34    Home,
35    /// End.
36    End,
37    /// Backspace.
38    Backspace,
39    /// Delete.
40    Delete,
41    /// Page up (scrolls the focused scrollable).
42    PageUp,
43    /// Page down.
44    PageDown,
45    /// A printable character.
46    Char(char),
47}
48
49/// A key press with modifiers.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct KeyInput {
52    /// The logical key.
53    pub key: Key,
54    /// Shift held.
55    pub shift: bool,
56    /// Control held.
57    pub ctrl: bool,
58    /// Alt/Option held.
59    pub alt: bool,
60    /// Command (macOS) / Windows key.
61    pub meta: bool,
62}
63
64impl KeyInput {
65    /// A plain, unmodified key press.
66    pub fn plain(key: Key) -> Self {
67        Self {
68            key,
69            shift: false,
70            ctrl: false,
71            alt: false,
72            meta: false,
73        }
74    }
75}
76
77/// A logical input event, shared by the windowed runner and headless
78/// `SyntheticEvent` injection.
79#[derive(Debug, Clone, PartialEq)]
80pub enum InputEvent {
81    /// Pointer moved to logical coordinates.
82    PointerMove {
83        /// Logical x.
84        x: f32,
85        /// Logical y.
86        y: f32,
87    },
88    /// Primary button pressed.
89    PointerDown,
90    /// Primary button released.
91    PointerUp,
92    /// Secondary (right) button pressed: the context-menu gesture.
93    RightDown,
94    /// Secondary (right) button released.
95    RightUp,
96    /// An OS file was dropped onto the window (one event per file).
97    FileDrop(std::path::PathBuf),
98    /// Wheel / trackpad scroll. Winit convention: positive `dy` moves the
99    /// content down (scrolling toward the top of the document); positive `dx`
100    /// moves the content right.
101    Wheel {
102        /// Horizontal delta in logical px (0.0 for a pure vertical event).
103        dx: f32,
104        /// Vertical delta in logical px.
105        dy: f32,
106    },
107    /// Modifier keys changed (runners forward this so pointer gestures
108    /// can honor Shift — e.g. shift-click selection extension).
109    Modifiers {
110        /// Shift held.
111        shift: bool,
112        /// Control held.
113        ctrl: bool,
114        /// Alt/Option held.
115        alt: bool,
116        /// Command / Windows key held.
117        meta: bool,
118    },
119    /// The pointer left the surface: hover state clears.
120    PointerLeave,
121    /// Focus next.
122    Tab,
123    /// Focus previous.
124    ShiftTab,
125    /// A key press.
126    Key(KeyInput),
127    /// Committed text input (typing or IME commit).
128    Text(String),
129    /// IME preedit update (composition in progress).
130    ImePreedit {
131        /// The composition text ("" clears it).
132        text: String,
133        /// Cursor range within the composition, in bytes.
134        cursor: Option<(usize, usize)>,
135    },
136}
137
138/// The result of dispatching one event.
139pub struct Dispatch<Msg> {
140    /// Messages emitted by handlers, in order.
141    pub msgs: Vec<Msg>,
142    /// Whether visual state changed (hover/active/focus/scroll).
143    pub redraw: bool,
144    /// Cursor to show; `None` leaves the current cursor unchanged
145    /// (non-pointer events must not reset it).
146    pub cursor: Option<Cursor>,
147}
148
149impl<Msg> Default for Dispatch<Msg> {
150    fn default() -> Self {
151        Self {
152            msgs: Vec::new(),
153            redraw: false,
154            cursor: None,
155        }
156    }
157}
158
159/// A handler entry: borrowed from the declared tree, or an owned,
160/// materialized virtual row (addressed by a child path inside it).
161enum ElemRef<'a, Msg> {
162    Borrowed(&'a Element<Msg>),
163    Owned {
164        row: std::rc::Rc<Element<Msg>>,
165        path: Vec<usize>,
166    },
167}
168
169impl<Msg> ElemRef<'_, Msg> {
170    fn resolve(&self) -> &Element<Msg> {
171        match self {
172            Self::Borrowed(el) => el,
173            Self::Owned { row, path } => {
174                let mut el: &Element<Msg> = row;
175                for &i in path {
176                    el = &el.children[i];
177                }
178                el
179            }
180        }
181    }
182}
183
184/// Index from widget id to the element carrying its handlers, rebuilt per
185/// dispatch from the same id derivation the frame build uses. Virtual
186/// containers materialize the same row window the frame build used, so
187/// handlers on virtual rows resolve like any other element.
188struct Handlers<'a, Msg> {
189    map: HashMap<WidgetId, ElemRef<'a, Msg>>,
190}
191
192impl<'a, Msg> Handlers<'a, Msg> {
193    fn collect(root: &'a Element<Msg>, state: &FrameState, viewport: f32) -> Self {
194        let mut handlers = Self {
195            map: HashMap::new(),
196        };
197        handlers.walk_borrowed(root, WidgetId::ROOT, state, viewport);
198        handlers
199    }
200
201    fn walk_borrowed(
202        &mut self,
203        el: &'a Element<Msg>,
204        id: WidgetId,
205        state: &FrameState,
206        viewport: f32,
207    ) {
208        self.expand_virtual(el, id, state, viewport);
209        self.map.insert(id, ElemRef::Borrowed(el));
210        for (i, child) in el.children.iter().enumerate() {
211            self.walk_borrowed(child, id.child(i, child.key.as_deref()), state, viewport);
212        }
213    }
214
215    /// Materializes the same window of rows the frame build produced
216    /// (spacer at child index 0; rows are keyed, so the index is moot).
217    fn expand_virtual(
218        &mut self,
219        el: &Element<Msg>,
220        id: WidgetId,
221        state: &FrameState,
222        viewport: f32,
223    ) {
224        let Some(v) = &el.virtual_rows else {
225            return;
226        };
227        // Variable lists: reuse the exact window the frame materialized
228        // (stashed at build), so handler ids line up with painted rows.
229        let window = if v.variable {
230            state.virtual_windows.get(&id).cloned().unwrap_or(0..0)
231        } else {
232            crate::frame::virtual_window(v.count, v.row_height, state.scroll_offset(id), viewport)
233        };
234        for (j, i) in window.enumerate() {
235            let row = if v.variable {
236                let mut row = (v.builder)(i);
237                if row.key.is_none() {
238                    row = row.id(&format!("v{i}"));
239                }
240                std::rc::Rc::new(row.shrink0())
241            } else {
242                std::rc::Rc::new(crate::frame::materialize_virtual_row(v, i))
243            };
244            let rid = id.child(1 + j, row.key.as_deref());
245            self.walk_owned(&row, Vec::new(), rid, state, viewport);
246        }
247    }
248
249    fn walk_owned(
250        &mut self,
251        row: &std::rc::Rc<Element<Msg>>,
252        path: Vec<usize>,
253        id: WidgetId,
254        state: &FrameState,
255        viewport: f32,
256    ) {
257        let el = {
258            let mut el: &Element<Msg> = row;
259            for &i in &path {
260                el = &el.children[i];
261            }
262            el
263        };
264        self.expand_virtual(el, id, state, viewport);
265        let child_ids: Vec<WidgetId> = el
266            .children
267            .iter()
268            .enumerate()
269            .map(|(i, c)| id.child(i, c.key.as_deref()))
270            .collect();
271        self.map.insert(
272            id,
273            ElemRef::Owned {
274                row: std::rc::Rc::clone(row),
275                path: path.clone(),
276            },
277        );
278        for (i, child_id) in child_ids.into_iter().enumerate() {
279            let mut child_path = path.clone();
280            child_path.push(i);
281            self.walk_owned(row, child_path, child_id, state, viewport);
282        }
283    }
284
285    fn get(&self, id: WidgetId) -> Option<&Element<Msg>> {
286        self.map.get(&id).map(ElemRef::resolve)
287    }
288}
289
290/// Recomputes the hover set and cursor at `point`, emitting hover-enter
291/// messages in chain (root-to-deepest) order.
292fn update_hover<Msg: Clone>(
293    handlers: &Handlers<'_, Msg>,
294    frame: &Frame,
295    state: &mut FrameState,
296    point: Point,
297    out: &mut Dispatch<Msg>,
298) {
299    let chain = frame.hit_chain(point);
300    let now = state.now();
301    let hovered: HashMap<WidgetId, f64> = chain
302        .iter()
303        .copied()
304        .filter(|id| {
305            handlers.get(*id).is_some_and(|el| {
306                !el.disabled
307                    && (el.hover_style.is_some()
308                        || el.state_layer.is_some()
309                        || el.on_click.is_some()
310                        || el.on_hover.is_some()
311                        || frame.toggle_overlay_of(*id).is_some()
312                        || has_hover_overlay(el))
313            })
314        })
315        // Elements still hovered keep their original enter time.
316        .map(|id| (id, state.hovered.get(&id).copied().unwrap_or(now)))
317        .collect();
318    // Hover-enter messages, in deterministic root-to-deepest order.
319    for id in &chain {
320        if hovered.contains_key(id)
321            && !state.hovered.contains_key(id)
322            && let Some(el) = handlers.get(*id)
323            && let Some(msg) = &el.on_hover
324        {
325            out.msgs.push(msg.clone());
326        }
327    }
328    let changed = hovered.len() != state.hovered.len()
329        || hovered.keys().any(|id| !state.hovered.contains_key(id));
330    if changed {
331        state.hovered = hovered;
332        out.redraw = true;
333    }
334    out.cursor = Some(cursor_of(handlers, &chain));
335}
336
337/// Whether any direct child is a hover overlay (tooltip): hovering the
338/// anchor must be tracked even without hover styling.
339fn has_hover_overlay<Msg>(el: &Element<Msg>) -> bool {
340    el.children.iter().any(|c| {
341        matches!(
342            c.overlay,
343            Some(crate::element::Overlay {
344                mode: crate::element::OverlayMode::Hover { .. },
345                ..
346            })
347        )
348    })
349}
350
351/// Recomputes the hover set against a freshly-built frame without emitting
352/// messages — used after scrolling moves content under a stationary
353/// pointer. Returns `true` when the hover set changed.
354pub fn refresh_hover<Msg>(root: &Element<Msg>, frame: &Frame, state: &mut FrameState) -> bool {
355    let Some((x, y)) = state.pointer else {
356        return false;
357    };
358    let handlers = Handlers::collect(root, state, frame.canvas_height());
359    let chain = frame.hit_chain(Point::new(f64::from(x), f64::from(y)));
360    let now = state.now();
361    let hovered: HashMap<WidgetId, f64> = chain
362        .iter()
363        .copied()
364        .filter(|id| {
365            handlers.get(*id).is_some_and(|el| {
366                !el.disabled
367                    && (el.hover_style.is_some()
368                        || el.state_layer.is_some()
369                        || el.on_click.is_some()
370                        || el.on_hover.is_some()
371                        || frame.toggle_overlay_of(*id).is_some()
372                        || has_hover_overlay(el))
373            })
374        })
375        .map(|id| (id, state.hovered.get(&id).copied().unwrap_or(now)))
376        .collect();
377    let changed = hovered.len() != state.hovered.len()
378        || hovered.keys().any(|id| !state.hovered.contains_key(id));
379    if changed {
380        state.hovered = hovered;
381        true
382    } else {
383        false
384    }
385}
386
387/// The message an enabled element with the given id would emit when
388/// clicked, if any. The shell uses it to honor accessibility action
389/// requests (AccessKit `Action::Click`) without synthesizing pointer
390/// events.
391pub fn click_msg_of<Msg: Clone>(
392    root: &Element<Msg>,
393    frame: &Frame,
394    state: &FrameState,
395    id: WidgetId,
396) -> Option<Msg> {
397    let handlers = Handlers::collect(root, state, frame.canvas_height());
398    handlers
399        .get(id)
400        .filter(|el| !el.disabled)
401        .and_then(|el| el.on_click.clone())
402}
403
404/// The first element in tree order carrying an OS file-drop handler.
405fn first_file_drop<Msg>(el: &Element<Msg>) -> Option<&(dyn Fn(&std::path::Path) -> Msg + '_)> {
406    if let Some(f) = &el.on_file_drop {
407        return Some(&**f);
408    }
409    el.children.iter().find_map(first_file_drop)
410}
411
412/// Minimum flick distance (logical px) to count as a swipe.
413const SWIPE_MIN_DIST: f32 = 24.0;
414/// Maximum flick duration (seconds) — slower than this is a drag, not a swipe.
415const SWIPE_MAX_SECS: f64 = 0.5;
416
417/// Classifies a press-to-release delta and duration as a [`SwipeDir`], or `None`
418/// when the gesture is too short or too slow to be a flick.
419fn recognize_swipe(dx: f32, dy: f32, dt: f64) -> Option<SwipeDir> {
420    if dx.hypot(dy) < SWIPE_MIN_DIST || !(0.0..=SWIPE_MAX_SECS).contains(&dt) {
421        return None;
422    }
423    Some(if dx.abs() >= dy.abs() {
424        if dx >= 0.0 {
425            SwipeDir::Right
426        } else {
427            SwipeDir::Left
428        }
429    } else if dy >= 0.0 {
430        SwipeDir::Down
431    } else {
432        SwipeDir::Up
433    })
434}
435
436/// Dispatches one event against the last laid-out frame, updating retained
437/// interaction state and collecting emitted messages.
438pub fn dispatch<Msg: Clone>(
439    root: &Element<Msg>,
440    frame: &Frame,
441    state: &mut FrameState,
442    fonts: &mut Fonts,
443    event: InputEvent,
444) -> Dispatch<Msg> {
445    let handlers = Handlers::collect(root, state, frame.canvas_height());
446    let mut out = Dispatch::default();
447
448    match event {
449        InputEvent::PointerMove { x, y } => {
450            let point = Point::new(f64::from(x), f64::from(y));
451            state.pointer = Some((x, y));
452
453            if let Some(active) = state.active {
454                // Active capture: the pressed element keeps receiving events.
455                if let Some(el) = handlers.get(active)
456                    && !el.disabled
457                {
458                    if matches!(el.kind, Kind::Input(_)) {
459                        // Drag extends the text selection.
460                        let now = state.now();
461                        if let Some((lx, ly)) = input_local(frame, state, el, active, point) {
462                            if let Some(editor) = state.editors.get_mut(&active) {
463                                input::pointer_drag(editor, fonts, lx, ly);
464                                editor.last_activity = now;
465                            }
466                            out.redraw = true;
467                        }
468                    } else if el.selectable && matches!(el.kind, Kind::Text(_) | Kind::Rich(_)) {
469                        if let Some((sid, sel, true)) = state.static_sel
470                            && sid == active
471                            && let Some((text, style)) = frame.static_text_of(active)
472                            && let Some(rect) = frame.rect_of(active)
473                            && let Some(point) = frame.to_layout_point(active, point)
474                        {
475                            #[expect(
476                                clippy::cast_possible_truncation,
477                                reason = "text coords fit in f32"
478                            )]
479                            let sel = fonts.static_extend(
480                                &text,
481                                style,
482                                Some(rect.width() as f32),
483                                sel,
484                                (point.x - rect.x0) as f32,
485                                (point.y - rect.y0) as f32,
486                            );
487                            state.static_sel = Some((active, sel, true));
488                            out.redraw = true;
489                        }
490                    } else if let Some(f) = &el.on_drag
491                        && let Some((fx, fy)) = frame.fraction_in(active, point)
492                        && let Some(msg) = f(fx, fy)
493                    {
494                        out.msgs.push(msg);
495                    }
496                }
497                out.cursor = Some(cursor_of(&handlers, &[active]));
498                return out;
499            }
500
501            update_hover(&handlers, frame, state, point, &mut out);
502        }
503        InputEvent::Modifiers {
504            shift,
505            ctrl,
506            alt,
507            meta,
508        } => {
509            state.mods = (shift, ctrl, alt, meta);
510        }
511        InputEvent::PointerLeave => {
512            state.pointer = None;
513            if !state.hovered.is_empty() {
514                state.hovered.clear();
515                out.redraw = true;
516            }
517            out.cursor = Some(Cursor::Default);
518        }
519        InputEvent::PointerDown => {
520            // A press with no known pointer position cannot hit anything.
521            let Some((px, py)) = state.pointer else {
522                return out;
523            };
524            let point = Point::new(f64::from(px), f64::from(py));
525            let chain = frame.hit_chain(point);
526            // Internal drag: the deepest drag source under the press.
527            state.dragging = chain.iter().rev().find_map(|id| {
528                handlers
529                    .get(*id)
530                    .filter(|el| !el.disabled)
531                    .and_then(|el| el.drag_source.clone())
532            });
533
534            // Outside-click handling for open overlays: clicking outside an
535            // open toggle (menu) closes it and swallows the press; clicking
536            // a modal backdrop asks the app to close via on_close.
537            let hit_overlay = chain
538                .first()
539                .and_then(|deepest| frame.overlay_containing(*deepest));
540            let mut closed_any = false;
541            for (overlay_id, mode) in frame.open_overlays_top_down() {
542                if matches!(mode, crate::element::OverlayMode::Toggle)
543                    && hit_overlay != Some(overlay_id)
544                    && !chain
545                        .iter()
546                        .any(|id| frame.toggle_overlay_of(*id) == Some(overlay_id))
547                {
548                    state.close_overlay(overlay_id);
549                    closed_any = true;
550                }
551            }
552            if closed_any {
553                out.redraw = true;
554                out.cursor = Some(cursor_of(&handlers, &chain));
555                return out;
556            }
557            if chain.is_empty()
558                && let Some(modal) = frame.top_overlay_is_modal()
559            {
560                // The backdrop swallowed the press.
561                if let Some(el) = handlers.get(modal)
562                    && let Some(msg) = &el.on_close
563                {
564                    out.msgs.push(msg.clone());
565                }
566                out.redraw = true;
567                return out;
568            }
569            // Deepest interactive node wins the press.
570            // A press anywhere ends the previous static selection;
571            // selecting below re-establishes one.
572            if state.static_sel.take().is_some() {
573                out.redraw = true;
574            }
575            let target = chain.iter().rev().copied().find(|id| {
576                handlers.get(*id).is_some_and(|el| {
577                    !el.disabled
578                        && (el.on_click.is_some()
579                            || el.on_drag.is_some()
580                            || el.on_swipe.is_some()
581                            || el.focusable
582                            || el.selectable
583                            || frame.toggle_overlay_of(*id).is_some())
584                })
585            });
586            if let Some(id) = target {
587                state.active = Some(id);
588                // Remember where/when the press began, for swipe recognition.
589                state.press_origin = state.pointer.map(|(x, y)| (x, y, state.now()));
590                if let Some(el) = handlers.get(id) {
591                    if el.focusable && state.focus != Some(id) {
592                        state.focus = Some(id);
593                        state.focus_visible = false;
594                    } else if el.focusable {
595                        state.focus_visible = false;
596                    }
597                    // Clicking a toggle-overlay anchor opens/closes its menu.
598                    if let Some(overlay_id) = frame.toggle_overlay_of(id) {
599                        if state.overlay_open(overlay_id) {
600                            state.close_overlay(overlay_id);
601                        } else {
602                            state.open_overlay(overlay_id);
603                        }
604                    }
605                    if matches!(el.kind, Kind::Input(_)) {
606                        let now = state.now();
607                        // Press chains: 1 = place caret, 2 = word, 3 = line
608                        // (platform convention: selection happens on press).
609                        let count = match state.last_press {
610                            Some((pid, at, c)) if pid == id && now - at <= 0.4 => (c % 3) + 1,
611                            _ => 1,
612                        };
613                        state.last_press = Some((id, now, count));
614                        if let Some((lx, ly)) = input_local(frame, state, el, id, point)
615                            && let Some(editor) = state.editors.get_mut(&id)
616                        {
617                            match count {
618                                2 => input::select_word_at(editor, fonts, lx, ly),
619                                3 => input::select_line_at(editor, fonts, lx, ly),
620                                _ => input::pointer_down(editor, fonts, lx, ly, state.mods.0),
621                            }
622                            editor.last_activity = now;
623                        }
624                    } else if el.selectable && matches!(el.kind, Kind::Text(_) | Kind::Rich(_)) {
625                        let now = state.now();
626                        let count = match state.last_press {
627                            Some((pid, at, c)) if pid == id && now - at <= 0.4 => (c % 3) + 1,
628                            _ => 1,
629                        };
630                        state.last_press = Some((id, now, count));
631                        if let Some((text, style)) = frame.static_text_of(id)
632                            && let Some(rect) = frame.rect_of(id)
633                            && let Some(point) = frame.to_layout_point(id, point)
634                        {
635                            #[expect(
636                                clippy::cast_possible_truncation,
637                                reason = "text coords fit in f32"
638                            )]
639                            let sel = fonts.static_select(
640                                &text,
641                                style,
642                                Some(rect.width() as f32),
643                                count,
644                                (point.x - rect.x0) as f32,
645                                (point.y - rect.y0) as f32,
646                            );
647                            state.static_sel = Some((id, sel, true));
648                        }
649                    } else if let Some(f) = &el.on_drag
650                        && let Some((fx, fy)) = frame.fraction_in(id, point)
651                        && let Some(msg) = f(fx, fy)
652                    {
653                        out.msgs.push(msg);
654                    }
655                }
656                out.redraw = true;
657            } else if state.focus.is_some() {
658                // Clicking empty space drops focus.
659                state.focus = None;
660                state.focus_visible = false;
661                out.redraw = true;
662            }
663            out.cursor = Some(cursor_of(&handlers, &chain));
664        }
665        InputEvent::FileDrop(path) => {
666            // Deepest enabled handler under the pointer, else the first
667            // handler in the declared tree (predictable fallback).
668            let hit = state.pointer.and_then(|(px, py)| {
669                frame
670                    .hit_chain(Point::new(f64::from(px), f64::from(py)))
671                    .iter()
672                    .rev()
673                    .find_map(|id| {
674                        handlers
675                            .get(*id)
676                            .filter(|el| !el.disabled && el.on_file_drop.is_some())
677                            .map(|_| *id)
678                    })
679            });
680            let msg = match hit {
681                Some(id) => handlers
682                    .get(id)
683                    .and_then(|el| el.on_file_drop.as_ref())
684                    .map(|f| f(&path)),
685                None => first_file_drop(root).map(|f| f(&path)),
686            };
687            if let Some(msg) = msg {
688                out.msgs.push(msg);
689                out.redraw = true;
690            }
691        }
692        InputEvent::RightDown => {
693            if let Some((px, py)) = state.pointer {
694                let chain = frame.hit_chain(Point::new(f64::from(px), f64::from(py)));
695                // Deepest enabled element with a right-click handler wins.
696                if let Some(msg) = chain.iter().rev().find_map(|id| {
697                    handlers
698                        .get(*id)
699                        .filter(|el| !el.disabled)
700                        .and_then(|el| el.on_right_click.clone())
701                }) {
702                    out.msgs.push(msg);
703                    out.redraw = true;
704                }
705            }
706        }
707        InputEvent::RightUp => {}
708        InputEvent::PointerUp => {
709            if let Some((sid, sel, true)) = state.static_sel {
710                state.static_sel = Some((sid, sel, false));
711            }
712            // Internal drag completion: deliver the payload to the deepest
713            // drop target under the release, if any.
714            if let Some(payload) = state.dragging.take()
715                && let Some((px, py)) = state.pointer
716                && let Some(msg) = frame
717                    .hit_chain(Point::new(f64::from(px), f64::from(py)))
718                    .iter()
719                    .rev()
720                    .find_map(|id| {
721                        handlers
722                            .get(*id)
723                            .filter(|el| !el.disabled)
724                            .and_then(|el| el.on_drop.as_ref())
725                            .and_then(|f| f(&payload))
726                    })
727            {
728                out.msgs.push(msg);
729                out.redraw = true;
730            }
731            if let Some(active) = state.active.take() {
732                // Click = press + release on the same element.
733                if let Some((px, py)) = state.pointer
734                    && frame
735                        .hit_chain(Point::new(f64::from(px), f64::from(py)))
736                        .contains(&active)
737                    && let Some(el) = handlers.get(active)
738                    && !el.disabled
739                {
740                    if let Some(msg) = &el.on_click {
741                        out.msgs.push(msg.clone());
742                        // Menus close when something inside them is chosen.
743                        if let Some(overlay_id) = frame.overlay_containing(active) {
744                            state.close_overlay(overlay_id);
745                        }
746                    }
747                    // Double click: a second completed click on the same
748                    // element within the window. Both singles also fire.
749                    let now = state.now();
750                    let doubled = state
751                        .last_click
752                        .is_some_and(|(id, at)| id == active && now - at <= 0.4);
753                    if doubled {
754                        if let Some(msg) = &el.on_double_click {
755                            out.msgs.push(msg.clone());
756                        }
757                        state.last_click = None;
758                    } else {
759                        state.last_click = Some((active, now));
760                    }
761                }
762                // Drag end: a captured drag (the press landed on an `on_drag`
763                // element) commits on release — even if the pointer has since
764                // left the element, mirroring `on_drag` firing on press. A
765                // plain click on a click-only element has no `on_drag`, so it
766                // never fires here.
767                if let Some(el) = handlers.get(active)
768                    && !el.disabled
769                    && el.on_drag.is_some()
770                    && let Some(msg) = &el.on_drag_end
771                {
772                    out.msgs.push(msg.clone());
773                    out.redraw = true;
774                }
775                // Swipe: a fast flick from the press origin past a small distance
776                // fires `on_swipe` with the dominant direction.
777                if let Some(el) = handlers.get(active)
778                    && !el.disabled
779                    && let Some(f) = &el.on_swipe
780                    && let (Some((ox, oy, ot)), Some((px, py))) =
781                        (state.press_origin, state.pointer)
782                    && let Some(dir) = recognize_swipe(px - ox, py - oy, state.now() - ot)
783                {
784                    out.msgs.push(f(dir));
785                    out.redraw = true;
786                }
787                out.redraw = true;
788            }
789            state.press_origin = None;
790            // Capture ended: hover reflects whatever is now under the
791            // pointer (it was frozen at press-time contents during capture).
792            if let Some((x, y)) = state.pointer {
793                update_hover(
794                    &handlers,
795                    frame,
796                    state,
797                    Point::new(f64::from(x), f64::from(y)),
798                    &mut out,
799                );
800            }
801        }
802        InputEvent::Wheel { dx, dy } => {
803            if let Some((x, y)) = state.pointer {
804                let p = Point::new(f64::from(x), f64::from(y));
805                // dy and dx route to the nearest scroller on their OWN axis —
806                // which may be different containers (e.g. a horizontal pane
807                // nested in a vertical one).
808                if dy.abs() > 1e-3
809                    && let Some(id) = frame.scrollable_y_at(p)
810                {
811                    state.scroll_by(id, -dy);
812                    out.redraw = true;
813                }
814                if dx.abs() > 1e-3
815                    && let Some(id) = frame.scrollable_x_at(p)
816                {
817                    state.scroll_by_x(id, -dx);
818                    out.redraw = true;
819                }
820            }
821        }
822        InputEvent::Tab | InputEvent::ShiftTab => {
823            let order = frame.focusables();
824            if !order.is_empty() {
825                let next = match state
826                    .focus
827                    .and_then(|f| order.iter().position(|id| *id == f))
828                {
829                    Some(i) if matches!(event, InputEvent::Tab) => order[(i + 1) % order.len()],
830                    Some(i) => order[(i + order.len() - 1) % order.len()],
831                    None if matches!(event, InputEvent::Tab) => order[0],
832                    None => order[order.len() - 1],
833                };
834                state.focus = Some(next);
835                state.focus_visible = true;
836                out.redraw = true;
837            }
838        }
839        InputEvent::Key(key) => {
840            let mut key_handled = false;
841            if let Some(focus) = state.focus
842                && let Some(el) = handlers.get(focus)
843                && !el.disabled
844            {
845                // Focused inputs consume editing and navigation keys first.
846                if matches!(el.kind, Kind::Input(_)) {
847                    let now = state.now();
848                    let st = &mut *state;
849                    if let Some(editor) = st.editors.get_mut(&focus) {
850                        let outcome = input::handle_key(editor, fonts, st.clipboard.as_mut(), &key);
851                        editor.last_activity = now;
852                        if outcome.changed {
853                            if let Some(f) = &el.on_input {
854                                out.msgs.push(f(editor.editor.raw_text()));
855                            }
856                            out.redraw = true;
857                            return out;
858                        }
859                        if outcome.consumed {
860                            out.redraw = true;
861                            return out;
862                        }
863                    }
864                }
865                if let Some(f) = &el.on_key
866                    && let Some(msg) = f(&key)
867                {
868                    out.msgs.push(msg);
869                    out.redraw = true;
870                    key_handled = true;
871                }
872                if let Some(f) = &el.on_type_ahead {
873                    if matches!(key.key, Key::Escape) {
874                        state.type_ahead = None;
875                    } else if let Key::Char(c) = key.key
876                        && !key.ctrl
877                        && !key.meta
878                        && !c.is_control()
879                    {
880                        let now = state.now();
881                        let buffer = match state.type_ahead.take() {
882                            // Continue the buffer within the window.
883                            Some((id, mut b, at)) if id == focus && now - at <= 1.0 => {
884                                b.push(c);
885                                b
886                            }
887                            _ => c.to_string(),
888                        };
889                        if let Some(msg) = f(&buffer) {
890                            out.msgs.push(msg);
891                            out.redraw = true;
892                            key_handled = true;
893                        }
894                        state.type_ahead = Some((focus, buffer, now));
895                    }
896                }
897                // Enter/Space activate clickables and toggle anchored menus.
898                if matches!(key.key, Key::Enter | Key::Space) {
899                    if let Some(msg) = &el.on_click {
900                        out.msgs.push(msg.clone());
901                        out.redraw = true;
902                    }
903                    if let Some(overlay_id) = frame.toggle_overlay_of(focus) {
904                        if state.overlay_open(overlay_id) {
905                            state.close_overlay(overlay_id);
906                        } else {
907                            state.open_overlay(overlay_id);
908                        }
909                        out.redraw = true;
910                    }
911                }
912            }
913            // Cmd/Ctrl+C copies an active static-text selection when no
914            // focused editor consumed the chord.
915            if !key_handled
916                && (key.meta || key.ctrl)
917                && matches!(key.key, Key::Char(c) if c.eq_ignore_ascii_case(&'c'))
918                && let Some((sid, sel, _)) = state.static_sel
919            {
920                let range = sel.text_range();
921                if range.start < range.end
922                    && let Some((text, _)) = frame.static_text_of(sid)
923                {
924                    let full = text.to_text();
925                    if let Some(slice) = full.get(range) {
926                        state.clipboard.as_mut().set(slice.to_owned());
927                    }
928                }
929            }
930
931            // Keyboard paging drives the focused element's nearest
932            // scrollable (or the first one) unless on_key consumed the key.
933            if !key_handled
934                && matches!(key.key, Key::PageUp | Key::PageDown | Key::Home | Key::End)
935                && let Some((target, rect)) = frame.scroll_target_for(state.focus)
936            {
937                #[expect(clippy::cast_possible_truncation, reason = "viewports fit in f32")]
938                let page = (rect.height() * 0.9) as f32;
939                match key.key {
940                    Key::PageDown => state.scroll_by(target, page),
941                    Key::PageUp => state.scroll_by(target, -page),
942                    Key::End => state.scroll_to(target, f32::MAX),
943                    Key::Home => state.scroll_to(target, 0.0),
944                    _ => {}
945                }
946                out.redraw = true;
947            }
948            // Esc closes the top overlay: toggles close directly, app-driven
949            // overlays are asked via on_close.
950            if matches!(key.key, Key::Escape)
951                && let Some((overlay_id, mode)) = frame.open_overlays_top_down().first().copied()
952            {
953                match mode {
954                    crate::element::OverlayMode::Toggle => {
955                        state.close_overlay(overlay_id);
956                        out.redraw = true;
957                    }
958                    crate::element::OverlayMode::Open => {
959                        if let Some(el) = handlers.get(overlay_id)
960                            && let Some(msg) = &el.on_close
961                        {
962                            out.msgs.push(msg.clone());
963                            out.redraw = true;
964                        }
965                    }
966                    crate::element::OverlayMode::Hover { .. } => {}
967                }
968            }
969        }
970        InputEvent::Text(text) => {
971            if let Some(focus) = state.focus
972                && let Some(el) = handlers.get(focus)
973                && !el.disabled
974            {
975                if matches!(el.kind, Kind::Input(_)) {
976                    let now = state.now();
977                    if let Some(editor) = state.editors.get_mut(&focus) {
978                        let outcome = input::handle_text(editor, fonts, &text);
979                        editor.last_activity = now;
980                        if outcome.changed {
981                            if let Some(f) = &el.on_input {
982                                out.msgs.push(f(editor.editor.raw_text()));
983                            }
984                            out.redraw = true;
985                        }
986                    }
987                } else if text == " "
988                    && let Some(msg) = &el.on_click
989                {
990                    // The runner sends printable keys as Text; Space must
991                    // still activate focused buttons.
992                    out.msgs.push(msg.clone());
993                    out.redraw = true;
994                }
995            }
996        }
997        InputEvent::ImePreedit { text, cursor } => {
998            let now = state.now();
999            if let Some(focus) = state.focus
1000                && let Some(el) = handlers.get(focus)
1001                && !el.disabled
1002                && matches!(el.kind, Kind::Input(_))
1003                && let Some(editor) = state.editors.get_mut(&focus)
1004            {
1005                input::handle_preedit(editor, fonts, &text, cursor);
1006                editor.last_activity = now;
1007                out.redraw = true;
1008            }
1009        }
1010    }
1011    out
1012}
1013
1014/// The cursor of the deepest element in the chain that sets one.
1015fn cursor_of<Msg>(handlers: &Handlers<'_, Msg>, chain: &[WidgetId]) -> Cursor {
1016    chain
1017        .iter()
1018        .rev()
1019        .find_map(|id| {
1020            handlers.get(*id).and_then(|el| {
1021                if el.disabled && el.cursor.is_some() {
1022                    Some(Cursor::NotAllowed)
1023                } else {
1024                    el.cursor
1025                }
1026            })
1027        })
1028        .unwrap_or(Cursor::Default)
1029}
1030
1031/// Maps a screen point into editor-layout coordinates for an input element:
1032/// inside the padding box, with the horizontal follow-scroll applied. The y
1033/// is the vertical middle (single-line editors clamp to the line anyway).
1034fn input_local<Msg>(
1035    frame: &Frame,
1036    state: &FrameState,
1037    el: &Element<Msg>,
1038    id: WidgetId,
1039    point: Point,
1040) -> Option<(f64, f64)> {
1041    let rect = frame.rect_of(id)?;
1042    let point = frame.to_layout_point(id, point)?;
1043    let scroll_x = state.editors.get(&id).map_or(0.0, |e| e.scroll_x);
1044    let pad = f64::from(el.style().padding.left);
1045    Some((point.x - rect.x0 - pad + scroll_x, rect.height() * 0.5))
1046}