use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};

use forge_ipc::{GestureSector, KeyTrigger, Keysym, Modifiers};
use smithay::backend::input::{
    AbsolutePositionEvent, ButtonState, Event, InputBackend, InputEvent, KeyState,
    KeyboardKeyEvent, MouseButton, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent,
    TouchEvent as _, TouchSlot,
};
use smithay::input::keyboard::{
    keysyms, FilterResult, Keycode, KeysymHandle, ModifiersState, XkbConfig,
};
use smithay::input::pointer::{AxisFrame, ButtonEvent, MotionEvent as PointerMotionData};
use smithay::input::touch::{DownEvent, MotionEvent, UpEvent};
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::calloop::{LoopHandle, RegistrationToken};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Rectangle, SERIAL_COUNTER};
use smithay::wayland::compositor;
use tracing::{error, info};

use crate::config::{GestureAction, GestureBinding};
use crate::daemon;
use crate::drawing::ForgeSurfaceData;
use crate::forge::Forge;
use crate::orientation::Orientation;
use crate::output::{Canvas, GESTURE_HANDLE_HEIGHT};
use crate::ssd::SsdAction;
use crate::windows::focus::FocusTarget;
use crate::windows::surface::{InputSurface, InputSurfaceKind};
use crate::windows::window::Window;

pub const HOLD_DURATION: Duration = Duration::from_secs(1);
pub const REPEAT_DELAY: Duration = Duration::from_millis(500);
pub const REPEAT_RATE: Duration = Duration::from_millis(25);

const DOUBLE_TAP_TIMEOUT: Duration = Duration::from_millis(300);
const DOUBLE_TAP_DISTANCE_SQ: f64 = 2000.0;
const TAP_DISTANCE_SQ: f64 = 400.0;
const VELOCITY_FRICTION: f64 = 0.1;
const POINTER_SLOT: Option<u32> = Some(0);
const TOP_GESTURE_HEIGHT: f64 = 100.0;
/// Bottom band (logical px) within which a swipe-up arms over an app, and
/// the fallback switcher zone when the dock height is unknown.
const BOTTOM_ARM_BAND: f64 = 60.0;
/// How long the finger must dwell (settled past the arm fraction) before the
/// bottom swipe-up commits the app switcher.
const DWELL_MS: u64 = 150;
/// Progress drift (fraction of screen height) the finger may wander without
/// restarting the dwell countdown — a margin so it need not be perfectly
/// still, only roughly settled.
const DWELL_TOLERANCE: f32 = 0.04;

pub enum ArmedSsdTarget {
    Wayland(Rc<RefCell<Window>>),
    X11(WlSurface),
}

pub struct ArmedSsdButton {
    pub slot: TouchSlot,
    pub target: ArmedSsdTarget,
    pub action: SsdAction,
}

pub struct TouchState {
    pub user_gestures: Vec<GestureBinding>,

    last_tap: Option<(Instant, Point<f64, Logical>)>,
    pending_single_tap: Option<RegistrationToken>,
    event_loop: LoopHandle<'static, Forge>,
    velocity_timer: Option<RegistrationToken>,
    repeat_timer: Option<RegistrationToken>,
    input_surface: Option<InputSurface>,
    tap_surface: Option<InputSurface>,
    active_app_id: Option<String>,
    velocity: Point<f64, Logical>,
    position: Point<f64, Logical>,
    events: Vec<TouchEvent>,
    slot: Option<TouchSlot>,
    start: TouchStart,
    is_drag: bool,
}

impl TouchState {
    pub fn new(event_loop: LoopHandle<'static, Forge>) -> Self {
        Self {
            event_loop,
            pending_single_tap: None,
            velocity_timer: None,
            input_surface: None,
            user_gestures: Vec::new(),
            active_app_id: None,
            repeat_timer: None,
            tap_surface: None,
            last_tap: None,
            position: Point::default(),
            velocity: Point::default(),
            is_drag: false,
            events: Vec::new(),
            start: TouchStart::default(),
            slot: None,
        }
    }

    pub fn cancel_velocity(&mut self) {
        if let Some(token) = self.velocity_timer.take() {
            self.event_loop.remove(token);
        }
        self.velocity = Point::default();
    }

    pub fn start_key_repeat<F: FnMut() + 'static>(&mut self, mut callback: F) {
        let timer = Timer::from_duration(REPEAT_DELAY);
        let token = self
            .event_loop
            .insert_source(timer, move |_, _, _| {
                callback();
                TimeoutAction::ToDuration(REPEAT_RATE)
            })
            .expect("key repeat timer");
        self.repeat_timer = Some(token);
    }

    pub fn cancel_key_repeat(&mut self) {
        if let Some(token) = self.repeat_timer.take() {
            self.event_loop.remove(token);
        }
    }

    fn start(&mut self, canvas: &Canvas, slot: TouchSlot, position: Point<f64, Logical>) {
        if self.slot.take().is_some() {
            self.last_tap = None;
            return;
        }
        self.slot = Some(slot);
        self.cancel_velocity();
        self.start = TouchStart::new(canvas, position);
        self.position = position;
        self.is_drag = false;
    }

    fn has_velocity(&self) -> bool {
        self.velocity.x.abs() >= f64::EPSILON || self.velocity.y.abs() >= f64::EPSILON
    }

    #[inline]
    pub fn touching(&self) -> bool {
        self.slot.is_some()
    }

    pub fn position(&self) -> Option<Point<f64, Logical>> {
        self.slot.map(|_| self.position)
    }

    pub fn cursor_pos(&self) -> Point<f64, Logical> {
        self.position
    }

    fn action(&mut self, canvas: &Canvas) -> Option<TouchAction> {
        if self.start.is_handle_gesture {
            match self.start.handle_direction {
                Some(HandleDirection::Horizontal) => return Some(HandleGesture::Horizontal.into()),
                Some(HandleDirection::Vertical) => {
                    return Some(HandleGesture::Vertical(self.position.y).into())
                },
                None => (),
            }

            if let Some(gesture) =
                HandleGesture::from_points(canvas, self.start.position, self.position)
            {
                self.start.handle_direction = Some(HandleDirection::from(&gesture));
                return Some(gesture.into());
            }
        }

        if !self.touching() && self.input_surface.is_none() {
            let app_id = self.active_app_id.as_ref();
            let mut gestures =
                self.matching_gestures(canvas, app_id, self.start.position, Some(self.position));

            if let Some(gesture) = gestures.next() {
                return Some(TouchAction::UserGesture(gesture.action.clone()));
            }
        }

        let delta = self.start.position - self.position;
        let dist_sq = delta.x.powi(2) + delta.y.powi(2);

        if self.is_drag || dist_sq > TAP_DISTANCE_SQ || self.start.time.elapsed() >= HOLD_DURATION {
            self.is_drag = true;
            return Some(TouchAction::Drag);
        }

        if self.touching() {
            return None;
        }

        if let Some((tap_time, tap_pos)) = self.last_tap.take() {
            let tap_delta = tap_pos - self.position;
            let tap_dist_sq = tap_delta.x.powi(2) + tap_delta.y.powi(2);

            if tap_time.elapsed() <= DOUBLE_TAP_TIMEOUT && tap_dist_sq <= DOUBLE_TAP_DISTANCE_SQ {
                return Some(TouchAction::DoubleTap);
            }
        }

        Some(TouchAction::Tap)
    }

    fn matching_gestures<'a>(
        &'a self,
        canvas: &'a Canvas,
        app_id: Option<&'a String>,
        start: Point<f64, Logical>,
        end: Option<Point<f64, Logical>>,
    ) -> impl Iterator<Item = &'a GestureBinding> {
        let size = canvas.size().to_f64();
        let start_sector = GestureSector::from_point(size, start);
        let end_sector = end.map(|e| GestureSector::from_point(size, e));

        self.user_gestures.iter().filter(move |g| {
            g.start == start_sector
                && end_sector.is_none_or(|s| g.end == s)
                && g.app_id.matches(app_id)
        })
    }
}

#[derive(Debug)]
struct TouchStart {
    handle_direction: Option<HandleDirection>,
    position: Point<f64, Logical>,
    is_handle_gesture: bool,
    is_top_gesture: bool,
    time: Instant,
}

impl Default for TouchStart {
    fn default() -> Self {
        Self {
            time: Instant::now(),
            is_handle_gesture: false,
            is_top_gesture: false,
            handle_direction: None,
            position: Point::default(),
        }
    }
}

impl TouchStart {
    fn new(canvas: &Canvas, position: Point<f64, Logical>) -> Self {
        Self {
            position,
            is_handle_gesture: HandleGesture::is_start(canvas, position),
            is_top_gesture: is_top_area(canvas, position),
            // DEBUG
            time: Instant::now(),
            handle_direction: None,
        }
    }
}

fn is_top_area(_canvas: &Canvas, position: Point<f64, Logical>) -> bool {
    position.y < TOP_GESTURE_HEIGHT
}

#[derive(Debug)]
enum TouchAction {
    HandleGesture(HandleGesture),
    UserGesture(GestureAction),
    Drag,
    Tap,
    DoubleTap,
}

impl From<HandleGesture> for TouchAction {
    fn from(g: HandleGesture) -> Self {
        Self::HandleGesture(g)
    }
}

#[derive(Debug, Copy, Clone)]
pub enum HandleGesture {
    Vertical(f64),
    Horizontal,
}

impl HandleGesture {
    fn from_points(
        canvas: &Canvas,
        start: Point<f64, Logical>,
        end: Point<f64, Logical>,
    ) -> Option<Self> {
        let delta = end - start;
        let len_sq = delta.x.powi(2) + delta.y.powi(2);

        if len_sq < TAP_DISTANCE_SQ {
            return None;
        }

        if delta.y.abs() >= delta.x.abs() {
            Some(Self::Vertical(end.y))
        } else if Self::is_start(canvas, end) {
            Some(Self::Horizontal)
        } else {
            None
        }
    }

    fn is_start(canvas: &Canvas, position: Point<f64, Logical>) -> bool {
        let size = canvas.size().to_f64();
        let handle_rect = Rectangle::new((0.0, size.h - GESTURE_HANDLE_HEIGHT as f64).into(), size);
        handle_rect.contains(position)
    }
}

#[derive(Debug)]
enum HandleDirection {
    Horizontal,
    Vertical,
}

impl From<&HandleGesture> for HandleDirection {
    fn from(g: &HandleGesture) -> Self {
        match g {
            HandleGesture::Horizontal => Self::Horizontal,
            HandleGesture::Vertical(_) => Self::Vertical,
        }
    }
}

#[derive(Copy, Clone, Debug)]
struct TouchEvent {
    position: Point<f64, Logical>,
    ty: TouchEventType,
    slot: TouchSlot,
    time: u32,
    /// When true, this event was synthesized from a pointer click.
    /// Compositor gesture logic runs normally, but no wl_touch events
    /// are forwarded to the client (the client already gets wl_pointer).
    from_pointer: bool,
}

impl TouchEvent {
    fn new(ty: TouchEventType, slot: TouchSlot, time: u32, position: Point<f64, Logical>) -> Self {
        Self { slot, time, position, ty, from_pointer: false }
    }

    fn from_pointer(ty: TouchEventType, slot: TouchSlot, time: u32, position: Point<f64, Logical>) -> Self {
        Self { slot, time, position, ty, from_pointer: true }
    }
}

#[derive(Copy, Clone, Debug)]
enum TouchEventType {
    Down,
    Up,
    Motion,
}

impl Forge {
    pub fn handle_orientation(&mut self, orientation: Orientation) {
        self.windows.update_orientation(orientation);
        self.force_redraw(false);
    }

    pub fn set_xkb_config(&mut self, config: XkbConfig) {
        let Some(keyboard) = self.seat.get_keyboard() else { return };

        let num_lock = keyboard.modifier_state().num_lock;

        if let Err(e) = keyboard.set_xkb_config(self, config) {
            error!("XKB config update failed: {e}");
            return;
        }

        let mut mods = keyboard.modifier_state();
        if mods.num_lock != num_lock {
            mods.num_lock = num_lock;
            keyboard.set_modifier_state(mods);
        }
    }

    pub fn handle_input<I: InputBackend>(&mut self, event: InputEvent<I>) {
        if !self.display_on && self.handle_display_off_input(&event) {
            return;
        }

        self.idle_notifier_state.notify_activity(&self.seat);
        crate::idle_monitor_dbus::touch_activity();

        match event {
            InputEvent::Keyboard { event, .. } => {
                self.on_keyboard_input(event.key_code(), event.state(), event.time_msec());
            },
            InputEvent::PointerMotion { event, .. } => {
                self.draw_cursor = true;
                let delta = (event.delta().x, event.delta().y);
                let pos = self.touch_state.position + Point::from(delta);
                let canvas = self.windows.canvas();
                let size_f = canvas.size().to_f64();
                let size_i = canvas.size();
                let clamped: Point<f64, Logical> =
                    (pos.x.clamp(0.0, size_f.w), pos.y.clamp(0.0, size_f.h)).into();
                self.touch_state.position = clamped;
                if self.touch_state.touching() {
                    let slot = TouchSlot::from(POINTER_SLOT);
                    let ev = TouchEvent::from_pointer(TouchEventType::Motion, slot, event.time_msec(), clamped);
                    self.on_touch_motion(ev);
                } else if self.windows.is_resize_dragging() {
                    self.windows.update_resize_drag(clamped);
                    self.unstall();
                } else if self.windows.is_move_dragging() {
                    self.windows.update_move_drag(clamped);
                    self.unstall();
                }

                // Active corner detection (uses raw deltas so stuck-cursor still progresses).
                if let Some((corner, action)) = self.corner_recognizer.on_motion(
                    clamped, delta.0, delta.1, size_i, std::time::Instant::now(),
                ) {
                    self.trigger_corner_action(corner, action);
                }

                if let Some(event) = self.edge_recognizer.on_motion(clamped, size_i.h) {
                    let msg = match event {
                        crate::edge_action::EdgeEvent::Enter => r#"{"action":"reveal_dock"}"#,
                        crate::edge_action::EdgeEvent::Leave => r#"{"action":"hide_dock"}"#,
                    };
                    Self::send_to_crustace(msg);
                }

                // Suppress forwarding pointer motion to clients while forge
                // owns the pointer session — window move/resize drag or a
                // topbar swipe. Otherwise motion leaks to whatever surface
                // sits under the cursor (usually a crustace layer surface)
                // and its gesture recogniser picks it up as a swipe.
                if !self.pointer_captured() {
                    self.on_pointer_motion(clamped, event.time_msec());
                }
            },
            InputEvent::PointerMotionAbsolute { event } => {
                self.draw_cursor = true;
                let pos = self.transform_position(&event);
                let prev = self.touch_state.position;
                let dx = pos.x - prev.x;
                let dy = pos.y - prev.y;
                self.touch_state.position = pos;
                let size_i = self.windows.canvas().size();
                if self.touch_state.touching() {
                    let slot = TouchSlot::from(POINTER_SLOT);
                    let ev = TouchEvent::from_pointer(TouchEventType::Motion, slot, event.time_msec(), pos);
                    self.on_touch_motion(ev);
                } else if self.windows.is_resize_dragging() {
                    self.windows.update_resize_drag(pos);
                    self.unstall();
                } else if self.windows.is_move_dragging() {
                    self.windows.update_move_drag(pos);
                    self.unstall();
                }

                if let Some((corner, action)) = self.corner_recognizer.on_motion(
                    pos, dx, dy, size_i, std::time::Instant::now(),
                ) {
                    self.trigger_corner_action(corner, action);
                }

                if let Some(event) = self.edge_recognizer.on_motion(pos, size_i.h) {
                    let msg = match event {
                        crate::edge_action::EdgeEvent::Enter => r#"{"action":"reveal_dock"}"#,
                        crate::edge_action::EdgeEvent::Leave => r#"{"action":"hide_dock"}"#,
                    };
                    Self::send_to_crustace(msg);
                }

                if !self.pointer_captured() {
                    self.on_pointer_motion(pos, event.time_msec());
                }
            },
            InputEvent::PointerButton { event } => {
                if self.screenshot.is_active() {
                    if event.button() == Some(MouseButton::Left) {
                        let pos = self.touch_state.position;
                        match event.state() {
                            ButtonState::Pressed => self.screenshot.on_press(pos),
                            ButtonState::Released => self.screenshot.on_release(),
                        }
                    }
                    self.request_redraw();
                    self.unstall();
                    return;
                }
                let serial = SERIAL_COUNTER.next_serial();
                let time = event.time_msec();
                let button = event.button_code();
                let state = event.state();

                let mut forward_to_client = true;

                // A pointer grab (Wayland popup, drag-and-drop, or an explicit
                // client grab) is modal: a *press* under an active grab must
                // go to the grab (via the forward below), not start a forge
                // interaction — this keeps a click from leaking to a window
                // underneath a grabbing popup (e.g. an X11 toolbar behind a
                // GTK menu), and lets a click outside the popup dismiss it.
                //
                // Only the PRESS is blocked. A release is never blocked, so an
                // in-flight move/resize drag or topbar swipe always reaches
                // its end handler below — otherwise it would stay glued to the
                // cursor forever if a grab appeared mid-gesture.
                //
                // Exception: the app switcher is a forge-owned modal overlay,
                // so a stale client pointer grab must not block clicks on it.
                // On winit the bottom swipe-up establishes the default pointer
                // grab on press and swallows the release, leaving the grab
                // live — which otherwise blocks every subsequent tile press.
                let block_routing = state == ButtonState::Pressed
                    && !self.app_switcher.is_active()
                    && self.seat.get_pointer().is_some_and(|p| p.is_grabbed());

                if event.button() == Some(MouseButton::Left) && !block_routing {
                    let pos = self.touch_state.position;

                    let switcher_active = self.app_switcher.is_active();
                    // An in-flight carousel drag (e.g. swipe-up-to-close) must
                    // always end through on_touch_up, even when the release
                    // lands on the topbar or dock band — otherwise the topbar /
                    // dock branch swallows the release and the tile stays glued
                    // to the cursor.
                    let dragging = self.app_switcher.is_carousel_dragging();
                    let on_topbar = switcher_active && !dragging && self.topbar.hit_test(pos).is_some();
                    let on_dock   = switcher_active && !dragging && self.windows.foreground_layer_at(pos);
                    if switcher_active && on_dock {
                        self.app_switcher.cancel();
                        self.windows.set_dirty();
                        self.on_pointer_motion(pos, time);
                    }
                    if switcher_active && on_topbar {
                        if state == ButtonState::Released {
                            if let Some(action) = self.topbar.hit_test(pos) {
                                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);
                                self.request_redraw();
                            }
                        }
                        forward_to_client = false;
                    } else if switcher_active && !on_dock {
                        // On mobile the carousel needs a real touch
                        // press→motion→release sequence so drag/inertia
                        // can run; synthesise the natural pair. On
                        // desktop the click commits on release with
                        // standard pointer semantics.
                        let mobile = self.windows.canvas().is_mobile();
                        let slot = TouchSlot::from(POINTER_SLOT);
                        match (mobile, state) {
                            (true, ButtonState::Pressed) => {
                                let ev = TouchEvent::from_pointer(TouchEventType::Down, slot, 0, pos);
                                self.on_touch_down(ev);
                            }
                            (true, ButtonState::Released) => {
                                let ev = TouchEvent::from_pointer(TouchEventType::Up, slot, 0, pos);
                                self.on_touch_up(ev);
                            }
                            (false, ButtonState::Released) => {
                                let ev = TouchEvent::from_pointer(TouchEventType::Down, slot, 0, pos);
                                self.on_touch_down(ev);
                            }
                            (false, ButtonState::Pressed) => {}
                        }
                        forward_to_client = false;
                    } else {

                    // An SSD title bar lives ABOVE its window's bounds, so
                    // `surface_at` falls through to whatever client body
                    // sits under that strip (typically a window one layer
                    // below in the stack). Pre-empt that: when the point
                    // lands on any visible SSD, treat the click as a
                    // non-client hit so `on_touch_down` can route it to
                    // `apply_ssd_button` / start a title-bar drag for the
                    // correct (topmost) window, and suppress the pointer
                    // button forwarding so no stale client receives it.
                    let desktop = !self.windows.canvas().is_mobile();
                    let on_ssd = desktop
                        && (self.windows.ssd_window_hit_test(pos).is_some()
                            || self.windows.ssd_x11_hit_test(pos).is_some());
                    let on_resize_band = desktop
                        && self.windows.resize_cursor_edge(pos).is_some();

                    let hit = if on_ssd || on_resize_band {
                        None
                    } else {
                        self.windows.surface_at(pos)
                    };
                    let is_toplevel_surface = hit
                        .as_ref()
                        .is_some_and(|s| matches!(s.toplevel, Some(InputSurfaceKind::Layout(_))));

                    // When the click lands directly on a toplevel's content
                    // surface (not on SSD / layer), smithay's pointer path
                    // forwards the click but does not raise the window or
                    // move keyboard focus to it. Do that here on press so
                    // clicking a background window both raises and focuses
                    // it, matching desktop conventions.
                    if is_toplevel_surface && state == ButtonState::Pressed {
                        if let Some(mut s) = hit {
                            if let Some(InputSurfaceKind::Layout((weak, _))) =
                                s.toplevel.take()
                            {
                                self.windows.set_focus(Some(FocusTarget::Layout(weak)));
                            }
                        }
                    }

                    let releasing_armed_ssd =
                        state == ButtonState::Released && self.armed_ssd.is_some();
                    let synthesize_touch = !is_toplevel_surface || releasing_armed_ssd;
                    if releasing_armed_ssd {
                        forward_to_client = false;
                    }

                    if synthesize_touch {
                        // Mirror of the CSD-drag fix in the `else if` branch
                        // below: when this synthesised touch-up is about to
                        // end an in-flight drag (release landed on an SSD
                        // strip or a layer surface like the dock), forward
                        // the matching button-up to the surface that started
                        // the drag BEFORE on_touch_up refreshes pointer
                        // focus. Skipping leaves the drag-originating client
                        // (Firefox / GTK4) stuck "button held" and the next
                        // pointer motion looks like a continuation of the
                        // previous drag.
                        if state == ButtonState::Released
                            && (self.windows.is_move_dragging()
                                || self.windows.is_resize_dragging())
                        {
                            let pointer = self.seat.get_pointer().unwrap();
                            pointer.button(
                                self,
                                &ButtonEvent { serial, time, button, state: state.into() },
                            );
                            pointer.frame(self);
                            forward_to_client = false;
                        }
                        let slot = TouchSlot::from(POINTER_SLOT);
                        let ty = if state == ButtonState::Pressed {
                            TouchEventType::Down
                        } else {
                            TouchEventType::Up
                        };
                        let ev = TouchEvent::from_pointer(ty, slot, 0, pos);
                        match ty {
                            TouchEventType::Down => self.on_touch_down(ev),
                            TouchEventType::Up => self.on_touch_up(ev),
                            TouchEventType::Motion => {}
                        }
                    } else if state == ButtonState::Released {
                        // CSD-initiated drags bypass the synthesised touch-up.
                        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.request_redraw();
                            // Order matters: smithay's pointer is still
                            // focused on whatever surface received the press
                            // that initiated the drag (typically the CSD
                            // client's title bar, since SSD-initiated drags
                            // go through `on_touch_up` instead). Send the
                            // matching button-up to THAT focus first so the
                            // client gets a balanced press/release pair —
                            // without it Firefox / GTK4 stay stuck "button
                            // held" and the next pointer motion is
                            // interpreted as a continuation of the drag,
                            // which surfaces as "the previous window keeps
                            // moving when you grab a different one"
                            // (eydos issue #8).
                            let pointer = self.seat.get_pointer().unwrap();
                            pointer.button(
                                self,
                                &ButtonEvent { serial, time, button, state: state.into() },
                            );
                            pointer.frame(self);
                            forward_to_client = false; // already delivered
                            // Now replay a motion at the current cursor
                            // position so smithay flips pointer focus from
                            // the dragged surface to whatever is under the
                            // cursor — that unblocks the dock hover and
                            // routes the next click to the intended window.
                            self.on_pointer_motion(pos, time);
                        }
                    }

                    // SSD clicks are fully handled compositor-side — don't
                    // leak the button press to whatever client surface
                    // smithay's pointer was last focused on.
                    if on_ssd {
                        forward_to_client = false;
                    }
                    }
                }

                // Right-click on a window's SSD title bar opens the
                // window context menu. Forge owns the SSD geometry so
                // it does the hit-test; rendering the menu surface is
                // crustace's job (it already owns every other shell
                // overlay), so we ship a JSON IPC with the window id +
                // the cursor position and let crustace anchor its
                // menu there. The button press is swallowed (forge
                // doesn't fall through to the client) so the right-
                // click never confuses the application below the bar.
                if event.button() == Some(MouseButton::Right)
                    && state == ButtonState::Pressed
                    && !block_routing
                    && !self.windows.canvas().is_mobile()
                {
                    let pos = self.touch_state.position;
                    if let Some((window_rc, action)) = self.windows.ssd_window_hit_test(pos) {
                        if matches!(action, crate::ssd::SsdAction::Move) {
                            let surface_id = {
                                use smithay::reexports::wayland_server::Resource as _;
                                use crate::windows::surface::Surface as _;
                                window_rc.borrow().surface.surface().id().protocol_id()
                            };
                            let msg = format!(
                                r#"{{"action":"window_context_menu","window_id":{},"x":{:.1},"y":{:.1}}}"#,
                                surface_id, pos.x, pos.y,
                            );
                            Self::send_to_crustace(&msg);
                            forward_to_client = false;
                        }
                    }
                }

                if forward_to_client {
                    let pointer = self.seat.get_pointer().unwrap();
                    pointer.button(
                        self,
                        &ButtonEvent {
                            serial,
                            time,
                            button,
                            state: state.into(),
                        },
                    );
                    pointer.frame(self);
                }
            },
            InputEvent::PointerAxis { event, .. } => {
                let source = event.source();
                let horizontal_amount = event
                    .amount(smithay::backend::input::Axis::Horizontal)
                    .unwrap_or_else(|| event.amount_v120(smithay::backend::input::Axis::Horizontal).unwrap_or(0.0) * 3.0 / 120.0);
                let vertical_amount = event
                    .amount(smithay::backend::input::Axis::Vertical)
                    .unwrap_or_else(|| event.amount_v120(smithay::backend::input::Axis::Vertical).unwrap_or(0.0) * 3.0 / 120.0);

                {
                    let mut frame = AxisFrame::new(event.time_msec()).source(source);

                    if horizontal_amount != 0.0 {
                        frame = frame.value(smithay::backend::input::Axis::Horizontal, horizontal_amount);
                        if let Some(v120) = event.amount_v120(smithay::backend::input::Axis::Horizontal) {
                            frame = frame.v120(smithay::backend::input::Axis::Horizontal, v120 as i32);
                        }
                    }
                    if vertical_amount != 0.0 {
                        frame = frame.value(smithay::backend::input::Axis::Vertical, vertical_amount);
                        if let Some(v120) = event.amount_v120(smithay::backend::input::Axis::Vertical) {
                            frame = frame.v120(smithay::backend::input::Axis::Vertical, v120 as i32);
                        }
                    }

                    let pointer = self.seat.get_pointer().unwrap();
                    pointer.axis(self, frame);
                    pointer.frame(self);
                }
            },
            InputEvent::TouchDown { event } => {
                let pos = self.transform_position(&event);
                self.touch_state.events.push(TouchEvent::new(
                    TouchEventType::Down,
                    event.slot(),
                    event.time_msec(),
                    pos,
                ));
            },
            InputEvent::TouchUp { event } => {
                self.touch_state.events.push(TouchEvent::new(
                    TouchEventType::Up,
                    event.slot(),
                    event.time_msec(),
                    self.touch_state.position,
                ));
            },
            InputEvent::TouchMotion { event } => {
                let pos = self.transform_position(&event);
                self.touch_state.events.push(TouchEvent::new(
                    TouchEventType::Motion,
                    event.slot(),
                    event.time_msec(),
                    pos,
                ));
            },
            InputEvent::TouchFrame { .. } => {
                for ev in self.touch_state.events.drain(..).collect::<Vec<_>>() {
                    match ev.ty {
                        TouchEventType::Down => self.on_touch_down(ev),
                        TouchEventType::Up => self.on_touch_up(ev),
                        TouchEventType::Motion => self.on_touch_motion(ev),
                    }
                }
                self.seat.get_touch().unwrap().frame(self);
            },
            InputEvent::TouchCancel { event } => {
                self.touch_state.events.retain(|e| e.slot != event.slot());
                if self.armed_ssd.as_ref().is_some_and(|a| a.slot == event.slot()) {
                    self.armed_ssd = None;
                    self.windows.set_dirty();
                }
            },
            InputEvent::DeviceAdded { .. } | InputEvent::DeviceRemoved { .. } => {
                self.refresh_keyboard_presence();
            },
            _ => (),
        }

        self.unstall();
    }

    fn handle_display_off_input<I: InputBackend>(&mut self, event: &InputEvent<I>) -> bool {
        match event {
            InputEvent::Keyboard { .. } => {
                self.lock_session();
                self.set_display_status(true);
            },
            InputEvent::TouchDown { event } => {
                let pos = self.transform_position(event);
                self.touch_state.events.push(TouchEvent::new(
                    TouchEventType::Down,
                    event.slot(),
                    event.time_msec(),
                    pos,
                ));
            },
            InputEvent::TouchUp { event } => {
                self.touch_state.events.push(TouchEvent::new(
                    TouchEventType::Up,
                    event.slot(),
                    event.time_msec(),
                    self.touch_state.position,
                ));
            },
            InputEvent::TouchFrame { .. } => {
                for ev in self.touch_state.events.drain(..).collect::<Vec<_>>() {
                    match ev.ty {
                        TouchEventType::Down => {
                            self.touch_state.start(self.windows.canvas(), ev.slot, ev.position);
                        },
                        TouchEventType::Up if self.touch_state.slot.take() == Some(ev.slot) => {
                            match self.touch_state.action(self.windows.canvas()) {
                                Some(TouchAction::Tap) => {
                                    self.touch_state.last_tap = Some((Instant::now(), ev.position));
                                },
                                Some(TouchAction::DoubleTap) => {
                                    self.lock_session();
                                    self.set_display_status(true);
                                },
                                _ => (),
                            }
                        },
                        _ => (),
                    }
                }
                self.seat.get_touch().unwrap().frame(self);
            },
            InputEvent::PointerMotion { .. }
            | InputEvent::PointerMotionAbsolute { .. }
            | InputEvent::PointerButton { .. }
            | InputEvent::PointerAxis { .. } => {
                self.lock_session();
                self.set_display_status(true);
            },
            _ => (),
        }

        true
    }

    /// True while forge is mid-gesture on the pointer stream and must
    /// not forward motion to Wayland clients: window move-drag, edge
    /// resize-drag, or an active topbar swipe-down. Armed-but-inactive
    /// topbar-swipe state is intentionally not captured here — the
    /// press hasn't escalated past the threshold so letting a micro-tap
    /// on the topbar fall through to client pointer events is correct.
    fn pointer_captured(&self) -> bool {
        self.windows.is_move_dragging()
            || self.windows.is_resize_dragging()
            || self.topbar_swipe.as_ref().is_some_and(|s| s.active)
            || self.bottom_swipe.as_ref().is_some_and(|s| s.active)
    }

    fn on_pointer_motion(&mut self, pos: Point<f64, Logical>, time: u32) {
        if self.screenshot.is_active() {
            self.screenshot.on_motion(pos);
            self.request_redraw();
            return;
        }
        let pointer = self.seat.get_pointer().unwrap();
        let serial = SERIAL_COUNTER.next_serial();

        // While the app switcher is up the panel covers everything, so a
        // pointer motion must NOT leak to whatever client window happens
        // to sit under the cursor in screen coordinates — otherwise that
        // client repaints its hover state and the live thumbnail shows
        // the change inside the carousel tile. Foreground layer surfaces
        // (the dock, the topbar) never appear in a thumbnail, so they are
        // exempt: they stay reachable so their hover and tooltips keep
        // working with the switcher open.
        let surface = if self.app_switcher.is_active() {
            self.windows.foreground_layer_surface_at(pos)
        } else {
            self.windows.surface_at(pos)
        };
        let mut under = surface.map(|s| (s.surface, (s.surface_offset.x as f64, s.surface_offset.y as f64).into()));

        // An SSD title bar / resize band lives above or outside its window's
        // bounds, so `surface_at` falls through to whatever client sits under
        // that strip (a lower window). Drop the hit so the pointer never enters
        // that client there — otherwise it receives stray hover and its cursor
        // shows over our own decoration. The arrow (or, at render time, the
        // resize shape) is shown instead. Mirrors the press path's `on_ssd`.
        if !self.app_switcher.is_active()
            && (self.windows.ssd_window_hit_test(pos).is_some()
                || self.windows.ssd_x11_hit_test(pos).is_some()
                || self.windows.resize_cursor_edge(pos).is_some())
        {
            under = None;
        }

        // Over the compositor background no client owns the pointer, so it
        // never gets the chance to set a shape; fall back to the arrow
        // rather than keep a stale shape (e.g. the I-beam from a text field).
        if under.is_none() {
            self.cursor_status = smithay::input::pointer::CursorImageStatus::default_named();
        }

        pointer.motion(
            self,
            under,
            &PointerMotionData {
                location: pos,
                serial,
                time,
            },
        );
        pointer.frame(self);
        // Feed the pointer position into the SSD hover machinery so
        // the close button can swap to its red surface, etc. This
        // also conditionally marks the compositor dirty on hover
        // transitions, so plain pointer travel across the desktop
        // does not thrash the render loop.
        self.windows.pointer_moved(pos);

        if self.app_switcher.is_active() && !self.windows.canvas().is_mobile() {
            if self.app_switcher.hover_select(pos) {
                self.windows.set_dirty();
            }
        }
        self.unstall();
    }

    fn emit_gesture( event: &forge_ipc::gesture::GestureEvent )
    {
        if let Ok( json ) = serde_json::to_string( event )
        {
            Self::send_to_crustace( &json );
        }
    }

    fn handle_gesture_action( &mut self, ev: &forge_ipc::gesture::GestureEvent )
    {
        use forge_ipc::gesture::*;

        if ev.phase != GesturePhase::End { return; }

        match ( ev.gesture_type, ev.edge, ev.direction )
        {
            // Edge back: left edge swipe right → send Back key to focused app
            ( GestureType::EdgePull | GestureType::Swipe, Edge::Left, Direction::Right ) =>
            {
                self.send_virtual_key(
                    smithay::input::keyboard::keysyms::KEY_XF86Back,
                    forge_ipc::Modifiers::default(),
                );
                info!( "Gesture: edge back" );
            }

            // Bottom edge swipe up is owned by the progressive `BottomSwipe`
            // state machine (launcher / go-home / app switcher), which
            // consumes the motion before the recogniser sees it.

            // Top edge swipe down → quick settings (crustace handles the UI)
            ( GestureType::EdgePull | GestureType::Swipe, Edge::Top, Direction::Down ) =>
            {
                info!( "Gesture: top edge pull (crustace handles)" );
            }

            // Right edge swipe left → forward navigation (if applicable)
            ( GestureType::EdgePull | GestureType::Swipe, Edge::Right, Direction::Left ) =>
            {
                info!( "Gesture: right edge swipe (reserved)" );
            }

            _ => {}
        }
    }

    fn on_touch_down(&mut self, event: TouchEvent) {
        if self.screenshot.is_active() {
            self.screenshot.on_press(event.position);
            self.request_redraw();
            return;
        }
        self.draw_cursor = false;
        let canvas = self.windows.canvas();
        self.gesture_recognizer.set_screen_size( canvas.size() );
        if let Some( ev ) = self.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.topbar_swipe = None;
        self.bottom_swipe = None;

        // Track potential topbar swipe-down: arm the state here; it
        // becomes active on vertical motion past SWIPE_START_THRESHOLD.
        // Lockscreen/powermenu 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.power_menu.visible
            && !self.polkit.visible
            && !self.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
        {
            self.topbar_swipe = Some(crate::forge::TopbarSwipe {
                start_y: event.position.y,
                active: false,
                last_sent_progress: -1.0,
            });
        }

        // 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.power_menu.visible
            && !self.polkit.visible
            && !self.app_switcher.is_active()
            && !self.windows.is_locked()
            && !excl_fg
            && self.topbar_swipe.is_none()
            && in_zone;
        if can_arm {
            self.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.app_switcher.is_active() && !self.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.app_switcher.carousel_drag_begin(event.position);
                self.touch_state.start(canvas, event.slot, event.position);
                self.request_redraw();
                return;
            }
            use crate::app_switcher::AppSwitcherClick;
            match self.app_switcher.click_at(event.position) {
                AppSwitcherClick::Activate(idx) => {
                    if let Some(oid) = self.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.app_switcher.object_id_at(idx) {
                        self.windows.close_window_by_object_id(&oid);
                        self.app_switcher.remove_at(idx);
                    } else if let Some(id) = self.app_switcher.surface_id_at(idx) {
                        self.windows.close_window_by_id(id as u64);
                        self.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.touch_state.pending_single_tap.take() {
                self.event_loop.remove(token);
            }
            self.touch_state.input_surface = None;
            self.touch_state.active_app_id = None;
            self.touch_state.tap_surface   = None;
            self.request_redraw();
            return;
        }

        self.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.
        if !self.windows.canvas().is_mobile() {
            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.armed_ssd = Some(armed);
                self.touch_state.input_surface = None;
                self.touch_state.active_app_id = None;
                self.touch_state.tap_surface = None;
                self.touch_state.slot = None;
                self.touch_state.last_tap = None;
                if let Some(token) = self.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.touch_state.input_surface = None;
                self.touch_state.active_app_id = None;
                self.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.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.end_move_drag();
                    self.windows.toggle_maximize_focused();
                } else {
                    self.ssd_last_press = Some((now, event.position));
                }

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

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

        self.touch_state.input_surface = None;
        self.touch_state.active_app_id = None;
        self.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.touch_state
                    .matching_gestures(self.windows.canvas(), app_id.as_ref(), event.position, None)
                    .next()
                    .is_some()
            };

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

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

    fn on_touch_up(&mut self, event: TouchEvent) {
        if self.screenshot.is_active() {
            self.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.armed_ssd = None;
            self.touch_state.slot = None;
            self.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.touch_state.position, event.time);
            }
            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.app_switcher.is_active()
            && !self.app_switcher.is_swiping()
            && self.windows.canvas().is_mobile()
        {
            if let Some(click) = self.app_switcher.carousel_drag_end() {
                use crate::app_switcher::AppSwitcherClick;
                match click {
                    AppSwitcherClick::Activate(idx) => {
                        if let Some(oid) = self.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.app_switcher.object_id_at(idx) {
                            self.windows.close_window_by_object_id(&oid);
                            self.app_switcher.remove_at(idx);
                        } else if let Some(id) = self.app_switcher.surface_id_at(idx) {
                            self.windows.close_window_by_id(id as u64);
                            self.app_switcher.remove_at(idx);
                        }
                    }
                    AppSwitcherClick::Dismiss => {
                        self.dismiss_app_switcher();
                    }
                    AppSwitcherClick::NoOp => {}
                }
            }
            self.touch_state.slot = None;
            self.touch_state.is_drag = false;
            self.request_redraw();
            return;
        }

        if self.armed_ssd.as_ref().is_some_and(|a| a.slot == event.slot) {
            let armed = self.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.touch_state.slot = None;
            self.touch_state.is_drag = false;
            self.request_redraw();
            return;
        }

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

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

        let _canvas = self.windows.canvas();

        if self.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.touch_state.slot != Some(event.slot) {
            return;
        }
        self.touch_state.slot = None;

        match self.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.touch_state.pending_single_tap = Some(token);
                self.touch_state.last_tap = Some((Instant::now(), self.touch_state.position));
            },
            Some(TouchAction::DoubleTap) => {
                if let Some(token) = self.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();
                self.update_position(self.touch_state.position);
            },
            None => self.add_velocity_timeout(),
        }
    }

    fn on_touch_motion(&mut self, event: TouchEvent) {
        if self.screenshot.is_active() {
            self.screenshot.on_motion(event.position);
            self.request_redraw();
            return;
        }
        self.touch_state.velocity = event.position - self.touch_state.position;
        self.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.app_switcher.is_active()
            && !self.app_switcher.is_swiping()
            && self.windows.canvas().is_mobile()
        {
            self.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.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
            .touch_state
            .input_surface
            .as_ref()
            .is_some_and(|s| !smithay::utils::IsAlive::alive(&s.surface));
        if grab_dead {
            self.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.touch_state.input_surface = Some(new_surface);
                }
            }
        }

        if let Some(ref input_surface) = self.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.touch_state.slot == Some(event.slot) {
            self.update_position(event.position);
        }
    }

    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>) {
        match self.touch_state.action(self.windows.canvas()) {
            Some(TouchAction::Drag) => (),
            Some(TouchAction::HandleGesture(g)) => self.on_handle_gesture(g),
            Some(TouchAction::UserGesture(a)) => self.on_user_gesture(a),
            _ => (),
        }

        self.touch_state.position = position;
    }

    fn on_handle_gesture(&mut self, gesture: HandleGesture) {
        let open_launcher = if self.touch_state.touching() || self.touch_state.has_velocity() {
            self.windows.on_gesture(&mut self.touch_state, gesture)
        } else {
            let result = self.windows.on_gesture_done(gesture);
            self.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.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.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.touch_state.tap_surface = Some(input_surface);
    }

    fn on_single_tap(_: Instant, _: &mut (), forge: &mut Self) -> TimeoutAction {
        // Handle power menu — modal, takes priority over everything
        if forge.power_menu.visible {
            use crate::powermenu::PowerAction;
            let canvas = forge.windows.canvas();
            let point = forge.touch_state.position;
            if let Some(action) = forge.power_menu.handle_tap(canvas, point) {
                forge.power_menu.dismiss();
                match action {
                    PowerAction::Lock => {
                        forge.lock_session();
                    }
                    PowerAction::Logout => {
                        forge.terminated = true;
                    }
                    PowerAction::Cancel => {}
                    action => {
                        crate::powermenu::PowerMenu::execute(action);
                    }
                }
                forge.windows.set_dirty();
            }
            forge.unstall();
            return TimeoutAction::Drop;
        }

        // Handle polkit authentication dialog — modal, takes priority
        if forge.polkit.visible {
            use crate::polkit::PolkitAction;
            let canvas = forge.windows.canvas();
            let point = forge.touch_state.position;
            if let Some(action) = forge.polkit.handle_tap(canvas, point) {
                match action {
                    PolkitAction::Authenticate => { forge.polkit.try_authenticate(); },
                    PolkitAction::Cancel => forge.polkit.cancel(),
                    PolkitAction::None => {},
                }
                forge.windows.set_dirty();
            }
            forge.unstall();
            return TimeoutAction::Drop;
        }

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

        // Alt+Tab switcher takes precedence over everything when visible.
        if forge.app_switcher.is_active() {
            use crate::app_switcher::AppSwitcherClick;
            match forge.app_switcher.click_at(forge.touch_state.position) {
                AppSwitcherClick::Activate(idx) => {
                    if let Some(oid) = forge.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.app_switcher.object_id_at(idx) {
                        forge.windows.close_window_by_object_id(&oid);
                        forge.app_switcher.remove_at(idx);
                    } else if let Some(id) = forge.app_switcher.surface_id_at(idx) {
                        forge.windows.close_window_by_id(id as u64);
                        forge.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.
        if let Some(action) = forge.topbar.hit_test(forge.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.touch_state.input_surface.is_none() {
            forge.windows.set_focus(None);
        }

        forge.unstall();
        TimeoutAction::Drop
    }

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

        self.windows.on_double_tap(self.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.touch_state.velocity;
        let position = &mut self.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.touch_state.position);
        self.unstall();

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

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

        if let Some(token) = self.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.touch_state.velocity_timer = Some(token);
    }

    fn on_keyboard_input(&mut self, code: Keycode, state: KeyState, time: u32) {
        let Some(keyboard) = self.seat.get_keyboard() else { return };
        let serial = SERIAL_COUNTER.next_serial();

        let action = keyboard.input(self, code, state, serial, time, |forge, mods, keysym| {
            Self::keyboard_action(forge, mods, keysym, state)
        });

        if let Some(InputAction::ChangeVt(vt)) = action {
            self.backend.change_vt(vt);
        }
    }

    fn send_virtual_key(&mut self, keysym: u32, mods: Modifiers) {
        let Some(keyboard) = self.seat.get_keyboard() else { return };

        let keycodes = self.keysym_to_keycode(keysym);
        let Some(keycode) = keycodes.first() else { return };

        let old_mods = keyboard.with_pressed_keysyms(|keysyms| {
            keysyms
                .iter()
                .filter(|k| {
                    let sym = k.modified_sym().raw();
                    (!mods.shift && (sym | 1 == keysyms::KEY_Shift_L))
                        || (!mods.alt && (sym | 1 == keysyms::KEY_Alt_L))
                        || (!mods.logo && (sym | 1 == keysyms::KEY_Super_L))
                        || (!mods.control && (sym | 1 == keysyms::KEY_Control_L))
                })
                .map(|k| k.raw_code().raw())
                .collect::<Vec<_>>()
        });

        let mut new_mods = Vec::new();
        let current = keyboard.modifier_state();
        if mods.shift && !current.shift {
            new_mods.push(50);
        }
        if mods.control && !current.ctrl {
            new_mods.push(37);
        }
        if mods.alt && !current.alt {
            new_mods.push(64);
        }
        if mods.logo && !current.logo {
            new_mods.push(133);
        }

        for &m in &old_mods {
            self.on_keyboard_input(Keycode::new(m), KeyState::Released, 0);
        }
        for &m in &new_mods {
            self.on_keyboard_input(Keycode::new(m), KeyState::Pressed, 0);
        }

        let raw = keycode.raw();
        self.on_keyboard_input(Keycode::new(raw), KeyState::Pressed, 0);
        self.on_keyboard_input(Keycode::new(raw), KeyState::Released, 0);

        for &m in &new_mods {
            self.on_keyboard_input(Keycode::new(m), KeyState::Released, 0);
        }
        for &m in &old_mods {
            self.on_keyboard_input(Keycode::new(m), KeyState::Pressed, 0);
        }
    }

    /// Dispatch an active-corner action.
    fn trigger_corner_action(
        &mut self,
        corner: crate::corner_action::Corner,
        action: crate::corner_action::CornerAction,
    ) {
        use crate::corner_action::CornerAction::*;
        // A locked session swallows the active corners: the launcher,
        // homescreen and app switcher must not surface over the lock screen.
        if self.windows.is_locked() {
            return;
        }
        tracing::info!("Active corner triggered: {:?} → {:?}", corner, action);
        match action {
            Overview      => Self::send_to_crustace(r#"{"action":"toggle_overview"}"#),
            ShowDesktop   => Self::send_to_crustace(r#"{"action":"show_homescreen"}"#),
            Lock          => {
                // Close crustace overlays first so they don't sit on top of
                // the lock screen, then lock the compositor.
                Self::send_to_crustace(r#"{"action":"show_homescreen"}"#);
                self.lock_session();
                self.request_redraw();
            }
            AppSwitcher   => {
                // In-process: open the Alt+Tab overlay without advancing the
                // selection (step=0). The gesture is a "show me what's open"
                // signal — the user picks the target with the pointer or
                // with Tab afterwards; advancing on the gesture itself would
                // mean a flick of the cursor reorders the focus chain.
                //
                // If the switcher is already visible (re-trigger before the
                // user picked anything), refresh the entries snapshot so the
                // panel reflects any windows opened/closed in the interim —
                // a no-op would leave it stale and make repeat triggers
                // look like "sometimes works, sometimes doesn't" from the
                // user's perspective.
                let entries = self.windows.app_switcher_entries();
                tracing::info!(
                    "corner→AppSwitcher: {} entries, app_switcher.visible={} (will {})",
                    entries.len(),
                    self.app_switcher.is_active(),
                    if self.app_switcher.is_active() { "refresh" } else { "open" },
                );
                let focused = self.windows.focused_object_id();
                if !entries.is_empty() {
                    self.app_switcher.begin(entries, 0, focused);
                    self.hide_osk();
                    self.request_redraw();
                }
            }
            Custom(_id)   => (),
        }
    }

    /// Try launching a program from a list of candidates (first success wins).
    /// Send a JSON message to crustace's IPC socket.
    /// Feed a motion event to the topbar-swipe state machine and emit
    /// any IPC messages it produces. Returns true if the swipe consumed
    /// the event (caller should skip normal input handling).
    fn update_topbar_swipe(&mut self, pos: Point<f64, Logical>) -> bool {
        use crate::forge::TopbarSwipeMsg;

        let Some(swipe) = self.topbar_swipe.as_mut() else { return false };
        let last = swipe.last_sent_progress;
        let (consumed, start, wiping) = swipe.on_motion(pos.y);
        let new_last = swipe.last_sent_progress;

        if matches!(start, Some(TopbarSwipeMsg::Start)) {
            let dy = pos.y - swipe.start_y;
            tracing::info!("Topbar swipe: activated (dy={:.1})", dy);
            Self::send_to_crustace(r#"{"action":"start_wipe_down"}"#);
        }

        if let Some(TopbarSwipeMsg::Wiping(progress)) = wiping {
            let msg = format!(r#"{{"action":"wiping","progress":{:.3}}}"#, progress);
            Self::send_to_crustace(&msg);
            let step_now  = (new_last * 10.0).floor() as i32;
            let step_last = (last.max(0.0) * 10.0).floor() as i32;
            if step_now != step_last {
                tracing::info!("Topbar swipe: wiping progress={:.2}", progress);
            }
        }

        consumed
    }

    /// End a topbar swipe, emitting the wipe_done IPC if the swipe had
    /// become active. Returns true if the caller should skip the normal
    /// up-path.
    fn end_topbar_swipe(&mut self, pos: Point<f64, Logical>) -> bool {
        use crate::forge::TopbarSwipeMsg;

        let Some(swipe) = self.topbar_swipe.take() else { return false };
        let Some(done) = swipe.on_release(pos.y) else { return false };
        if let TopbarSwipeMsg::Done { progress, committed } = done {
            tracing::info!("Topbar swipe: done (progress={:.2}, committed={})", progress, committed);
            let msg = format!(
                r#"{{"action":"wipe_done","committed":{},"progress":{:.3}}}"#,
                committed, progress,
            );
            Self::send_to_crustace(&msg);
        }
        true
    }

    /// Escalate a pending bottom press into an active swipe-up, then drive
    /// either the launcher reveal (crustace) or the app switcher's
    /// finger-following entry from the swipe progress. Returns true once the
    /// gesture owns the event so normal input handling is skipped.
    fn update_bottom_swipe(&mut self, pos: Point<f64, Logical>) -> bool {
        use crate::forge::SwipeContext;

        let (consumed, activated, wiping) = {
            let Some(swipe) = self.bottom_swipe.as_mut() else { return false };
            swipe.on_motion(pos.x, pos.y)
        };
        if !consumed {
            return false;
        }

        if activated {
            // The press may already have reached a client surface; cancel it
            // so the app sees no dangling touch while forge drives the slide.
            self.touch_state.input_surface = None;
            if let Some(touch) = self.seat.get_touch() {
                touch.cancel(self);
            }

            // A crustace panel (Quick Settings, calendar, picker…) is open:
            // this swipe-up dismisses it rather than revealing the launcher
            // or shrinking an app. Resolve inertly so the release is a no-op.
            if self.windows.has_dismissable_panel() {
                Self::send_to_crustace(r#"{"action":"show_homescreen"}"#);
                if let Some(s) = self.bottom_swipe.as_mut() { s.resolved = true; }
                return true;
            }

            // Launcher only on the homescreen and only when the swipe starts
            // at the dock's top edge or higher; the very bottom (and an empty
            // homescreen with nothing to switch to) forms the switcher.
            let home = self.windows.homescreen_active();
            let canvas_h = self.windows.canvas().size().h as f64;
            let dock_h = self.app_switcher.dock_height() as f64;
            let start_y = self.bottom_swipe.as_ref().map_or(pos.y, |s| s.start_y);
            let switcher_zone = if dock_h > 0.0 {
                start_y > canvas_h - dock_h
            } else {
                start_y >= canvas_h - BOTTOM_ARM_BAND
            };
            let ctx = if home && (!switcher_zone || !self.windows.has_windows()) {
                SwipeContext::Launcher
            } else {
                SwipeContext::Switcher
            };
            let over_app = !home;
            if let Some(s) = self.bottom_swipe.as_mut() {
                s.context = Some(ctx);
                s.over_app = over_app;
            }
            match ctx {
                SwipeContext::Launcher => {
                    Self::send_to_crustace(r#"{"action":"start_launcher_wipe"}"#)
                },
                SwipeContext::Switcher => {
                    let entries = self.windows.app_switcher_entries();
                    let focused = self.windows.focused_object_id();
                    self.app_switcher.begin_swipe(entries, focused);
                },
            }
        }

        if self.bottom_swipe.as_ref().is_some_and(|s| s.resolved) {
            return true;
        }

        match self.bottom_swipe.as_ref().and_then(|s| s.context) {
            Some(SwipeContext::Launcher) => {
                if let Some(progress) = wiping {
                    let msg = format!(
                        r#"{{"action":"launcher_wiping","progress":{:.3}}}"#,
                        progress,
                    );
                    Self::send_to_crustace(&msg);
                }
            },
            Some(SwipeContext::Switcher) => {
                let progress = self.bottom_swipe.as_ref().map_or(0.0, |s| s.progress(pos.y));
                self.app_switcher.set_swipe_progress(progress);
                self.request_redraw();
            },
            None => {}
        }

        self.arm_dwell();
        true
    }

    /// (Re)arm or cancel the dwell-to-switcher timer. The countdown is only
    /// restarted when the finger drifts past [`DWELL_TOLERANCE`] from where it
    /// began, so it need not be perfectly still — only roughly settled past
    /// [`BottomSwipe::DWELL_ARM`] — to fire and commit.
    fn arm_dwell(&mut self) {
        let Some(swipe) = self.bottom_swipe.as_ref() else { return };
        if swipe.resolved { return; }
        let progress = swipe.last_sent_progress;

        if progress < crate::forge::BottomSwipe::DWELL_ARM {
            if let Some(token) = self.dwell_timer.take() {
                self.event_loop.remove(token);
            }
            self.dwell_anchor = None;
            return;
        }

        let drifted = self
            .dwell_anchor
            .map_or(true, |a| (progress - a).abs() > DWELL_TOLERANCE);
        if self.dwell_timer.is_none() || drifted {
            if let Some(token) = self.dwell_timer.take() {
                self.event_loop.remove(token);
            }
            self.dwell_anchor = Some(progress);
            let timer = Timer::from_duration(std::time::Duration::from_millis(DWELL_MS));
            let token = self
                .event_loop
                .insert_source(timer, |_, _, forge| {
                    forge.on_dwell_fired();
                    TimeoutAction::Drop
                })
                .expect("dwell timer");
            self.dwell_timer = Some(token);
        }
    }

    /// Dwell elapsed with the finger still down past the arm fraction:
    /// commit the app switcher. Only the switcher context dwells — a
    /// launcher swipe settles on release.
    fn on_dwell_fired(&mut self) {
        self.dwell_timer = None;
        self.dwell_anchor = None;
        let Some(swipe) = self.bottom_swipe.as_mut() else { return };
        if !swipe.active || swipe.resolved { return; }
        if swipe.last_sent_progress < crate::forge::BottomSwipe::DWELL_ARM { return; }
        if !matches!(swipe.context, Some(crate::forge::SwipeContext::Switcher)) { return; }
        swipe.resolved = true;

        self.open_swipe_switcher();
        // The switcher now owns the touch (carousel drag); drop the swipe so
        // a later release doesn't double-handle it — on mobile the carousel
        // up-path returns before end_bottom_swipe.
        self.bottom_swipe = None;
        self.unstall();
        self.request_redraw();
    }

    /// Freeze the swipe-driven app switcher fully open and interactive.
    fn open_swipe_switcher(&mut self) {
        self.app_switcher.commit_swipe();
        self.hide_osk();
        Self::send_to_crustace(r#"{"action":"hide_homescreen"}"#);
    }

    /// Close the switcher and fall back to whatever is underneath: the
    /// foreground app, or — when every window is minimised — the crustace
    /// homescreen, which was hidden when the switcher opened.
    fn dismiss_app_switcher(&mut self) {
        self.app_switcher.cancel();
        if self.windows.homescreen_active() {
            Self::send_to_crustace(r#"{"action":"show_homescreen"}"#);
        }
    }

    /// End a bottom swipe-up: settle the launcher, or resolve the switcher
    /// to go-home / commit / cancel. Returns true if the caller should skip
    /// the normal up-path.
    fn end_bottom_swipe(&mut self, pos: Point<f64, Logical>) -> bool {
        use crate::forge::{ BottomSwipe, SwipeContext };

        let Some(swipe) = self.bottom_swipe.take() else { return false };
        if let Some(token) = self.dwell_timer.take() {
            self.event_loop.remove(token);
        }
        self.dwell_anchor = None;
        if !swipe.active {
            return false;
        }
        if swipe.resolved {
            return true;
        }

        let progress = swipe.progress(pos.y);
        match swipe.context {
            Some(SwipeContext::Launcher) => {
                let committed = progress >= BottomSwipe::LAUNCHER_COMMIT;
                let msg = format!(
                    r#"{{"action":"launcher_wipe_done","committed":{},"progress":{:.3}}}"#,
                    committed, progress,
                );
                Self::send_to_crustace(&msg);
            },
            Some(SwipeContext::Switcher) => {
                // Over an app: a big swipe (past the home threshold) minimises
                // it and lands home; a deliberate swipe past the arm point but
                // short of home opens the switcher; a tiny one returns to the
                // app. On the homescreen there is no app to return to, so any
                // release past the arm point keeps the switcher. (Holding to
                // dwell also commits, but that path is unreliable on slow
                // touch hardware, so the release decides too.)
                if swipe.over_app && progress >= BottomSwipe::HOME_COMMIT {
                    self.app_switcher.cancel_swipe();
                    self.windows.minimize_all();
                    self.hide_osk();
                    Self::send_to_crustace(r#"{"action":"show_homescreen"}"#);
                } else if progress >= BottomSwipe::DWELL_ARM {
                    self.open_swipe_switcher();
                } else {
                    self.app_switcher.cancel_swipe();
                }
            },
            None => {}
        }
        self.request_redraw();
        true
    }

    pub( crate ) fn send_to_crustace(msg: &str) {
        use std::io::Write;
        use std::os::unix::net::UnixStream;

        let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
        let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".into());
        let socket_path = format!("{runtime_dir}/crustace-{wayland_display}.sock");

        match UnixStream::connect(&socket_path) {
            Ok(mut stream) => {
                let _ = writeln!(stream, "{msg}");
                let _ = stream.flush();
            }
            Err(e) => {
                tracing::debug!("Could not connect to crustace socket: {e}");
            }
        }
    }

    fn launch_first(candidates: &[&str], kind: &str) {
        let empty: [&str; 0] = [];
        for &prog in candidates {
            match daemon(prog, empty) {
                Ok(()) => {
                    info!("{kind}: launched {prog}");
                    return;
                },
                Err(_) => continue,
            }
        }
        // Try common Flatpak IDs as last resort
        let flatpak_ids: &[&str] = match kind {
            "Terminal" => &[
                "org.gnome.Ptyxis",
                "org.gnome.Terminal",
                "org.kde.konsole",
            ],
            "File manager" => &[
                "org.gnome.Nautilus",
                "org.gnome.Files",
                "org.kde.dolphin",
            ],
            "Browser" => &[
                "org.mozilla.firefox",
                "org.chromium.Chromium",
                "com.google.Chrome",
                "org.gnome.Epiphany",
            ],
            _ => &[],
        };
        for id in flatpak_ids {
            if daemon("flatpak", ["run", id]).is_ok() {
                info!("{kind}: launched flatpak {id}");
                return;
            }
        }
        error!("{kind} launch failed: no candidate found");
    }

    fn keyboard_action(
        forge: &mut Forge,
        mods: &ModifiersState,
        keysym: KeysymHandle,
        state: KeyState,
    ) -> FilterResult<InputAction> {
        let sym = keysym.modified_sym().raw();
        let raw_sym = keysym.raw_syms().first().map(|s| s.raw()).unwrap_or(sym);

        // Polkit auth dialog — modal, intercepts all keyboard
        if forge.polkit.visible {
            if state == KeyState::Pressed {
                match sym {
                    keysyms::KEY_Return | keysyms::KEY_KP_Enter => {
                        forge.polkit.try_authenticate();
                    }
                    keysyms::KEY_Escape => {
                        forge.polkit.cancel();
                    }
                    keysyms::KEY_BackSpace => {
                        forge.polkit.pop_char();
                    }
                    s => {
                        if let Some( c ) = char::from_u32( s ) {
                            if !c.is_control() {
                                forge.polkit.push_char( c );
                            }
                        }
                    }
                }
                forge.windows.set_dirty();
                forge.request_redraw();
            }
            return InputAction::None.into();
        }

        // When power menu is visible, intercept all input
        if forge.power_menu.visible {
            if state == KeyState::Pressed {
                if sym == keysyms::KEY_Escape || raw_sym == keysyms::KEY_XF86PowerOff {
                    forge.power_menu.dismiss();
                    forge.request_redraw();
                }
            }
            return InputAction::None.into();
        }

        // Ctrl+Alt+Backspace: quit compositor (return to login screen)
        if mods.ctrl && mods.alt && state == KeyState::Pressed
            && raw_sym == keysyms::KEY_BackSpace
        {
            forge.terminated = true;
            return InputAction::None.into();
        }

        // Ctrl+Alt+F<N>: VT switch. Mirrors the XF86Switch_VT_<N> match in the
        // big keysym table below, but kicks in even when the xkb layout does
        // not translate Ctrl+Alt+F<N> to the symbolic keysym (depends on
        // model/options). Pressed-only to avoid a duplicate switch on release.
        if mods.ctrl && mods.alt && state == KeyState::Pressed
            && matches!(raw_sym, keysyms::KEY_F1..=keysyms::KEY_F12)
        {
            return InputAction::ChangeVt(
                (raw_sym - keysyms::KEY_F1 + 1) as i32,
            ).into();
        }

        // Auto-dismiss OSD on any keypress (it'll also expire naturally)
        if forge.osd.visible {
            forge.osd.maybe_expire();
        }

        match (raw_sym, state) {
            // Volume/brightness keys -> built-in OSD
            (keysyms::KEY_XF86AudioRaiseVolume, KeyState::Pressed) => {
                let (percent, muted) = crate::osd::adjust_volume(true);
                forge.osd.show(crate::osd::OsdKind::Volume, percent, muted);
                forge.sys_state.volume.percent = percent;
                forge.sys_state.volume.muted = muted;
                forge.request_redraw();
                InputAction::None.into()
            },
            (keysyms::KEY_XF86AudioLowerVolume, KeyState::Pressed) => {
                let (percent, muted) = crate::osd::adjust_volume(false);
                forge.osd.show(crate::osd::OsdKind::Volume, percent, muted);
                forge.sys_state.volume.percent = percent;
                forge.sys_state.volume.muted = muted;
                forge.request_redraw();
                InputAction::None.into()
            },
            (keysyms::KEY_XF86AudioMute, KeyState::Pressed) => {
                let (percent, muted) = crate::osd::toggle_mute();
                forge.osd.show(crate::osd::OsdKind::Volume, percent, muted);
                forge.sys_state.volume.percent = percent;
                forge.sys_state.volume.muted = muted;
                forge.request_redraw();
                InputAction::None.into()
            },
            (keysyms::KEY_XF86MonBrightnessUp, KeyState::Pressed) => {
                let percent = crate::osd::adjust_brightness(true);
                forge.osd.show(crate::osd::OsdKind::Brightness, percent, false);
                forge.request_redraw();
                InputAction::None.into()
            },
            (keysyms::KEY_XF86MonBrightnessDown, KeyState::Pressed) => {
                let percent = crate::osd::adjust_brightness(false);
                forge.osd.show(crate::osd::OsdKind::Brightness, percent, false);
                forge.request_redraw();
                InputAction::None.into()
            },
            // Print Screen: take screenshot
            (keysyms::KEY_Print, KeyState::Pressed) if !mods.shift => {
                forge.take_screenshot();
                InputAction::None.into()
            },
            // Shift+Print Screen: toggle screencast recording
            (keysyms::KEY_Print, KeyState::Pressed) if mods.shift => {
                forge.toggle_screencast();
                InputAction::None.into()
            },
            (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12, KeyState::Pressed) => {
                InputAction::ChangeVt((raw_sym - keysyms::KEY_XF86Switch_VT_1 + 1) as i32).into()
            },
            // Power button: short press = lock + display off, long press = power menu
            (keysyms::KEY_XF86PowerOff, KeyState::Pressed) => {
                forge.power_key_pressed_at = Some(Instant::now());
                InputAction::None.into()
            },
            (keysyms::KEY_XF86PowerOff, KeyState::Released) => {
                if let Some(pressed_at) = forge.power_key_pressed_at.take() {
                    if pressed_at.elapsed() >= Duration::from_millis(800) {
                        forge.power_menu.show();
                    } else {
                        forge.lock_session();
                        forge.set_display_status(false);
                    }
                }
                forge.request_redraw();
                InputAction::None.into()
            },
            // Once the app switcher is visible, plain Tab/arrows cycle
            // the selection and Enter activates it — modifier-less so the
            // user can navigate after the corner-gesture / mouse-driven
            // open.
            (keysyms::KEY_Tab | keysyms::KEY_ISO_Left_Tab, KeyState::Pressed)
                if forge.app_switcher.is_active() =>
            {
                let step = if mods.shift { -1 } else { 1 };
                forge.app_switcher.step(step);
                forge.windows.set_dirty();
                forge.unstall();
                InputAction::None.into()
            },
            (keysyms::KEY_Right, KeyState::Pressed) if forge.app_switcher.is_active() => {
                forge.app_switcher.step(1);
                forge.windows.set_dirty();
                forge.unstall();
                InputAction::None.into()
            },
            (keysyms::KEY_Left, KeyState::Pressed) if forge.app_switcher.is_active() => {
                forge.app_switcher.step(-1);
                forge.windows.set_dirty();
                forge.unstall();
                InputAction::None.into()
            },
            (keysyms::KEY_Down, KeyState::Pressed) if forge.app_switcher.is_active() => {
                let per_row = forge.app_switcher.per_row(forge.windows.canvas().is_mobile()) as i32;
                forge.app_switcher.step(per_row);
                forge.windows.set_dirty();
                forge.unstall();
                InputAction::None.into()
            },
            (keysyms::KEY_Up, KeyState::Pressed) if forge.app_switcher.is_active() => {
                let per_row = forge.app_switcher.per_row(forge.windows.canvas().is_mobile()) as i32;
                forge.app_switcher.step(-per_row);
                forge.windows.set_dirty();
                forge.unstall();
                InputAction::None.into()
            },
            (keysyms::KEY_Return | keysyms::KEY_KP_Enter, KeyState::Pressed)
                if forge.app_switcher.is_active() =>
            {
                if let Some(oid) = forge.app_switcher.commit_object_id() {
                    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);
                    }
                }
                forge.request_redraw();
                InputAction::None.into()
            },
            // Alt+Tab / Alt+Shift+Tab: Windows-style window switcher.
            // First press shows the overlay and selects next/prev window;
            // subsequent presses while Alt is held cycle the selection.
            (keysyms::KEY_Tab | keysyms::KEY_ISO_Left_Tab, KeyState::Pressed)
                if mods.alt && !forge.windows.is_locked() =>
            {
                let step = if mods.shift { -1 } else { 1 };
                if forge.app_switcher.is_active() {
                    forge.app_switcher.step(step);
                } else {
                    let entries = forge.windows.app_switcher_entries();
                    let focused = forge.windows.focused_object_id();
                    forge.app_switcher.begin(entries, step, focused);
                    forge.hide_osk();
                }
                forge.windows.set_dirty();
                forge.unstall();
                InputAction::None.into()
            },
            // Super+Tab / Super+Shift+Tab: alias for Alt+Tab so the
            // switcher is reachable in nested winit sessions even when the
            // host compositor swallows Alt+Tab.
            (keysyms::KEY_Tab | keysyms::KEY_ISO_Left_Tab, KeyState::Pressed)
                if mods.logo && !forge.windows.is_locked() =>
            {
                let step = if mods.shift { -1 } else { 1 };
                if forge.app_switcher.is_active() {
                    forge.app_switcher.step(step);
                } else {
                    let entries = forge.windows.app_switcher_entries();
                    let focused = forge.windows.focused_object_id();
                    forge.app_switcher.begin(entries, step, focused);
                    forge.hide_osk();
                }
                forge.windows.set_dirty();
                forge.unstall();
                InputAction::None.into()
            },
            // Super release while switcher visible: commit selection.
            (keysyms::KEY_Super_L | keysyms::KEY_Super_R, KeyState::Released)
                if forge.app_switcher.is_active() =>
            {
                if let Some(oid) = forge.app_switcher.commit_object_id() {
                    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);
                    }
                }
                forge.request_redraw();
                InputAction::None.into()
            },
            // Enter while the screenshot selector is up: capture the selection.
            (keysyms::KEY_Return | keysyms::KEY_KP_Enter, KeyState::Pressed)
                if forge.screenshot.is_active() =>
            {
                if let Some(region) = forge.screenshot.confirm() {
                    forge.screenshot_region_to_clipboard(region);
                } else {
                    forge.request_redraw();
                }
                InputAction::None.into()
            },
            // Esc while the screenshot selector is up: cancel it.
            (keysyms::KEY_Escape, KeyState::Pressed) if forge.screenshot.is_active() => {
                forge.screenshot.cancel();
                forge.request_redraw();
                InputAction::None.into()
            },
            // Esc while switcher visible: cancel.
            (keysyms::KEY_Escape, KeyState::Pressed) if forge.app_switcher.is_active() => {
                forge.app_switcher.cancel();
                forge.request_redraw();
                InputAction::None.into()
            },
            // Type-to-search: a printable keystroke while the switcher is up
            // dismisses it and opens the launcher seeded with that character,
            // so the user can filter apps by just starting to type. The
            // launcher grabs keyboard focus on reveal, so the rest of the word
            // flows straight into its search field.
            (_, KeyState::Pressed)
                if forge.app_switcher.is_active()
                    && !mods.ctrl && !mods.alt && !mods.logo
                    && keysym.modified_sym().key_char().is_some_and(|c| !c.is_control()) =>
            {
                let c = keysym.modified_sym().key_char().unwrap();
                forge.app_switcher.cancel();
                let query = c.to_string().replace('\\', "\\\\").replace('"', "\\\"");
                Self::send_to_crustace(&format!(
                    r#"{{"action":"launcher_search_open","query":"{}"}}"#, query
                ));
                forge.request_redraw();
                InputAction::None.into()
            },
            // Alt release while switcher visible: commit selection.
            (keysyms::KEY_Alt_L | keysyms::KEY_Alt_R, KeyState::Released)
                if forge.app_switcher.is_active() =>
            {
                if let Some(oid) = forge.app_switcher.commit_object_id() {
                    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);
                    }
                }
                forge.request_redraw();
                InputAction::None.into()
            },
            // Super+Up: maximize/unmaximize focused window (desktop)
            (keysyms::KEY_Up, KeyState::Pressed) if mods.logo => {
                forge.windows.toggle_maximize_focused();
                forge.request_redraw();
                InputAction::None.into()
            },
            // Super+Down: minimize focused window (desktop)
            (keysyms::KEY_Down, KeyState::Pressed) if mods.logo => {
                forge.windows.minimize_focused();
                forge.request_redraw();
                InputAction::None.into()
            },
            // Super+L: lock screen
            (keysyms::KEY_l, KeyState::Pressed) if mods.logo => {
                forge.lock_session();
                forge.request_redraw();
                InputAction::None.into()
            },
            // Super+D: show desktop (minimize all)
            (keysyms::KEY_d, KeyState::Pressed) if mods.logo => {
                forge.windows.minimize_all();
                forge.hide_osk();
                Self::send_to_crustace(r#"{"action":"show_homescreen"}"#);
                forge.request_redraw();
                InputAction::None.into()
            },
            // Super+1..9: switch to workspace by number
            (keysyms::KEY_1..=keysyms::KEY_9, KeyState::Pressed) if mods.logo => {
                let index = (raw_sym - keysyms::KEY_1) as usize;
                forge.windows.switch_workspace(index);
                forge.unstall();
                InputAction::None.into()
            },
            // Alt+F4: close focused window
            (keysyms::KEY_F4, KeyState::Pressed) if mods.alt => {
                forge.windows.close_focused();
                forge.unstall();
                InputAction::None.into()
            },
            // F11 while the screenshot selector is up: toggle the selection
            // between the full output and the previous rectangle.
            (keysyms::KEY_F11, KeyState::Pressed) if forge.screenshot.is_active() => {
                let full = Rectangle::new(Point::from((0, 0)), forge.windows.canvas().size());
                forge.screenshot.toggle_fullscreen(full);
                forge.request_redraw();
                InputAction::None.into()
            },
            // F11: toggle fullscreen
            (keysyms::KEY_F11, KeyState::Pressed) => {
                forge.windows.toggle_fullscreen();
                forge.unstall();
                InputAction::None.into()
            },
            // Ctrl+L: lock screen
            (keysyms::KEY_l, KeyState::Pressed) if mods.ctrl && !mods.alt => {
                forge.lock_session();
                forge.request_redraw();
                InputAction::None.into()
            },
            // Ctrl+Return: launch terminal (try multiple terminals)
            (keysyms::KEY_Return, KeyState::Pressed) if mods.ctrl && !mods.alt => {
                Self::launch_first(
                    &["foot", "ptyxis", "konsole", "kitty", "alacritty", "gnome-terminal", "xfce4-terminal", "xterm"],
                    "Terminal",
                );
                InputAction::None.into()
            },
            // Ctrl+S: (quick settings removed)
            (keysyms::KEY_s, KeyState::Pressed) if mods.ctrl && !mods.alt => {
                InputAction::None.into()
            },
            // Ctrl+Space: cycle keyboard layout
            (keysyms::KEY_space, KeyState::Pressed) if mods.ctrl && !mods.alt => {
                forge.cycle_keyboard_layout();
                forge.unstall();
                InputAction::None.into()
            },
            // Super+Return: launch terminal
            (keysyms::KEY_Return, KeyState::Pressed) if mods.logo => {
                Self::launch_first(
                    &["foot", "ptyxis", "konsole", "kitty", "alacritty", "gnome-terminal", "xfce4-terminal", "xterm"],
                    "Terminal",
                );
                InputAction::None.into()
            },
            // Super+E: file manager
            (keysyms::KEY_e, KeyState::Pressed) if mods.logo => {
                Self::launch_first(
                    &["nautilus", "dolphin", "thunar", "nemo", "pcmanfm"],
                    "File manager",
                );
                InputAction::None.into()
            },
            // Super+B: browser
            (keysyms::KEY_b, KeyState::Pressed) if mods.logo => {
                Self::launch_first(
                    &["firefox", "firefox-esr", "chromium", "chromium-browser", "google-chrome-stable", "epiphany"],
                    "Browser",
                );
                InputAction::None.into()
            },
            // Super+A: toggle app launcher (handled by crustace via IPC)
            (keysyms::KEY_a, KeyState::Pressed) if mods.logo => {
                forge.unstall();
                InputAction::None.into()
            },
            // Super+P: power menu
            (keysyms::KEY_p, KeyState::Pressed) if mods.logo => {
                forge.power_menu.show();
                forge.request_redraw();
                InputAction::None.into()
            },
            _ => Self::handle_user_binding(forge, mods, raw_sym, state),
        }
    }

    fn handle_user_binding(
        forge: &mut Forge,
        mods: impl Into<Modifiers>,
        raw_keysym: u32,
        state: KeyState,
    ) -> FilterResult<InputAction> {
        forge.touch_state.cancel_key_repeat();

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

        if inhibits {
            return FilterResult::Forward;
        }

        let mods = mods.into();
        let active_app = forge.windows.focus().and_then(|(_, app_id)| app_id);
        let pressed = state == KeyState::Pressed;

        if pressed {
            forge.active_keys.insert(Keysym::Xkb(raw_keysym));
        }

        let mut result = FilterResult::Forward;

        for binding in &forge.key_bindings {
            let trigger_matches = match binding.trigger {
                KeyTrigger::Release => !pressed,
                _ => pressed,
            };

            if *binding.keys == forge.active_keys
                && binding.mods == mods
                && binding.app_id.matches(active_app.as_ref())
                && trigger_matches
            {
                let _ = daemon(&binding.program, &binding.arguments)
                    .inspect_err(|e| error!("Keybinding failed: {e}"));

                if binding.trigger == KeyTrigger::Repeat {
                    let program = binding.program.clone();
                    let arguments = binding.arguments.clone();
                    forge.touch_state.start_key_repeat(move || {
                        let _ = daemon(&program, &arguments)
                            .inspect_err(|e| error!("Keybinding repeat failed: {e}"));
                    });
                }

                result = InputAction::None.into();
            }
        }

        if !pressed {
            forge.active_keys.remove(&Keysym::Xkb(raw_keysym));
        }

        result
    }

    fn transform_position<I, E>(&self, event: &E) -> Point<f64, Logical>
    where
        E: AbsolutePositionEvent<I>,
        I: InputBackend,
    {
        let canvas = self.windows.canvas();
        let res = canvas.resolution();
        let (mut x, mut y) = event.position_transformed(res).into();
        let (w, h) = res.to_f64().into();

        (x, y) = match canvas.orientation() {
            Orientation::Portrait => (x, y),
            Orientation::Landscape => (y, w - x),
            Orientation::InversePortrait => (w - x, h - y),
            Orientation::InverseLandscape => (h - y, x),
        };

        (x, y).into()
    }

    fn keysym_to_keycode(&mut self, keysym: u32) -> Vec<Keycode> {
        let Some(keyboard) = self.seat.get_keyboard() else {
            return Vec::new();
        };

        let mut codes = Vec::new();

        keyboard.with_xkb_state(self, |ctx| unsafe {
            let xkb = ctx.xkb().lock().unwrap();
            let layout = xkb.active_layout();
            xkb.keymap().key_for_each(|_, keycode| {
                if xkb.raw_syms_for_key_in_layout(keycode, layout).iter().any(|s| s.raw() == keysym)
                {
                    codes.push(keycode);
                }
            });
        });

        codes
    }
}

enum InputAction {
    ChangeVt(i32),
    None,
}

impl From<InputAction> for FilterResult<InputAction> {
    fn from(a: InputAction) -> Self {
        FilterResult::Intercept(a)
    }
}
