// App switcher overlay — Windows-style Alt+Tab plus hot-corner gesture, with desktop grid and mobile carousel layouts.
//
// State machine:
//   - Hidden: no overlay, Alt+Tab simply moves selection one step and shows.
//   - Visible: overlay is drawn. Tab/Shift+Tab cycle selection. Releasing
//     Alt activates the selected window and hides. Esc cancels.
//
// Window order is "most-recently-used": when a window is activated (either
// directly or via Alt+Tab itself), it is moved to the head of the list.

use std::sync::mpsc;

use smithay::utils::{Logical, Rectangle};

use crate::drawing::RenderTexture;

mod assets;
mod gestures;
mod render;
#[cfg(test)]
mod tests;

/// Solid fallback used when the theme has no `lockscreen` asset (or its
/// decode fails) — same dark navy crustace uses for the same fallback so
/// the chrome reads consistent across the two surfaces.
const PANEL_BG_FALLBACK: [u8; 4] = [0x0a, 0x0a, 0x14, 255];
const TILE_GAP: f32 = 12.0;
/// Panel interior padding as a fraction of the panel's shorter side.
const PANEL_PAD_FRAC: f32 = 0.02;
const FONT_SIZE: f32 = 13.0;
const TEXT_OFFSET: i32 = 6;
const CLOSE_BTN_SIZE: i32 = 36;
const CLOSE_BTN_PAD: i32 = 5;
/// Fraction of the screen width occupied by the focused carousel tile on mobile.
const CAROUSEL_FOCUSED_FRAC: f32 = 0.8;
/// Gap (logical px) between adjacent tiles in the mobile carousel.
const CAROUSEL_GAP: f32 = 16.0;
/// Friction applied to carousel inertia, per second (v *= e^(-friction*dt)).
const CAROUSEL_FRICTION: f32 = 4.0;
/// Below this velocity the carousel snaps to the nearest tile and stops.
const CAROUSEL_SNAP_VELOCITY: f32 = 60.0;
/// Drag distance under this is treated as a tap (activate).
const CAROUSEL_TAP_PX: f32 = 10.0;
/// Per-frame snap-easing factor (1.0 = jump, 0.2 = slow ease).
const CAROUSEL_SNAP_EASE: f32 = 0.25;
/// Travel before a carousel drag locks to the horizontal (pan) or
/// vertical (close) axis.
const CAROUSEL_AXIS_LOCK_PX: f32 = 12.0;
/// Upward travel (logical px) at which releasing a vertical tile drag
/// closes that window; below it the tile snaps back.
const CAROUSEL_CLOSE_COMMIT_PX: f32 = 120.0;
/// Longest-edge target size used when rasterising the theme's
/// `lockscreen.svg`. 1920 px covers FHD landscape; the renderer scales
/// from there so HiDPI / portrait crops still come out sharp.
const PANEL_BG_RASTER_SIZE: u32 = 1920;
/// Frames over which the overlay fades in / out (~200 ms at 60 fps).
const FADE_FRAMES_TOTAL: u32 = 12;
/// Swipe progress at which the finger-driven entry is fully formed
/// (alpha 1, scale settled). Matches `BottomSwipe::DWELL_ARM` so the
/// panel reads as solid by the time a dwell can commit it.
const SWIPE_FORM_PROGRESS: f32 = 0.25;
/// Panel scale at swipe progress 0: the carousel starts zoomed in (the
/// focused window roughly fills the viewport) and settles to 1.0 as the
/// finger rises, reading as the window shrinking into the carousel.
const SWIPE_ENTRY_SCALE: f64 = 1.25;
/// Duration of the tile reposition animation triggered when the number
/// of entries changes while the switcher is open (window closed, new
/// window mapped): surviving tiles glide from their previous rect to the
/// new layout, newcomers fade + grow in place.
const LAYOUT_ANIM: std::time::Duration = std::time::Duration::from_millis(500);

/// Identity of an entry across layout changes: `object_id` when present
/// (globally unique), `surface_id` as the test-path fallback.
type EntryKey = (
    Option<smithay::reexports::wayland_server::backend::ObjectId>,
    u32,
);

#[derive(Clone)]
pub struct WindowEntry {
    /// `wl_surface.id().protocol_id()`. **Not** unique across clients — two
    /// distinct Wayland clients can each have a surface whose `protocol_id`
    /// returns the same `u32` (the value is per-client). Kept for backward
    /// compat with the IPC layer and the in-tree unit tests; new commit
    /// paths must prefer `object_id` when present so cross-client collisions
    /// stop activating the wrong window.
    pub surface_id: u32,
    /// Globally unique `ObjectId` of the toplevel's `wl_surface`. `Some` for
    /// entries built at runtime by `Windows::app_switcher_entries`; tests set
    /// this to `None` because constructing an `ObjectId` requires a server
    /// context. The commit path falls back to `surface_id` when this is
    /// `None`.
    pub object_id: Option<smithay::reexports::wayland_server::backend::ObjectId>,
    pub app_id: String,
    pub title: String,
    /// Latest rendered texture for the window, used as a live thumbnail.
    /// `None` if the window hasn't rendered any frame yet.
    pub thumbnail: Option<RenderTexture>,
    /// Logical size of the window's thumbnail texture (used for aspect ratio).
    pub thumb_size: Option<smithay::utils::Size<i32, smithay::utils::Logical>>,
    /// Connector name of the extend screen owning the window; `None` for
    /// the primary. Each screen's panel shows its own entries.
    pub output: Option<String>,
}

/// Result of a click hit-test in the Alt+Tab overlay.
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum AppSwitcherClick {
    /// Click on a tile: activate this window (commit and close switcher).
    Activate(usize),
    /// Click on a tile's close button: close that window without committing.
    Close(usize),
    /// Click outside the entire panel — user wants to dismiss the switcher.
    Dismiss,
    /// Click inside the panel but not on any actionable region (the gaps
    /// between tiles, the empty band below the tile row, etc.) — or a
    /// click during the post-open grace period. Treat as a no-op so the
    /// user can't accidentally close the panel by clicking its
    /// background.
    NoOp,
}

/// Grace period after the switcher becomes visible during which a click
/// landing outside the tiles is treated as `NoOp` rather than `Dismiss`.
/// The corner gesture opens the panel with the cursor at (0,0); without
/// this window any near-immediate click would close the switcher before
/// the user has had time to move the cursor onto a tile.
const OPEN_GRACE: std::time::Duration = std::time::Duration::from_millis(300);

pub struct AppSwitcher {
    pub visible: bool,
    pub selected: usize,
    pub entries: Vec<WindowEntry>,
    /// Decoded RGBA of the theme `lockscreen` asset, kept around so the
    /// panel-sized composed texture can be regenerated when the panel
    /// changes size or scale. `None` until the decode thread delivers a
    /// result, or permanently if the asset is missing.
    panel_bg_rgba: Option<(Vec<u8>, u32, u32)>,
    /// Panel-shaped compositions of `panel_bg_rgba`: cover-scaled to fit
    /// the panel and masked to the panel's rounded silhouette so the
    /// switcher's corners are not painted square. Keyed by
    /// `(panel_w_log, panel_h_log, scale_milli)` — one entry per screen in
    /// extend mode, all composed from the single decoded master.
    panel_bg_composed: std::collections::HashMap<(i32, i32, i32), RenderTexture>,
    /// In-flight off-thread composition: the panel-key it is composing for and
    /// the channel delivering the finished `(rgba, w_px, h_px)`. The cover-scale
    /// + Lanczos resize is heavy enough to drop a frame on a slow GPU, which ate
    /// the first switcher-open animation; doing it off-thread keeps that render
    /// cheap (the navy fallback shows until the composed image arrives).
    panel_bg_compose_pending: Option<((i32, i32, i32), mpsc::Receiver<(Vec<u8>, u32, u32)>)>,
    /// Set on first render to ensure the decode thread is started only once.
    panel_bg_loaded: bool,
    /// Longest-edge target for the decoded backdrop master. Starts at
    /// `PANEL_BG_RASTER_SIZE` and grows when the panel outgrows the master
    /// (4K/8K outputs), re-arming the decode thread at the larger size.
    bg_raster_target: u32,
    /// Receives `(rgba, w, h)` from the background decode thread, or `None`
    /// if the asset is missing / decode failed. Consumed when the result
    /// arrives; `None` thereafter.
    panel_bg_pending: Option<mpsc::Receiver<Option<(Vec<u8>, u32, u32)>>>,
    /// Theme id + mode at the moment the bg decode was kicked off. When
    /// the active theme flips (light ↔ dark, theme swap) `tick_theme`
    /// notices the key change, drops the theme-dependent caches and
    /// respawns the decode so the panel reflects the new palette /
    /// branding instead of the previous one's lockscreen image.
    current_theme_key: String,
    /// Rasterised text cache keyed by `(text, font_size_milli, rgba)`.
    /// Used for both the label below each tile and the placeholder text
    /// painted inside a tile that has no live thumbnail yet.
    label_cache: std::collections::HashMap<(String, u16, [u8; 4]), (RenderTexture, i32, i32)>,
    /// Last-rendered tile rectangles in logical coords for hit-testing.
    tile_rects: Vec<smithay::utils::Rectangle<i32, smithay::utils::Logical>>,
    /// Last-rendered close-button rectangles in logical coords.
    close_rects: Vec<smithay::utils::Rectangle<i32, smithay::utils::Logical>>,
    /// Wall-clock when the switcher last became visible. Used by the
    /// click-grace period: when the corner gesture opens the panel the
    /// cursor is by definition at the corner (outside the panel), so a
    /// click that lands inside the first 250 ms after open is silently
    /// ignored instead of dismissing — the user hasn't had time to
    /// move to a tile yet.
    opened_at: Option<std::time::Instant>,
    /// Bidirectional fade clock. Counts up to `FADE_FRAMES_TOTAL` while
    /// the panel is appearing, then counts back down to `0` while
    /// `is_hiding` is set. `fade_frames / FADE_FRAMES_TOTAL` is the
    /// alpha multiplier applied to every appended element.
    fade_frames: u32,
    /// `true` after a commit / cancel: the panel keeps rendering with a
    /// decreasing alpha, but input is no longer routed to it so the
    /// activated window can be interacted with through the fading scrim.
    is_hiding: bool,
    /// Live height (logical px) of crustace's auto-hide dock pill, pushed
    /// from the shell via the `set_dock_zone` IPC. The dock doesn't
    /// reserve an exclusive zone on its layer-shell surface (it hides
    /// when the cursor leaves the bottom band), so `canvas.available()`
    /// reaches the bottom of the output and the panel would otherwise
    /// paint over the dock. Eydos #6 wants a `dock_height + 5% screen`
    /// bottom margin instead — we honour that here.
    dock_height: i32,
    /// Stable-ID cached backgrounds so the damage tracker sees the same Id
    /// across frames and can skip repainting unchanged regions. Now rendered
    /// as rounded-corner textures via SoftCanvas, so the cache key includes
    /// the output scale (scale_milli = round(scale*1000)) — a fractional
    /// scale change would otherwise leave us with stale aliased corners.
    /// Solid-colour rectangle cache keyed by `(w_log, h_log, scale_milli,
    /// rgba)`. Used to paint the selected-tile outline (one outer +
    /// one inner colour) without re-rasterising on every frame.
    solid_rect_cache: std::collections::HashMap<(i32, i32, i32, [u8; 4]), RenderTexture>,
    /// Close button (window_button Close) rasterised through an LTK
    /// `UiSurface` so it matches the SSD chrome. Cached per scale.
    close_btn_ui: Option<ltk::core::UiSurface<()>>,
    close_btn_tex: Option<(i32, RenderTexture)>,
    /// Rounded solid backdrop per panel geometry, keyed by
    /// `(panel_w_log, panel_h_log, scale_milli)` — one per screen in
    /// extend mode, so alternating panels don't thrash a single slot.
    panel_bg_solid: std::collections::HashMap<(i32, i32, i32), RenderTexture>,
    /// Fade alpha + grow scale computed once per frame by `tick_frame`,
    /// reused by every screen's panel projection.
    frame_clock: (f32, f64),
    /// Reposition-animation ease for this frame, resolved once so every
    /// panel interpolates identically.
    frame_ease: Option<f32>,
    /// Mobile carousel horizontal offset, logical px. Positive shifts the
    /// row to the right (revealing earlier tiles on the left).
    carousel_offset: f32,
    /// Snap target for the current settle animation. `None` when no
    /// animation is in flight. Set every frame the snap-ease is
    /// progressing so `animating()` keeps requesting redraws — a stale
    /// `None` here let the ease run only when an unrelated event woke the
    /// render loop, which surfaces as a stuttering "blink".
    carousel_snap: Option<f32>,
    /// Set by `tick_carousel` when inertia just decayed to zero. The
    /// next render reads this, adopts the nearest tile as `selected`, and
    /// clears the flag — without it the snap-ease would still pull the
    /// offset back toward the original `selected` after a drag.
    carousel_settling: bool,
    /// In-flight drag state (touch press → up). `None` while idle or
    /// during inertia.
    carousel_drag: Option<CarouselDrag>,
    /// Inertia velocity in logical px / s. Zero when settled.
    carousel_velocity: f32,
    /// Wall-clock of the previous inertia tick, used for dt integration.
    carousel_last_tick: Option<std::time::Instant>,
    /// Finger-driven entry progress `[0, 1]` while the mobile bottom
    /// swipe-up is forming the panel. `None` once the entry is timed
    /// (committed or opened by Alt+Tab / hot-corner): then the fade clock
    /// drives `panel_alpha` / `panel_scale` instead.
    swipe_progress: Option<f32>,
    /// Active vertical tile drag: `(tile index, vertical offset in logical
    /// px, negative = up)`. The render lifts and fades that tile; releasing
    /// past [`CAROUSEL_CLOSE_COMMIT_PX`] closes the window.
    close_drag: Option<(usize, f32)>,
    /// Start of the in-flight tile reposition animation, `None` when idle.
    layout_anim: Option<std::time::Instant>,
    /// Cell each surviving entry occupied when the reposition began.
    layout_anim_from: std::collections::HashMap<EntryKey, ltk::Rect>,
    /// Cell each entry was drawn at last frame (interpolated position while
    /// a reposition is in flight), so a new animation starts from wherever
    /// the tiles currently are.
    last_cells: std::collections::HashMap<EntryKey, ltk::Rect>,
    /// Entry count at the previous render; a mismatch triggers the
    /// reposition animation.
    prev_entry_count: usize,
    /// Desktop tile drag: pointer-grabbed tile travelling between screens.
    tile_drag: Option<TileDrag>,
}

/// A tile grabbed with the pointer. Released over another screen's panel,
/// the window moves to that screen; released where it started (or barely
/// moved), it activates as a plain click.
#[derive(Clone, Copy)]
pub struct TileDrag {
    pub entry: usize,
    pub start: smithay::utils::Point<f64, Logical>,
    pub pos: smithay::utils::Point<f64, Logical>,
    /// Cell size at grab time, so the floating tile keeps its proportions
    /// while crossing screens of different scale.
    size: smithay::utils::Size<i32, Logical>,
    moved: bool,
}

/// How far the pointer must travel before a press becomes a drag rather
/// than a click.
const TILE_DRAG_SLOP: f64 = 8.0;

#[derive(Clone, Debug)]
struct CarouselDrag {
    start_pos: smithay::utils::Point<f64, smithay::utils::Logical>,
    last_pos: smithay::utils::Point<f64, smithay::utils::Logical>,
    last_time: std::time::Instant,
    start_offset: f32,
    /// Running max pointer travel since press — used to disambiguate
    /// taps (≤ CAROUSEL_TAP_PX) from drags.
    moved_px: f32,
    /// Smoothed velocity from the last few motion samples (px/s).
    velocity: f32,
    /// Locked once travel passes [`CAROUSEL_AXIS_LOCK_PX`]: horizontal pans
    /// the carousel, vertical lifts the grabbed tile to close it.
    axis: CarouselAxis,
    /// Tile grabbed for a vertical close-drag, hit-tested once when the axis
    /// locks. Fixed thereafter so the lifting tile moving out from under the
    /// finger doesn't re-hit-test and flicker.
    close_idx: Option<usize>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CarouselAxis {
    Undecided,
    Horizontal,
    Vertical,
}

impl std::fmt::Debug for AppSwitcher {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("AppSwitcher")
            .field("visible", &self.visible)
            .field("selected", &self.selected)
            .field("entries", &self.entries.len())
            .finish()
    }
}

impl AppSwitcher {
    pub fn new() -> Self {
        // Kick off the lockscreen.svg decode + Gaussian blur on a worker
        // thread at construction time. Without this the first open of
        // the switcher renders against the navy fallback for hundreds of
        // ms while the decode finishes.
        let (panel_bg_pending, panel_bg_loaded) = Self::spawn_bg_decode_thread(PANEL_BG_RASTER_SIZE);
        let current_theme_key = Self::theme_key();

        Self {
            visible: false,
            selected: 0,
            entries: Vec::new(),
            panel_bg_rgba: None,
            panel_bg_composed: std::collections::HashMap::new(),
            panel_bg_compose_pending: None,
            panel_bg_loaded,
            panel_bg_pending,
            bg_raster_target: PANEL_BG_RASTER_SIZE,
            current_theme_key,
            label_cache: std::collections::HashMap::new(),
            tile_rects: Vec::new(),
            close_rects: Vec::new(),
            opened_at: None,
            fade_frames: 0,
            is_hiding: false,
            dock_height: 0,
            solid_rect_cache: std::collections::HashMap::new(),
            close_btn_ui: None,
            close_btn_tex: None,
            panel_bg_solid: std::collections::HashMap::new(),
            frame_clock: (0.0, 1.0),
            frame_ease: None,
            carousel_offset: 0.0,
            carousel_snap: None,
            carousel_drag: None,
            carousel_velocity: 0.0,
            carousel_last_tick: None,
            carousel_settling: false,
            swipe_progress: None,
            close_drag: None,
            layout_anim: None,
            layout_anim_from: std::collections::HashMap::new(),
            last_cells: std::collections::HashMap::new(),
            prev_entry_count: 0,
            tile_drag: None,
        }
    }

    /// Build a rounded-rect RGBA texture at `(w_log, h_log)` logical px,
    /// rendered at `scale` and uploaded to the GPU. Used to bake the panel
    /// background, tile fills and close button once per (size, scale) rather
    /// than every frame — tiny-skia path fills are orders of magnitude more
    /// expensive than a 1×1 colour texture, so the cache turns "every frame"
    pub fn set_dock_height(&mut self, h: i32) {
        self.dock_height = h.max(0);
    }

    pub fn dock_height(&self) -> i32 {
        self.dock_height
    }

    /// On the first call: spawns a background thread to decode the theme's
    /// `lockscreen` branding asset so the file I/O and SVG rasterisation do
    /// not block the render loop. On every subsequent call: checks whether
    /// the thread has finished and, if so, uploads the texture to the GPU.
    /// The solid `PANEL_BG_FALLBACK` colour is drawn instead until the image
    /// is ready, so Alt+Tab is always responsive even on a cold first open.
    pub fn click_at(
        &self,
        point: smithay::utils::Point<f64, smithay::utils::Logical>,
    ) -> AppSwitcherClick {
        if !self.visible || self.entries.is_empty() {
            return AppSwitcherClick::Dismiss;
        }
        let (px, py) = (point.x as i32, point.y as i32);
        // Close buttons take precedence over the tile background.
        for (i, r) in self.close_rects.iter().enumerate() {
            if px >= r.loc.x && px < r.loc.x + r.size.w && py >= r.loc.y && py < r.loc.y + r.size.h
            {
                return AppSwitcherClick::Close(i);
            }
        }
        for (i, r) in self.tile_rects.iter().enumerate() {
            if px >= r.loc.x && px < r.loc.x + r.size.w && py >= r.loc.y && py < r.loc.y + r.size.h
            {
                return AppSwitcherClick::Activate(i);
            }
        }
        // Click in the empty space outside every tile dismisses the switcher
        // (returning to the app underneath, or home). The open-grace window
        // still swallows it so the hot-corner's own opening click can't close
        // the panel before the cursor has moved onto a tile.
        let in_grace = self
            .opened_at
            .map(|t| t.elapsed() < OPEN_GRACE)
            .unwrap_or(false);
        if in_grace {
            AppSwitcherClick::NoOp
        } else {
            AppSwitcherClick::Dismiss
        }
    }

    /// Remove the entry at `idx` (after closing the corresponding window)
    /// without dismissing the switcher. Adjusts selection if needed.
    pub fn remove_at(&mut self, idx: usize) {
        if idx >= self.entries.len() {
            return;
        }
        self.entries.remove(idx);
        if self.entries.is_empty() {
            self.cancel();
            return;
        }
        if self.selected >= self.entries.len() {
            self.selected = self.entries.len() - 1;
        }
    }

    /// Returns the surface id of the entry at `idx` (used by close-button click).
    pub fn surface_id_at(&self, idx: usize) -> Option<u32> {
        self.entries.get(idx).map(|e| e.surface_id)
    }

    /// Globally-unique identity of the entry at `idx`. Prefer this over
    /// [`surface_id_at`] for cross-client lookups: `surface_id` is a
    /// per-client `protocol_id` and collides between clients.
    pub fn object_id_at(
        &self,
        idx: usize,
    ) -> Option<smithay::reexports::wayland_server::backend::ObjectId> {
        self.entries.get(idx).and_then(|e| e.object_id.clone())
    }

    /// Sets the selection to `idx` and commits the switcher, returning the
    /// globally-unique `ObjectId` of the entry's `wl_surface` (used by tile
    /// click — same effect as Alt release on that selection).
    pub fn commit_object_id_index(
        &mut self,
        idx: usize,
    ) -> Option<smithay::reexports::wayland_server::backend::ObjectId> {
        if idx >= self.entries.len() {
            return None;
        }
        self.selected = idx;
        self.commit_object_id()
    }

    /// `ObjectId` of the highlighted entry, leaving the panel open.
    pub fn selected_object_id(
        &self,
    ) -> Option<smithay::reexports::wayland_server::backend::ObjectId> {
        self.entries.get(self.selected).and_then(|e| e.object_id.clone())
    }

    pub fn commit_object_id(
        &mut self,
    ) -> Option<smithay::reexports::wayland_server::backend::ObjectId> {
        let oid = self
            .entries
            .get(self.selected)
            .and_then(|e| e.object_id.clone());
        self.cancel();
        oid
    }

    /// Begin a switch: build the entry list from the current windows snapshot,
    /// move selection forward by `step` (typically +1, or -1 for Shift+Tab),
    /// and show the overlay. When `focused` is `Some(oid)` and matches an
    /// entry, the initial selection is computed relative to that entry so
    /// the user navigates around the currently active window; otherwise
    /// it starts at the alphabetical position 0.
    pub fn begin(
        &mut self,
        entries: Vec<WindowEntry>,
        step: i32,
        focused: Option<smithay::reexports::wayland_server::backend::ObjectId>,
    ) {
        // Label textures are small but unbounded; cap at 256 entries.
        // Cleared rather than pruned because the keying (text, size, color)
        // makes targeted eviction awkward and the re-render cost is small.
        if self.label_cache.len() > 256 {
            self.label_cache.clear();
        }

        // A direct open (Alt+Tab / hot-corner) is never finger-driven. Clear
        // any leftover swipe state so `is_swiping()` can't stay stuck true and
        // block the carousel's tap / close / drag input. `begin_swipe` re-sets
        // the progress right after it calls this.
        self.swipe_progress = None;
        self.close_drag = None;

        self.entries = entries;
        if self.entries.is_empty() {
            self.visible = false;
            self.is_hiding = false;
            self.opened_at = None;
            self.fade_frames = 0;
            return;
        }
        // Start from the focused window when known, then apply `step` so
        // Alt+Tab jumps to the next/previous window relative to the
        // current one (Windows-style) while preserving the alphabetical
        // tile order. Falls back to position 0 when the focused entry
        // isn't in the list (e.g. a layer surface had focus).
        let len = self.entries.len() as i32;
        let base = focused
            .and_then(|oid| {
                self.entries
                    .iter()
                    .position(|e| e.object_id.as_ref() == Some(&oid))
            })
            .map(|p| p as i32)
            .unwrap_or(0);
        self.selected = ((base + step).rem_euclid(len)) as usize;
        let was_open = self.visible;
        // Interrupting a fade-out: keep the current fade_frames so the
        // alpha animates smoothly forward from where it was, rather than
        // jumping back to zero. Treat hide-in-progress as "still open"
        // here so the user's re-trigger doesn't wipe carousel state.
        self.is_hiding = false;
        self.visible = true;
        if !was_open {
            self.opened_at = Some(std::time::Instant::now());
            // Carousel reset on a fresh open so the offset doesn't carry
            // over from a previous session.
            self.carousel_offset = 0.0;
            self.carousel_snap = None;
            self.carousel_drag = None;
            self.carousel_velocity = 0.0;
            self.carousel_last_tick = None;
            self.carousel_settling = false;
        }
    }

    /// Hit-test `point` against the last-rendered tile rects and adopt
    /// the hovered tile as the new selection. Returns `true` when the
    /// selection changed so the caller can flag a redraw. No-op when the
    /// switcher is not visible, the point lands outside every tile, or
    /// the hovered tile is already selected.
    pub fn hover_select(
        &mut self,
        point: smithay::utils::Point<f64, smithay::utils::Logical>,
    ) -> bool {
        if !self.visible || self.entries.is_empty() {
            return false;
        }
        let (px, py) = (point.x as i32, point.y as i32);
        for (i, r) in self.tile_rects.iter().enumerate() {
            if r.size.w <= 0 || r.size.h <= 0 {
                continue;
            }
            if px >= r.loc.x && px < r.loc.x + r.size.w && py >= r.loc.y && py < r.loc.y + r.size.h
            {
                if self.selected != i {
                    self.selected = i;
                    return true;
                }
                return false;
            }
        }
        false
    }

    pub fn step(&mut self, step: i32) {
        if !self.visible || self.entries.is_empty() {
            return;
        }
        let len = self.entries.len() as i32;
        let cur = self.selected as i32;
        self.selected = (cur + step).rem_euclid(len) as usize;
    }

    /// Columns per row in the current layout — used by the keyboard
    /// handler so Up/Down move by one full row instead of one tile.
    /// Mobile collapses to a 1-D carousel, so per_row is always 1 there.
    pub fn per_row(&self, is_mobile: bool) -> usize {
        if is_mobile || self.entries.is_empty() {
            return 1;
        }
        ((self.entries.len() as f32).sqrt().ceil() as usize).max(1)
    }

    /// Replace the current entry list while the switcher stays on
    /// screen. Called every frame the panel is active so new toplevels
    /// (mapped after `begin`) appear immediately and the layout
    /// (rows / carousel stride) adapts. The previously focused window's
    /// position in the new list is preserved when possible; otherwise
    /// `selected` clamps to a valid index.
    pub fn refresh_entries(&mut self, new_entries: Vec<WindowEntry>) {
        if !self.visible || self.is_hiding {
            return;
        }
        // Always replace: a fast-path keyed on (surface_id, title) would
        // miss thumbnail transitions (None → Some when a window finishes
        // its first frame) and the live tile would stay empty forever.
        // The per-frame copy is ~10 WindowEntry clones — cheap.
        let cur_identity = self
            .entries
            .get(self.selected)
            .map(|e| (e.object_id.clone(), e.surface_id));
        // Preserve the identity of the currently-selected window across
        // entry-list refreshes. Three-tier match:
        //   1. object_id (stable Wayland object pointer — preferred)
        //   2. surface_id (protocol id, stable for the surface lifetime)
        //   3. app_id + title (last resort: survives surface re-creation,
        //      e.g. apps that destroy and re-create their toplevel on
        //      un-minimize; fixes desktop ALT-TAB dropping some windows)
        let cur_app_title = self
            .entries
            .get(self.selected)
            .map(|e| (e.app_id.clone(), e.title.clone()));
        self.entries = new_entries;
        if self.entries.is_empty() {
            self.cancel();
            return;
        }
        let new_selected = cur_identity
            .and_then(|(oid, sid)| {
                oid.as_ref()
                    .and_then(|oid| {
                        self.entries
                            .iter()
                            .position(|e| e.object_id.as_ref() == Some(oid))
                    })
                    .or_else(|| {
                        self.entries
                            .iter()
                            .position(|e| e.object_id.is_none() && e.surface_id == sid)
                    })
                    .or_else(|| {
                        cur_app_title.as_ref().and_then(|(app_id, title)| {
                            if app_id.is_empty() && title.is_empty() {
                                return None;
                            }
                            self.entries.iter().position(|e| {
                                &e.app_id == app_id && &e.title == title
                            })
                        })
                    })
            })
            .unwrap_or(self.selected);
        self.selected = new_selected.min(self.entries.len() - 1);
    }

    /// Begin the fade-out. The panel stays rendered with decreasing alpha
    /// until `tick_fade` notices `fade_frames` has hit zero and clears
    /// the state. While hiding, `is_active()` reports false so input
    /// routes around the panel.
    pub fn cancel(&mut self) {
        if !self.visible {
            return;
        }
        self.is_hiding = true;
        self.opened_at = None;
    }

    /// Final teardown: zero every transient piece of state. Called from
    /// `tick_fade` once the exit animation finishes and from explicit
    /// "switcher is gone NOW" callers (test helpers, panic paths, …).
    fn finish_hide(&mut self) {
        self.visible = false;
        self.is_hiding = false;
        self.opened_at = None;
        self.entries.clear();
        self.selected = 0;
        self.fade_frames = 0;
        self.carousel_offset = 0.0;
        self.carousel_snap = None;
        self.carousel_drag = None;
        self.carousel_velocity = 0.0;
        self.carousel_last_tick = None;
        self.carousel_settling = false;
        self.swipe_progress = None;
        self.close_drag = None;
        self.layout_anim = None;
        self.layout_anim_from.clear();
        self.last_cells.clear();
        self.prev_entry_count = 0;
    }

    fn tick_frame(&mut self) -> Option<(f32, f64)> {
        if !self.visible || self.entries.is_empty() {
            self.tile_rects.clear();
            self.close_rects.clear();
            return None;
        }

        // Hit slots per entry, written in global coordinates by whichever
        // screen's panel draws the entry; stale slots survive a skipped
        // head frame so its tiles stay clickable.
        if self.tile_rects.len() != self.entries.len() {
            let zero = Rectangle::<i32, Logical>::default();
            self.tile_rects = vec![zero; self.entries.len()];
            self.close_rects = vec![zero; self.entries.len()];
        }

        // Entry-count change while open (window closed, new window mapped):
        // start the reposition animation from wherever each tile was drawn
        // last frame. Skipped on the first frame after open (prev == 0) and
        // while a finger-driven entry or the fade-out owns the motion.
        let n = self.entries.len();
        if self.prev_entry_count != n {
            if self.prev_entry_count != 0 && !self.is_hiding && self.swipe_progress.is_none() {
                self.layout_anim = Some(std::time::Instant::now());
                self.layout_anim_from = self.last_cells.clone();
            }
            self.prev_entry_count = n;
        }
        // Ease-out cubic progress of the reposition, `None` once settled.
        self.frame_ease = self.layout_anim.and_then(|start| {
            let t = start.elapsed().as_secs_f32() / LAYOUT_ANIM.as_secs_f32();
            (t < 1.0).then(|| 1.0 - (1.0 - t).powi(3))
        });
        if self.layout_anim.is_some() && self.frame_ease.is_none() {
            self.layout_anim = None;
            self.layout_anim_from.clear();
        }

        // Panel alpha + scale come from one of two clocks. A finger-driven
        // entry (mobile bottom swipe-up) maps the swipe progress directly:
        // the panel fades in and settles from `SWIPE_ENTRY_SCALE` to 1.0 as
        // the finger rises, so it follows the touch. Otherwise the timed
        // fade clock drives them — smoothstep eases the curve (snappier than
        // linear) and the panel grows 95 % → 100 % on appear / shrinks back
        // on dismiss, reading as an expansion rather than a uniform darken.
        if let Some(progress) = self.swipe_progress {
            let t = (progress / SWIPE_FORM_PROGRESS).clamp(0.0, 1.0);
            let eased = t * t * (3.0 - 2.0 * t);
            Some((
                eased,
                SWIPE_ENTRY_SCALE - (SWIPE_ENTRY_SCALE - 1.0) * (eased as f64),
            ))
        } else {
            if self.is_hiding {
                if self.fade_frames > 0 {
                    self.fade_frames -= 1;
                }
                if self.fade_frames == 0 {
                    self.finish_hide();
                    self.tile_rects.clear();
                    self.close_rects.clear();
                    return None;
                }
            } else if self.fade_frames < FADE_FRAMES_TOTAL {
                self.fade_frames += 1;
            }
            let t = (self.fade_frames as f32 / FADE_FRAMES_TOTAL as f32).clamp(0.0, 1.0);
            let eased = t * t * (3.0 - 2.0 * t);
            Some((eased, 0.95_f64 + 0.05_f64 * (eased as f64)))
        }
    }

    /// Returns true if a fade (in or out) or a carousel motion (drag,
    /// inertia, snap-ease) is still in progress and the caller should
    /// schedule another redraw.
    pub fn animating(&self) -> bool {
        if !self.visible {
            return false;
        }
        if self.swipe_progress.is_some() {
            return true;
        }
        if self.is_hiding {
            return true;
        }
        if self.fade_frames < FADE_FRAMES_TOTAL {
            return true;
        }
        if self.carousel_drag.is_some() {
            return true;
        }
        if self.carousel_velocity.abs() > f32::EPSILON {
            return true;
        }
        if self.carousel_snap.is_some() {
            return true;
        }
        if self.layout_anim.is_some() {
            return true;
        }
        false
    }

    /// True when the switcher should receive input — visible AND not in
    /// the middle of its fade-out. Callers route clicks / motion / key
    /// events through this rather than `visible` so the activated window
    /// underneath the fading panel takes over immediately after a commit.
    pub fn is_active(&self) -> bool {
        self.visible && !self.is_hiding
    }

}

