//! Touch and tap handling on `Forge`: down/up/motion events, single/double
//! tap resolution, the flick-velocity kinetic scroll, and the bottom/handle
//! touch gestures. Operates on `Forge`.

use super::*;

impl Forge {
    pub(super) fn on_touch_down(&mut self, event: TouchEvent) {
        if self.shell.screenshot.is_active() {
            self.shell.screenshot.on_press(event.position);
            self.request_redraw();
            return;
        }
        self.input.draw_cursor = false;
        let canvas = self.windows.canvas();
        self.input.gesture_recognizer.set_screen_size( canvas.size() );
        if let Some( ev ) = self.input.gesture_recognizer.on_touch_down( event.position, 1 )
        {
            Self::emit_gesture( &ev );
        }

        // A touch-down begins a fresh gesture, so clear any swipe arm state a
        // previous sequence left behind. A press on an SSD close button in the
        // top band arms `topbar_swipe` too, but the button's release returns
        // early without ending it; without this reset a later swipe — even one
        // starting from the dock — would resume that stale top-anchored swipe
        // and wrongly pull down quick settings. Re-armed below when in zone.
        self.input.topbar_swipe = None;
        self.input.bottom_swipe = None;

        // Track potential topbar swipe-down: arm the state here; it
        // becomes active on vertical motion past SWIPE_START_THRESHOLD.
        // Lockscreen views keep their own input paths.
        //
        // The arm zone is 60 logical px (roughly a fingertip below the
        // visual topbar strip), which matches the default edge-pull
        // gesture threshold so both recognisers arm off the same press.
        const SWIPE_ARM_BAND: f64 = 60.0;
        if !self.auth_active()
            && !self.shell.app_switcher.is_active()
            && !self.windows.is_locked()
            && !self.windows.has_exclusive_foreground_at(event.position)
            && event.position.y >= 0.0
            && event.position.y < SWIPE_ARM_BAND
        {
            let out_h = self.windows.canvas().size().h as f64;
            self.input.topbar_swipe = Some(crate::forge::TopbarSwipe {
                start_y: event.position.y,
                active: false,
                last_sent_progress: -1.0,
                full_distance: crate::forge::TopbarSwipe::full_distance_for(out_h),
            });
        }

        // Arm the bottom edge swipe-up. On the homescreen it may start
        // anywhere (the dock-top-or-above zone reveals the launcher, the very
        // bottom forms the switcher); over an app it must start in the bottom
        // band so a mid-screen swipe stays with the app's own content.
        let canvas_h = self.windows.canvas().size().h as f64;
        let home_active = self.windows.homescreen_active();
        let excl_fg = self.windows.has_exclusive_foreground_at(event.position);
        let in_zone = home_active || event.position.y >= canvas_h - BOTTOM_ARM_BAND;
        let can_arm = self.windows.canvas().is_mobile()
            && !self.auth_active()
            && !self.shell.app_switcher.is_active()
            && !self.windows.is_locked()
            && !excl_fg
            && self.input.topbar_swipe.is_none()
            && in_zone;
        if can_arm {
            self.input.bottom_swipe = Some(crate::forge::BottomSwipe::armed(
                event.position.x,
                event.position.y,
                canvas_h,
            ));
        }

        // Alt+Tab switcher: act on press, not on the single-tap timer.
        // Without this the activation is delayed by `DOUBLE_TAP_TIMEOUT`
        // (300ms) — `on_single_tap` already routes alt-tab clicks, but
        // the user sees a sluggish gap between click and window raise.
        // Handling here makes the corner-gesture → click-to-select flow
        // (eydos #6, pedro's clarification) feel immediate. We also
        // cancel the pending single-tap timer so the same click doesn't
        // get re-dispatched 300ms later.
        if self.shell.app_switcher.is_active() && !self.shell.app_switcher.is_swiping() {
            // Mobile: carousel — defer activation to release so motion can
            // accumulate into a drag and inertia, not a stray click.
            if self.windows.canvas().is_mobile() {
                self.shell.app_switcher.carousel_drag_begin(event.position);
                self.input.touch_state.start(canvas, event.slot, event.position);
                self.request_redraw();
                return;
            }
            // Desktop: a press on a tile arms a cross-screen drag; the
            // release decides between "moved to another screen" and a plain
            // click. Nothing else happens on press, so click-to-activate
            // keeps its immediacy through the release path below.
            if self.shell.app_switcher.tile_drag_begin(event.position) {
                if let Some(token) = self.input.touch_state.pending_single_tap.take() {
                    self.event_loop.remove(token);
                }
                self.input.touch_state.input_surface = None;
                self.input.touch_state.active_app_id = None;
                self.input.touch_state.tap_surface   = None;
                self.request_redraw();
                return;
            }
            use crate::app_switcher::AppSwitcherClick;
            match self.shell.app_switcher.click_at(event.position) {
                AppSwitcherClick::Activate(idx) => {
                    if let Some(oid) = self.shell.app_switcher.commit_object_id_index(idx) {
                        self.windows.activate_by_object_id(&oid);
                        if let Some(s) = self.windows.activate_x11_by_object_id(&oid) {
                            if let Some(xwm) = self.xwm.as_mut() {
                                let _ = xwm.raise_window(&s);
                            }
                            let _ = s.set_activated(true);
                        }
                    }
                }
                AppSwitcherClick::Close(idx) => {
                    if let Some(oid) = self.shell.app_switcher.object_id_at(idx) {
                        self.windows.close_window_by_object_id(&oid);
                        self.shell.app_switcher.remove_at(idx);
                    } else if let Some(id) = self.shell.app_switcher.surface_id_at(idx) {
                        self.windows.close_window_by_id(id as u64);
                        self.shell.app_switcher.remove_at(idx);
                    }
                }
                AppSwitcherClick::Dismiss => {
                    self.dismiss_app_switcher();
                }
                AppSwitcherClick::NoOp => {
                    // Open-grace click — swallow it without dismissing so the
                    // user keeps the panel open and can try again on a tile.
                }
            }
            if let Some(token) = self.input.touch_state.pending_single_tap.take() {
                self.event_loop.remove(token);
            }
            self.input.touch_state.input_surface = None;
            self.input.touch_state.active_app_id = None;
            self.input.touch_state.tap_surface   = None;
            self.request_redraw();
            return;
        }

        self.input.touch_state.start(canvas, event.slot, event.position);

        // Desktop mode: intercept SSD buttons and drag gestures before
        // the normal surface focus path. Order matters:
        //  1. Close/Max/Min buttons — armed on press, applied on release
        //     if it falls on the same button (cancellable by dragging off).
        //  2. Resize grab band — outside the frame, tested before move.
        //  3. Title-bar move — covers the rest of the bar.
        // A top/overlay layer surface covering the point (dock, calendar,
        // any crustace panel) stacks above windows and their SSD chrome,
        // so it exempts the whole interception block.
        if !self.windows.canvas().is_mobile()
            && !self.windows.foreground_layer_at(event.position)
        {
            let armed = if let Some((wl, action)) = self.windows.ssd_x11_hit_test(event.position) {
                if matches!(action, SsdAction::Close | SsdAction::Maximize | SsdAction::Minimize) {
                    Some(ArmedSsdButton { slot: event.slot, target: ArmedSsdTarget::X11(wl), action })
                } else {
                    None
                }
            } else if let Some((window_rc, action)) = self.windows.ssd_window_hit_test(event.position) {
                if matches!(action, SsdAction::Close | SsdAction::Maximize | SsdAction::Minimize) {
                    Some(ArmedSsdButton { slot: event.slot, target: ArmedSsdTarget::Wayland(window_rc), action })
                } else {
                    None
                }
            } else {
                None
            };

            if let Some(armed) = armed {
                self.input.armed_ssd = Some(armed);
                self.input.touch_state.input_surface = None;
                self.input.touch_state.active_app_id = None;
                self.input.touch_state.tap_surface = None;
                self.input.touch_state.slot = None;
                self.input.touch_state.last_tap = None;
                if let Some(token) = self.input.touch_state.pending_single_tap.take() {
                    self.event_loop.remove(token);
                }
                self.windows.set_dirty();
                return;
            }

            if self.windows.start_resize_drag(event.position) {
                self.input.touch_state.input_surface = None;
                self.input.touch_state.active_app_id = None;
                self.input.touch_state.tap_surface = None;
                self.windows.set_dirty();
                return;
            }

            // SSD title-bar: double-click toggles maximize, single click
            // starts a move drag. We detect double-click here before
            // starting the drag.
            if self.windows.start_move_drag(event.position) {
                let now = Instant::now();
                let is_double = self.input.ssd_last_press
                    .take()
                    .is_some_and(|(t, p)| {
                        now.duration_since(t) < DOUBLE_TAP_TIMEOUT
                            && (p - event.position).to_f64().x.powi(2)
                             + (p - event.position).to_f64().y.powi(2)
                             < DOUBLE_TAP_DISTANCE_SQ
                    });

                if is_double {
                    self.windows.toggle_maximize_dragged();
                } else {
                    self.input.ssd_last_press = Some((now, event.position));
                }

                self.input.touch_state.input_surface = None;
                self.input.touch_state.active_app_id = None;
                self.input.touch_state.tap_surface = None;
                self.windows.set_dirty();
                return;
            }
        }

        let surface = self.windows.surface_at(event.position);

        self.input.touch_state.input_surface = None;
        self.input.touch_state.active_app_id = None;
        self.input.touch_state.tap_surface = None;

        if let Some(mut input_surface) = surface {
            let app_id = input_surface.toplevel.as_mut().and_then(InputSurfaceKind::take_app_id);

            let inhibits = compositor::with_states(&input_surface.surface, |states| {
                states.data_map.get::<ForgeSurfaceData>().is_some_and(|d| d.inhibits_shortcuts)
            });

            let has_gesture = || {
                self.input.touch_state
                    .matching_gestures(self.windows.canvas(), app_id.as_ref(), event.position, None)
                    .next()
                    .is_some()
            };

            let gesture_active = self.input.touch_state.start.is_handle_gesture
                || self.input.touch_state.start.is_top_gesture
                || (!inhibits && has_gesture());
            self.input.touch_state.active_app_id = app_id;

            if gesture_active {
                self.input.touch_state.tap_surface = Some(input_surface);
            } else {
                self.on_surface_down(event, &mut input_surface);
                self.input.touch_state.input_surface = Some(input_surface);
            }
        }
    }

    pub(super) fn on_touch_up(&mut self, event: TouchEvent) {
        if self.shell.screenshot.is_active() {
            self.shell.screenshot.on_release();
            self.request_redraw();
            return;
        }
        // A release ALWAYS ends an in-progress title-bar/resize drag, before
        // any other early-return path (armed SSD button, topbar swipe, app
        // switcher). Otherwise a stale `armed_ssd` — armed by pressing a
        // different window that overlaps the same spot — would consume the
        // release in its own branch and leave the drag running forever. Any
        // such armed button is dropped: the drag was the real gesture.
        let drag_ended = if self.windows.is_resize_dragging() {
            self.windows.end_resize_drag();
            true
        } else if self.windows.is_move_dragging() {
            self.windows.end_move_drag();
            true
        } else {
            false
        };
        if drag_ended {
            self.input.armed_ssd = None;
            self.input.touch_state.slot = None;
            self.input.touch_state.is_drag = false;
            self.request_redraw();
            // The drag suppressed pointer.motion for its whole duration, so
            // smithay's pointer focus is stale. Refresh it when the drag was
            // pointer-driven (synthesised POINTER_SLOT touch) so the click
            // that ends it — and the next — go to the surface under the
            // cursor. Real touch drags skip this (the cursor isn't at the
            // finger).
            if event.slot == TouchSlot::from(POINTER_SLOT) {
                self.on_pointer_motion(self.input.touch_state.position, event.time);
            }
            return;
        }

        // Desktop tile drag release: dropped on another screen's panel the
        // window moves there; a drop on its own screen (or a press that
        // never travelled) falls through to the click routing.
        if self.shell.app_switcher.is_active() && !self.windows.canvas().is_mobile() {
            if let Some(idx) = self.shell.app_switcher.tile_drag_end() {
                let target = self.windows.output_name_at(self.input.touch_state.position);
                if let Some(oid) = self.shell.app_switcher.object_id_at(idx) {
                    if self.windows.move_window_to_output(&oid, target.as_deref()) {
                        let entries = self.windows.app_switcher_entries();
                        self.shell.app_switcher.refresh_entries(entries);
                    }
                }
                self.input.touch_state.slot = None;
                self.input.touch_state.is_drag = false;
                self.request_redraw();
                return;
            }
            // A press that never became a drag activates on release, the
            // behaviour the press path used to have.
            use crate::app_switcher::AppSwitcherClick;
            match self.shell.app_switcher.click_at(self.input.touch_state.position) {
                AppSwitcherClick::Activate(idx) => {
                    if let Some(oid) = self.shell.app_switcher.commit_object_id_index(idx) {
                        self.windows.activate_by_object_id(&oid);
                        if let Some(s) = self.windows.activate_x11_by_object_id(&oid) {
                            if let Some(xwm) = self.xwm.as_mut() {
                                let _ = xwm.raise_window(&s);
                            }
                            let _ = s.set_activated(true);
                        }
                    }
                    self.input.touch_state.slot = None;
                    self.input.touch_state.is_drag = false;
                    self.request_redraw();
                    return;
                }
                _ => {}
            }
        }

        // Mobile carousel release: short drag → dispatch a click (which
        // routes Activate / Close / Dismiss through the same path the
        // desktop pointer uses); long drag → hand off to inertia. Skipped
        // while the bottom swipe-up still owns the touch (it resolves the
        // release itself in `end_bottom_swipe`).
        if self.shell.app_switcher.is_active()
            && !self.shell.app_switcher.is_swiping()
            && self.windows.canvas().is_mobile()
        {
            if let Some(click) = self.shell.app_switcher.carousel_drag_end() {
                use crate::app_switcher::AppSwitcherClick;
                match click {
                    AppSwitcherClick::Activate(idx) => {
                        if let Some(oid) = self.shell.app_switcher.commit_object_id_index(idx) {
                            self.windows.activate_by_object_id(&oid);
                            if let Some(s) = self.windows.activate_x11_by_object_id(&oid) {
                                if let Some(xwm) = self.xwm.as_mut() {
                                    let _ = xwm.raise_window(&s);
                                }
                                let _ = s.set_activated(true);
                            }
                        }
                    }
                    AppSwitcherClick::Close(idx) => {
                        if let Some(oid) = self.shell.app_switcher.object_id_at(idx) {
                            self.windows.close_window_by_object_id(&oid);
                            self.shell.app_switcher.remove_at(idx);
                        } else if let Some(id) = self.shell.app_switcher.surface_id_at(idx) {
                            self.windows.close_window_by_id(id as u64);
                            self.shell.app_switcher.remove_at(idx);
                        }
                    }
                    AppSwitcherClick::Dismiss => {
                        self.dismiss_app_switcher();
                    }
                    AppSwitcherClick::NoOp => {}
                }
            }
            self.input.touch_state.slot = None;
            self.input.touch_state.is_drag = false;
            self.request_redraw();
            return;
        }

        if self.input.armed_ssd.as_ref().is_some_and(|a| a.slot == event.slot) {
            let armed = self.input.armed_ssd.take().unwrap();
            let hit_matches = match &armed.target {
                ArmedSsdTarget::Wayland(rc) => self
                    .windows
                    .ssd_window_hit_test(event.position)
                    .is_some_and(|(new_rc, new_action)| {
                        Rc::ptr_eq(rc, &new_rc) && new_action == armed.action
                    }),
                ArmedSsdTarget::X11(surf) => self
                    .windows
                    .ssd_x11_hit_test(event.position)
                    .is_some_and(|(new_surf, new_action)| {
                        &new_surf == surf && new_action == armed.action
                    }),
            };
            let confirmed = hit_matches;
            if confirmed {
                match &armed.target {
                    ArmedSsdTarget::Wayland(rc) => {
                        self.windows.apply_ssd_button(rc, armed.action);
                    }
                    ArmedSsdTarget::X11(surf) => {
                        self.windows.apply_ssd_button_x11(surf, armed.action);
                    }
                }
            }
            self.request_redraw();
            return;
        }

        // End any in-progress topbar swipe first.
        if self.end_topbar_swipe(event.position) {
            self.input.touch_state.slot = None;
            self.input.touch_state.is_drag = false;
            self.request_redraw();
            return;
        }

        // Then any in-progress bottom swipe-up.
        if self.end_bottom_swipe(event.position) {
            self.input.touch_state.slot = None;
            self.input.touch_state.is_drag = false;
            self.request_redraw();
            return;
        }

        for ev in self.input.gesture_recognizer.on_touch_up( event.position )
        {
            self.handle_gesture_action( &ev );
            Self::emit_gesture( &ev );
        }

        let _canvas = self.windows.canvas();

        if self.input.touch_state.input_surface.is_some() && !event.from_pointer {
            self.seat.get_touch().unwrap().up(
                self,
                &UpEvent {
                    serial: SERIAL_COUNTER.next_serial(),
                    slot: event.slot,
                    time: event.time,
                },
            );
        }

        if self.input.touch_state.slot != Some(event.slot) {
            return;
        }
        self.input.touch_state.slot = None;

        match self.input.touch_state.action(self.windows.canvas()) {
            Some(TouchAction::Tap) => {
                self.replay_ignored_tap(event);

                let timer = Timer::from_duration(DOUBLE_TAP_TIMEOUT);
                let token = self.event_loop.insert_source(timer, Self::on_single_tap).unwrap();
                self.input.touch_state.pending_single_tap = Some(token);
                self.input.touch_state.last_tap = Some((Instant::now(), self.input.touch_state.position));
            },
            Some(TouchAction::DoubleTap) => {
                if let Some(token) = self.input.touch_state.pending_single_tap.take() {
                    self.event_loop.remove(token);
                }

                self.replay_ignored_tap(event);
                self.on_double_tap();
            },
            Some(
                TouchAction::Drag | TouchAction::HandleGesture(_) | TouchAction::UserGesture(_),
            ) => {
                self.add_velocity_timeout();
                if self.input.touch_state.input_surface.is_none() {
                    self.update_position(self.input.touch_state.position, None);
                }
            },
            None => self.add_velocity_timeout(),
        }
    }

    pub(super) fn on_touch_motion(&mut self, event: TouchEvent) {
        if self.shell.screenshot.is_active() {
            self.shell.screenshot.on_motion(event.position);
            self.request_redraw();
            return;
        }
        self.input.touch_state.velocity = event.position - self.input.touch_state.position;
        self.input.touch_state.position = event.position;

        // Mobile carousel drag takes precedence: while the app switcher is
        // up on mobile, every motion sample feeds the carousel offset rather
        // than the normal touch routing. Skipped while the switcher is still
        // being formed by the bottom swipe-up — that gesture owns the touch
        // until it commits.
        if self.shell.app_switcher.is_active()
            && !self.shell.app_switcher.is_swiping()
            && self.windows.canvas().is_mobile()
        {
            self.shell.app_switcher.carousel_drag_motion(event.position);
            self.request_redraw();
            return;
        }

        // Title-bar / edge drag takes precedence over anything else.
        if self.windows.is_resize_dragging() {
            self.windows.update_resize_drag(event.position);
            self.unstall();
            return;
        }
        if self.windows.is_move_dragging() {
            self.windows.update_move_drag(event.position);
            self.unstall();
            return;
        }

        // Topbar swipe-down: escalate a pending press into an active
        // swipe once the pointer has moved down past the threshold; from
        // then on forward progress to crustace over IPC.
        if self.update_topbar_swipe(event.position) {
            self.unstall();
            return;
        }

        // Bottom swipe-up: drives the launcher reveal (home) or the app
        // shrink (over an app), and arms the dwell-to-switcher timer.
        if self.update_bottom_swipe(event.position) {
            self.unstall();
            return;
        }

        if let Some( ev ) = self.input.gesture_recognizer.on_touch_motion( event.position )
        {
            Self::emit_gesture( &ev );
        }

        // smithay pins a touch point's focus at `down` and never re-targets;
        // if the grabbed surface died mid-drag, re-open the grab on whatever
        // is now under the finger so the rest of the sequence isn't dropped.
        let grab_dead = self
            .input
            .touch_state
            .input_surface
            .as_ref()
            .is_some_and(|s| !smithay::utils::IsAlive::alive(&s.surface));
        if grab_dead {
            self.input.touch_state.input_surface = None;
            if !event.from_pointer {
                if let Some(mut new_surface) = self.windows.surface_at(event.position) {
                    let down = TouchEvent { ty: TouchEventType::Down, ..event };
                    self.on_surface_down(down, &mut new_surface);
                    self.input.touch_state.input_surface = Some(new_surface);
                }
            }
        }

        if let Some(ref input_surface) = self.input.touch_state.input_surface {
            if !event.from_pointer {
                let scale = self.windows.canvas().scale();
                let scaled_pos = event.position.upscale(scale / input_surface.surface_scale);

                self.seat.get_touch().unwrap().motion(
                    self,
                    None,
                    &MotionEvent { location: scaled_pos, time: event.time, slot: event.slot },
                );
            }
            return;
        }

        if self.input.touch_state.slot == Some(event.slot) {
            self.update_position(event.position, Some(event));
        }
    }

    fn on_surface_down(&mut self, event: TouchEvent, input_surface: &mut InputSurface) {
        match input_surface.toplevel.take() {
            Some(InputSurfaceKind::Layout((window, _))) => {
                self.windows.set_focus(Some(FocusTarget::Layout(window)));
            },
            Some(InputSurfaceKind::Layer((layer, app_id))) => {
                self.windows.set_focus(Some(FocusTarget::Layer(layer, app_id)));
            },
            Some(InputSurfaceKind::X11(wl)) => {
                let x11 = self.windows.x11_windows.find(&wl).cloned();
                self.windows.set_focus(Some(FocusTarget::X11(wl)));
                if let (Some(xwm), Some(surface)) = (self.xwm.as_mut(), x11) {
                    let _ = xwm.raise_window(&surface);
                    let _ = surface.set_activated(true);
                }
            },
            None => (),
        }

        if !event.from_pointer {
            let scale = self.windows.canvas().scale();
            let scaled_pos = event.position.upscale(scale / input_surface.surface_scale);
            let surface_pos =
                Point::from((input_surface.surface_offset.x, input_surface.surface_offset.y));

            self.seat.get_touch().unwrap().down(
                self,
                Some((input_surface.surface.clone(), surface_pos)),
                &DownEvent {
                    serial: SERIAL_COUNTER.next_serial(),
                    slot: event.slot,
                    time: event.time,
                    location: scaled_pos,
                },
            );
        }
    }

    fn update_position(&mut self, position: Point<f64, Logical>, event: Option<TouchEvent>) {
        match (self.input.touch_state.action(self.windows.canvas()), event) {
            (Some(TouchAction::Drag), Some(ev)) => self.promote_withheld_touch(ev),
            (Some(TouchAction::HandleGesture(_)), Some(ev)) if !self.windows.is_fullscreen() => {
                self.promote_withheld_touch(ev)
            },
            (Some(TouchAction::HandleGesture(g)), _) => self.on_handle_gesture(g),
            (Some(TouchAction::UserGesture(a)), _) => self.on_user_gesture(a),
            _ => (),
        }

        self.input.touch_state.position = position;
    }

    /// A touch held back at `down` for a possible edge gesture turned into a
    /// plain drag nobody claimed: hand it to the client after all, starting
    /// with the deferred `down` at the original position.
    fn promote_withheld_touch(&mut self, event: TouchEvent) {
        let Some(mut input_surface) = self.input.touch_state.tap_surface.take() else { return };
        let down = TouchEvent {
            ty: TouchEventType::Down,
            position: self.input.touch_state.start.position,
            ..event
        };
        self.on_surface_down(down, &mut input_surface);
        if !event.from_pointer {
            let scale = self.windows.canvas().scale();
            let scaled_pos = event.position.upscale(scale / input_surface.surface_scale);
            self.seat.get_touch().unwrap().motion(
                self,
                None,
                &MotionEvent { location: scaled_pos, time: event.time, slot: event.slot },
            );
        }
        self.input.touch_state.input_surface = Some(input_surface);
    }

    fn on_handle_gesture(&mut self, gesture: HandleGesture) {
        let open_launcher = if self.input.touch_state.touching() || self.input.touch_state.has_velocity() {
            self.windows.on_gesture(&mut self.input.touch_state, gesture)
        } else {
            let result = self.windows.on_gesture_done(gesture);
            self.input.touch_state.cancel_velocity();
            result
        };

        if open_launcher {
            Self::send_to_crustace(r#"{"action":"toggle_launcher"}"#);
        }

        self.seat.get_touch().unwrap().cancel(self);
    }

    fn on_user_gesture(&mut self, action: GestureAction) {
        match action {
            GestureAction::Exec { program, args } => {
                if let Err(e) = daemon(&program, &args) {
                    error!("Gesture command failed ({program} {args:?}): {e}");
                }
            },
            GestureAction::Key { keysym, mods } => self.send_virtual_key(keysym, mods),
        }

        self.input.touch_state.cancel_velocity();
        self.seat.get_touch().unwrap().cancel(self);
    }

    fn replay_ignored_tap(&mut self, event: TouchEvent) {
        let Some(mut input_surface) = self.input.touch_state.tap_surface.take() else { return };

        self.on_surface_down(event, &mut input_surface);

        if !event.from_pointer {
            self.seat.get_touch().unwrap().up(
                self,
                &UpEvent {
                    serial: SERIAL_COUNTER.next_serial(),
                    time: event.time + 1,
                    slot: event.slot,
                },
            );
            self.seat.get_touch().unwrap().frame(self);
        }

        self.input.touch_state.tap_surface = Some(input_surface);
    }

    fn on_single_tap(_: Instant, _: &mut (), forge: &mut Self) -> TimeoutAction {
        // Handle polkit authentication dialog — modal, takes priority
        if forge.auth_active() {
            use crate::polkit::PolkitAction;
            let canvas = forge.windows.canvas();
            let point = forge.input.touch_state.position;
            let intent = match forge.shell.polkit.handle_tap(canvas, point) {
                Some(PolkitAction::Authenticate) => Some(UiIntent::Authenticate),
                Some(PolkitAction::Cancel) => Some(UiIntent::CancelAuthentication),
                _ => None,
            };
            if let Some(intent) = intent {
                forge.dispatch(intent);
            }
            forge.unstall();
            return TimeoutAction::Drop;
        }

        // Handle portal consent dialog — modal, takes priority
        if forge.windows.confirm.visible {
            let point = forge.input.touch_state.position;
            let canvas = *forge.windows.canvas();
            let choice = forge.windows.confirm.handle_tap(&canvas, point);
            if let Some(allow) = choice {
                forge.dispatch(UiIntent::PortalConsent(allow));
            }
            forge.windows.set_dirty();
            forge.unstall();
            return TimeoutAction::Drop;
        }

        // Handle MPRIS media controls (normal mode)
        {
            let canvas = forge.windows.canvas();
            let point = forge.input.touch_state.position;
            if forge.shell.mpris.handle_tap(canvas, point, false) {
                forge.request_redraw();
                return TimeoutAction::Drop;
            }
        }

        // Alt+Tab switcher takes precedence over everything when visible.
        if forge.shell.app_switcher.is_active() {
            use crate::app_switcher::AppSwitcherClick;
            match forge.shell.app_switcher.click_at(forge.input.touch_state.position) {
                AppSwitcherClick::Activate(idx) => {
                    if let Some(oid) = forge.shell.app_switcher.commit_object_id_index(idx) {
                        forge.windows.activate_by_object_id(&oid);
                        if let Some(s) = forge.windows.activate_x11_by_object_id(&oid) {
                            if let Some(xwm) = forge.xwm.as_mut() {
                                let _ = xwm.raise_window(&s);
                            }
                            let _ = s.set_activated(true);
                        }
                    }
                }
                AppSwitcherClick::Close(idx) => {
                    if let Some(oid) = forge.shell.app_switcher.object_id_at(idx) {
                        forge.windows.close_window_by_object_id(&oid);
                        forge.shell.app_switcher.remove_at(idx);
                    } else if let Some(id) = forge.shell.app_switcher.surface_id_at(idx) {
                        forge.windows.close_window_by_id(id as u64);
                        forge.shell.app_switcher.remove_at(idx);
                    }
                }
                AppSwitcherClick::Dismiss => {
                    forge.dismiss_app_switcher();
                }
                AppSwitcherClick::NoOp => {
                    // Open-grace click — keep the switcher up so the user can
                    // retry on a tile.
                }
            }
            forge.request_redraw();
            return TimeoutAction::Drop;
        }

        // Built-in topbar hit testing — overrides apps in its strip. A
        // kiosk owns the whole screen; its wizard must not be able to
        // summon crustace's calendar / quick settings from the top edge.
        if !forge.session.kiosk {
            if let Some(action) = forge.shell.topbar.hit_test(forge.input.touch_state.position) {
                let msg = match action {
                    crate::topbar::TopbarAction::OpenNotifications => r#"{"action":"open_notifications"}"#,
                    crate::topbar::TopbarAction::OpenQuickSettings => r#"{"action":"open_quick_settings"}"#,
                    crate::topbar::TopbarAction::ToggleCalendar    => r#"{"action":"toggle_calendar"}"#,
                };
                Self::send_to_crustace(msg);
                forge.request_redraw();
                return TimeoutAction::Drop;
            }
        }

        // SSD button presses are handled instantly in `on_touch_down`
        // (see the desktop-mode pre-emption block); the tap-timer path
        // is not reached on desktop for those hits.

        if forge.input.touch_state.input_surface.is_none() {
            forge.windows.set_focus(None);
        }

        forge.unstall();
        TimeoutAction::Drop
    }

    fn on_double_tap(&mut self) {
        if self.input.touch_state.input_surface.is_none() {
            self.windows.set_focus(None);
        }

        self.windows.on_double_tap(self.input.touch_state.position);
        self.unstall();
    }

    fn on_velocity_tick(&mut self) -> TimeoutAction {
        let interval = self.windows.canvas().frame_interval();
        let speed = interval.as_millis() as f64 / 16.0;

        let velocity = &mut self.input.touch_state.velocity;
        let position = &mut self.input.touch_state.position;

        velocity.x -= velocity.x.signum()
            * (velocity.x.abs() * VELOCITY_FRICTION * speed + 1.0).min(velocity.x.abs());
        velocity.y -= velocity.y.signum()
            * (velocity.y.abs() * VELOCITY_FRICTION * speed + 1.0).min(velocity.y.abs());
        position.x += velocity.x * speed;
        position.y += velocity.y * speed;

        self.update_position(self.input.touch_state.position, None);
        self.unstall();

        if self.input.touch_state.has_velocity() {
            TimeoutAction::ToDuration(interval)
        } else {
            TimeoutAction::Drop
        }
    }

    fn add_velocity_timeout(&mut self) {
        if !self.input.touch_state.has_velocity() {
            return;
        }

        if let Some(token) = self.input.touch_state.velocity_timer.take() {
            self.event_loop.remove(token);
        }

        let interval = self.windows.canvas().frame_interval();
        let timer = Timer::from_duration(interval);
        let token = self
            .event_loop
            .insert_source(timer, |_, _, forge| forge.on_velocity_tick())
            .expect("velocity timer");
        self.input.touch_state.velocity_timer = Some(token);
    }
}
