// 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::backend::renderer::element::Element;
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::utils::{Logical, Physical, Point, Rectangle};

use crate::canvas::{Color, SoftCanvas};
use crate::drawing::{ForgeElement, RenderTexture, Texture};
use crate::output::Canvas;
use crate::text::TextRenderer;

/// 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 TEXT_COL: [u8; 4] = [230, 230, 235, 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;

#[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>>,
}

/// 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 composition 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)`.
    panel_bg_composed: Option<((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,
    /// 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)>,
    panel_bg_solid: Option<RenderTexture>,
    /// (panel_w_log, panel_h_log, scale_milli) — recreate the rounded
    /// fill whenever any of these change.
    panel_bg_solid_key: (i32, i32, i32),
    /// 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)>,
}

#[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();
        let current_theme_key = Self::theme_key();

        Self {
            visible: false,
            selected: 0,
            entries: Vec::new(),
            panel_bg_rgba: None,
            panel_bg_composed: None,
            panel_bg_compose_pending: None,
            panel_bg_loaded,
            panel_bg_pending,
            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: None,
            panel_bg_solid_key: (0, 0, 0),
            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,
        }
    }

    /// 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"
    fn theme_key() -> String {
        format!(
            "{}:{:?}",
            ltk::theme::active_theme_id(),
            ltk::theme::active_mode()
        )
    }

    fn spawn_bg_decode_thread() -> (Option<mpsc::Receiver<Option<(Vec<u8>, u32, u32)>>>, bool) {
        let Some(path) = ltk::theme_branding_image("lockscreen", 0, 0) else {
            return (None, false);
        };
        let (tx, rx) = mpsc::sync_channel(1);
        let spawned = std::thread::Builder::new()
            .name("app-switcher-bg".into())
            .spawn(move || {
                let _ = tx.send(load_bg_pixels(path));
            })
            .is_ok();
        if spawned {
            (Some(rx), true)
        } else {
            (None, false)
        }
    }

    /// Re-check the active theme/mode every frame. When it changes,
    /// drop the cached lockscreen rgba + composed texture + close
    /// button raster + theme-coloured solid rects / labels, then start
    /// a fresh decode of the new theme's lockscreen image. Cheap when
    /// nothing has changed (one HashMap lookup + a `String` compare).
    pub fn tick_theme(&mut self) {
        let key = Self::theme_key();
        if key == self.current_theme_key {
            return;
        }
        self.current_theme_key = key;
        self.panel_bg_rgba = None;
        self.panel_bg_composed = None;
        self.panel_bg_compose_pending = None;
        self.close_btn_tex = None;
        self.solid_rect_cache.clear();
        self.label_cache.clear();
        let (rx, loaded) = Self::spawn_bg_decode_thread();
        self.panel_bg_pending = rx;
        self.panel_bg_loaded = loaded;
    }

    fn make_rounded_texture(
        renderer: &mut GlesRenderer,
        scale: f64,
        w_log: i32,
        h_log: i32,
        rgba: [u8; 4],
        radius_log: f32,
    ) -> RenderTexture {
        let w_px = ((w_log as f64) * scale).round().max(1.0) as u32;
        let h_px = ((h_log as f64) * scale).round().max(1.0) as u32;
        let mut canvas = SoftCanvas::new(w_px, h_px);
        canvas.clear();
        let color = Color::rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
        canvas.fill_rect(
            0.0,
            0.0,
            w_px as f32,
            h_px as f32,
            color,
            (radius_log as f64 * scale) as f32,
        );
        let tex = Texture::from_buffer(
            renderer,
            scale,
            canvas.data(),
            w_px as i32,
            h_px as i32,
            false,
        );
        RenderTexture::new(tex)
    }

    /// Crustace pushes the live pill height (logical px) via the
    /// `set_dock_zone` IPC action. We honour it as the bottom margin
    /// so the alt-tab panel never paints over the dock when the user
    /// brings the cursor down to reveal it.
    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.
    fn try_load_panel_bg(&mut self, _renderer: &mut GlesRenderer, _scale: f64) {
        if !self.panel_bg_loaded {
            self.panel_bg_loaded = true;
            if let Some(path) = ltk::theme_branding_image("lockscreen", 0, 0) {
                let (tx, rx) = mpsc::sync_channel(1);
                std::thread::Builder::new()
                    .name("app-switcher-bg".into())
                    .spawn(move || {
                        let _ = tx.send(load_bg_pixels(path));
                    })
                    .ok();
                self.panel_bg_pending = Some(rx);
            }
        }

        if self.panel_bg_rgba.is_none() {
            if let Some(rx) = self.panel_bg_pending.take() {
                match rx.try_recv() {
                    Ok(Some((rgba, w, h))) => {
                        self.panel_bg_rgba = Some((rgba, w, h));
                    }
                    Ok(None) => {} // decode failed; solid fallback is used permanently
                    Err(mpsc::TryRecvError::Empty) => {
                        self.panel_bg_pending = Some(rx); // still decoding
                    }
                    Err(mpsc::TryRecvError::Disconnected) => {} // thread panic
                }
            }
        }
    }

    /// Build (or reuse) the panel-sized composition: the lockscreen image
    /// cover-scaled and masked to the panel's rounded silhouette so the
    /// switcher's corners read rounded instead of square. Returns the
    /// freshly cached texture, or `None` while the image hasn't decoded
    /// yet (caller renders the `panel_bg_solid` fallback in that case).
    fn ensure_panel_bg_composed(
        &mut self,
        renderer: &mut GlesRenderer,
        scale: f64,
        panel_w_log: i32,
        panel_h_log: i32,
        scale_key: i32,
        radius_log: f32,
    ) -> Option<RenderTexture> {
        let key = (panel_w_log, panel_h_log, scale_key);
        if let Some((k, tex)) = &self.panel_bg_composed {
            if *k == key {
                return Some(tex.clone());
            }
        }

        // An off-thread compose may be in flight. Take it out so the field is
        // free to reassign, then decide: upload it, put it back (still busy),
        // or drop it (finished/stale) and fall through to a fresh compose.
        if let Some((pk, rx)) = self.panel_bg_compose_pending.take() {
            if pk == key {
                match rx.try_recv() {
                    Ok((rgba, w_px, h_px)) => {
                        let tex = Texture::from_buffer(
                            renderer, scale, &rgba, w_px as i32, h_px as i32, false,
                        );
                        let rt = RenderTexture::new(tex);
                        self.panel_bg_composed = Some((key, rt.clone()));
                        return Some(rt);
                    },
                    Err(mpsc::TryRecvError::Empty) => {
                        self.panel_bg_compose_pending = Some((pk, rx));
                        return None;
                    },
                    Err(mpsc::TryRecvError::Disconnected) => {},
                }
            }
        }

        // Nothing cached or in flight for this key: compose on a worker thread
        // (the cover-scale + Lanczos resize is the heavy part) and show the
        // solid fallback until it lands.
        let (rgba, img_w, img_h) = match self.panel_bg_rgba.as_ref() {
            Some((r, w, h)) if *w > 0 && *h > 0 => (r.clone(), *w, *h),
            _ => return None,
        };
        let panel_w_px = ((panel_w_log as f64) * scale).round().max(1.0) as u32;
        let panel_h_px = ((panel_h_log as f64) * scale).round().max(1.0) as u32;
        let radius_px = (radius_log as f64 * scale) as f32;
        let (tx, rx) = mpsc::sync_channel(1);
        let spawned = std::thread::Builder::new()
            .name("app-switcher-bg-compose".into())
            .spawn(move || {
                let rgba =
                    compose_panel_bg_rgba(rgba, img_w, img_h, panel_w_px, panel_h_px, radius_px);
                let _ = tx.send((rgba, panel_w_px, panel_h_px));
            })
            .is_ok();
        if spawned {
            self.panel_bg_compose_pending = Some((key, rx));
        }
        None
    }

    /// Build the size/scale-dependent caches (panel solid fill, frosted
    /// background, close button) ahead of the first open so the opening
    /// animation doesn't stall while they raster on the first swipe-up. Called
    /// on idle frames while the switcher is closed; every step is self-guarded,
    /// so it only does work the first time (and after a theme / size change).
    /// Entry-dependent caches (labels, thumbnails) still build lazily on open.
    pub fn prewarm(&mut self, renderer: &mut GlesRenderer, canvas: &Canvas) {
        let scale = canvas.scale();
        let screen = canvas.size();
        if screen.w <= 0 || screen.h <= 0 {
            return;
        }
        let (panel_w, panel_h) = (screen.w, screen.h);
        let scale_key = (scale * 1000.0).round() as i32;
        let panel_radius = 0.0;

        self.try_load_panel_bg(renderer, scale);
        let _ = self.ensure_panel_bg_composed(renderer, scale, panel_w, panel_h, scale_key, panel_radius);

        let cur_panel_key = (panel_w, panel_h, scale_key);
        if self.panel_bg_solid_key != cur_panel_key {
            self.panel_bg_solid = Some(Self::make_rounded_texture(
                renderer,
                scale,
                panel_w,
                panel_h,
                PANEL_BG_FALLBACK,
                panel_radius,
            ));
            self.panel_bg_solid_key = cur_panel_key;
        }
        let _ = self.ensure_close_btn(renderer, scale, scale_key);
    }

    /// Hit-test a click in logical coordinates.
    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()
    }

    /// Returns the `ObjectId` of the selected entry and hides the overlay.
    /// See [`commit_object_id_index`] for why callers prefer the `ObjectId`
    /// form over a per-client `u32`.
    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;
        }
    }

    /// Open the switcher in finger-driven entry mode: the panel forms and
    /// settles as [`set_swipe_progress`] follows the bottom swipe-up, in
    /// place of the timed fade. [`commit_swipe`] freezes it fully open;
    /// [`cancel_swipe`] tears it back down.
    pub fn begin_swipe(
        &mut self,
        entries: Vec<WindowEntry>,
        focused: Option<smithay::reexports::wayland_server::backend::ObjectId>,
    ) {
        self.begin(entries, 0, focused);
        if self.visible {
            self.swipe_progress = Some(0.0);
            self.fade_frames = 0;
        }
    }

    /// Drive the finger-following entry from a bottom-swipe progress
    /// `[0, 1]`. No-op once the entry has been committed or was never a
    /// swipe (Alt+Tab / hot-corner).
    pub fn set_swipe_progress(&mut self, progress: f32) {
        if self.swipe_progress.is_some() {
            self.swipe_progress = Some(progress.clamp(0.0, 1.0));
        }
    }

    /// Freeze the swipe-driven panel fully open and interactive — the
    /// dwell committed the switcher. The fade clock takes over at full
    /// alpha so later dismiss/commit animate normally.
    pub fn commit_swipe(&mut self) {
        if self.swipe_progress.take().is_some() {
            self.fade_frames = FADE_FRAMES_TOTAL;
            self.opened_at = Some(std::time::Instant::now());
        }
    }

    /// Abandon a swipe-driven entry (released below the commit threshold):
    /// drop the panel so the foreground app underneath takes over again.
    pub fn cancel_swipe(&mut self) {
        self.swipe_progress = None;
        self.cancel();
    }

    /// True while the panel is being formed by the bottom swipe-up.
    pub fn is_swiping(&self) -> bool {
        self.swipe_progress.is_some()
    }

    /// True while a carousel press→drag is in flight (used by the pointer
    /// path to feed mouse motion into the drag, since winit has no
    /// TouchMotion events).
    pub fn is_carousel_dragging(&self) -> bool {
        self.carousel_drag.is_some()
    }

    /// 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;
    }

    fn ensure_label(
        &mut self,
        text: &str,
        font_size: f32,
        color: [u8; 4],
        renderer: &mut GlesRenderer,
        text_renderer: &mut TextRenderer,
        scale: f64,
    ) -> Option<(RenderTexture, i32, i32)> {
        let key = (text.to_string(), (font_size * 10.0) as u16, color);
        if let Some(v) = self.label_cache.get(&key) {
            return Some(v.clone());
        }
        let (buf, w, h) = text_renderer.render_line(text, font_size * scale as f32, color);
        if w == 0 || h == 0 {
            return None;
        }
        let tex = RenderTexture::new(Texture::from_buffer(
            renderer, scale, &buf, w as i32, h as i32, false,
        ));
        let entry = (tex, w as i32, h as i32);
        self.label_cache.insert(key, entry.clone());
        Some(entry)
    }

    pub fn render(
        &mut self,
        textures: &mut Vec<ForgeElement>,
        canvas: &Canvas,
        renderer: &mut GlesRenderer,
        text_renderer: &mut TextRenderer,
    ) {
        if !self.visible || self.entries.is_empty() {
            self.tile_rects.clear();
            self.close_rects.clear();
            return;
        }

        // 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.
        let (panel_alpha, panel_scale) = 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);
            (
                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;
                }
            } 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);
            (eased, 0.95_f64 + 0.05_f64 * (eased as f64))
        };

        let insert_at = textures.len();

        self.try_load_panel_bg(renderer, canvas.scale());

        let scale = canvas.scale();
        let screen = canvas.size();
        let is_mobile = canvas.is_mobile();
        let avail = canvas.available();

        self.tile_rects.clear();
        self.close_rects.clear();

        let (panel_x, panel_y, panel_w, panel_h) = (0, 0, screen.w, screen.h);

        let panel_log =
            Rectangle::<i32, Logical>::new((panel_x, panel_y).into(), (panel_w, panel_h).into());
        let panel_phys = panel_log.to_physical_precise_round(scale);
        let panel_center = smithay::utils::Point::<i32, smithay::utils::Physical>::from((
            panel_phys.loc.x + panel_phys.size.w / 2,
            panel_phys.loc.y + panel_phys.size.h / 2,
        ));
        let scale_key = (scale * 1000.0).round() as i32;
        let panel_radius = 0.0;

        // Panel background: solid rounded fill as always-on backdrop, plus the
        // theme's lockscreen image overlaid on top once decoded.
        let cur_panel_key = (panel_w, panel_h, scale_key);
        if self.panel_bg_solid_key != cur_panel_key {
            self.panel_bg_solid = Some(Self::make_rounded_texture(
                renderer,
                scale,
                panel_w,
                panel_h,
                PANEL_BG_FALLBACK,
                panel_radius,
            ));
            self.panel_bg_solid_key = cur_panel_key;
        }
        if let Some(bg) = self.panel_bg_solid.as_ref().cloned() {
            ForgeElement::add_element_panel_composited(
                textures,
                bg,
                panel_phys.loc,
                panel_phys,
                None,
                scale,
                panel_alpha,
                panel_scale,
                panel_center,
            );
        }
        if let Some(bg_img) = self.ensure_panel_bg_composed(
            renderer,
            scale,
            panel_w,
            panel_h,
            scale_key,
            panel_radius,
        ) {
            ForgeElement::add_element_panel_composited(
                textures,
                bg_img,
                panel_phys.loc,
                panel_phys,
                None,
                scale,
                panel_alpha,
                panel_scale,
                panel_center,
            );
        }

        let n = self.entries.len();
        let content_rect = if is_mobile {
            let pad = ((panel_w.min(panel_h)) as f32 * PANEL_PAD_FRAC).max(8.0);
            ltk::Rect {
                x: panel_x as f32 + pad,
                y: panel_y as f32 + pad,
                width: (panel_w as f32 - 2.0 * pad).max(1.0),
                height: (panel_h as f32 - 2.0 * pad).max(1.0),
            }
        } else {
            let h_margin = (screen.w as f32 * 0.02) as i32;
            let v_margin = (screen.h as f32 * 0.02) as i32;
            let cx = avail.loc.x + h_margin;
            let cy = avail.loc.y + v_margin;
            let cw = (avail.size.w - 2 * h_margin).max(120);
            let bottom = screen.h - self.dock_height - v_margin;
            let ch = (bottom - cy).max(120);
            ltk::Rect {
                x: cx as f32,
                y: cy as f32,
                width: cw as f32,
                height: ch as f32,
            }
        };
        // The LTK layout helpers ask the canvas about font metrics for text
        // children; with spacer placeholders the canvas is unused, so a
        // throwaway 1×1 one is enough.
        let ltk_canvas = ltk::core::Canvas::new(1, 1);

        let label_band = (FONT_SIZE * 1.6) as f32;
        let cells: Vec<ltk::Rect> = if is_mobile && n > 1 {
            // Carousel: clamp + ease the offset, then ask LTK for the rects.
            let stride = content_rect.width * CAROUSEL_FOCUSED_FRAC + CAROUSEL_GAP;
            let min_off = -(((n - 1) as f32) * stride);
            self.carousel_offset = self.carousel_offset.clamp(min_off, 0.0);
            // While not dragging and no inertia in flight, ease toward the
            // snap target so a keyboard step / fresh open animates rather
            // than jumps. If we just settled out of inertia, adopt the
            // nearest tile as the new selection first — without this the
            // carousel snaps back to whatever was selected before the drag.
            if self.carousel_drag.is_none() && self.carousel_velocity.abs() < f32::EPSILON {
                if self.carousel_settling {
                    let focused = (-self.carousel_offset / stride)
                        .round()
                        .clamp(0.0, (n - 1) as f32) as usize;
                    self.selected = focused;
                    self.carousel_settling = false;
                }
                let target = -(self.selected as f32) * stride;
                let delta = target - self.carousel_offset;
                if delta.abs() < 0.5 {
                    self.carousel_offset = target;
                    self.carousel_snap = None;
                } else {
                    self.carousel_offset += delta * CAROUSEL_SNAP_EASE;
                    // Keep `animating()` truthy while the ease is in
                    // flight — otherwise the next frame depends on an
                    // unrelated event and the motion stutters.
                    self.carousel_snap = Some(target);
                }
            }
            let mut car = ltk::carousel::<()>()
                .focused_width_frac(CAROUSEL_FOCUSED_FRAC)
                .gap(CAROUSEL_GAP)
                .offset(self.carousel_offset);
            for _ in 0..n {
                car = car.push(ltk::spacer());
            }
            car.layout(content_rect, &ltk_canvas)
                .into_iter()
                .map(|(r, _)| r)
                .collect()
        } else if is_mobile {
            // n == 1: size it like the carousel's focused tile (centred) so a
            // single window looks the same as one among several rather than
            // ballooning to fill the screen.
            let w = content_rect.width * CAROUSEL_FOCUSED_FRAC;
            let x = content_rect.x + (content_rect.width - w) / 2.0;
            vec![ltk::Rect {
                x,
                y: content_rect.y,
                width: w,
                height: content_rect.height,
            }]
        } else {
            // Desktop grid: per_row = ceil(sqrt(n)), last partial row centred.
            // WrapGrid sizes rows from children's natural heights, which is
            // zero for placeholder spacers, so we compute cells manually
            // here — same formula as the LTK widget but distributing the
            // full content height across the rows.
            let per_row = ((n as f32).sqrt().ceil() as usize).max(1);
            let n_rows = ((n + per_row - 1) / per_row).max(1);
            let gap = TILE_GAP;
            let cell_w = (content_rect.width - (per_row - 1) as f32 * gap) / per_row as f32;
            let cell_h = (content_rect.height - (n_rows - 1) as f32 * gap) / n_rows as f32;
            let mut cells = Vec::with_capacity(n);
            for i in 0..n {
                let row = i / per_row;
                let col_in_row = i % per_row;
                let items_in_row = if row == n_rows - 1 {
                    n - row * per_row
                } else {
                    per_row
                };
                let row_offset = if items_in_row < per_row {
                    (per_row - items_in_row) as f32 * (cell_w + gap) / 2.0
                } else {
                    0.0
                };
                let x = content_rect.x + row_offset + col_in_row as f32 * (cell_w + gap);
                let y = content_rect.y + row as f32 * (cell_h + gap);
                cells.push(ltk::Rect {
                    x,
                    y,
                    width: cell_w,
                    height: cell_h,
                });
            }
            cells
        };

        // Snapshot per-entry view data so the per-tile loop doesn't have to
        // borrow `self.entries` while it mutates label/icon/tile-bg caches.
        let entries_snapshot: Vec<(
            usize,
            String,
            String,
            Option<RenderTexture>,
            Option<smithay::utils::Size<i32, Logical>>,
        )> = self
            .entries
            .iter()
            .enumerate()
            .map(|(i, e)| {
                let label = if e.title.is_empty() {
                    e.app_id.clone()
                } else {
                    e.title.clone()
                };
                (
                    i,
                    e.app_id.clone(),
                    label,
                    e.thumbnail.clone(),
                    e.thumb_size,
                )
            })
            .collect();

        let panel_left = panel_x as f32;
        let panel_right = (panel_x + panel_w) as f32;
        let close_drag = self.close_drag;
        for (mut cell, snap) in cells.into_iter().zip(entries_snapshot.into_iter()) {
            // Mobile carousel skips tiles whose cell does not intersect the
            // panel viewport — clipping happens at composition, but we'd
            // still pay the rasterisation cost without this guard.
            if is_mobile && (cell.x + cell.width <= panel_left || cell.x >= panel_right) {
                self.tile_rects
                    .push(Rectangle::<i32, Logical>::new((0, 0).into(), (0, 0).into()));
                self.close_rects
                    .push(Rectangle::<i32, Logical>::new((0, 0).into(), (0, 0).into()));
                continue;
            }
            let (i, app_id, label, thumbnail, thumb_size) = snap;
            // A vertical close-drag lifts the grabbed tile with the finger
            // and fades it toward the commit point so it reads as peeling
            // off the deck before it closes.
            let mut tile_alpha = panel_alpha;
            if let Some((idx, dy)) = close_drag {
                if idx == i {
                    cell.y += dy;
                    let f = (-dy / CAROUSEL_CLOSE_COMMIT_PX).clamp(0.0, 1.0);
                    tile_alpha = panel_alpha * (1.0 - 0.6 * f);
                }
            }
            self.render_tile(
                textures,
                renderer,
                text_renderer,
                cell,
                i,
                &app_id,
                &label,
                thumbnail,
                thumb_size,
                scale,
                scale_key,
                is_mobile,
                label_band,
                tile_alpha,
                panel_scale,
                panel_center,
            );
        }

        textures[insert_at..].reverse();
    }

    fn render_tile(
        &mut self,
        textures: &mut Vec<ForgeElement>,
        renderer: &mut GlesRenderer,
        text_renderer: &mut TextRenderer,
        cell: ltk::Rect,
        entry_idx: usize,
        _app_id: &str,
        label: &str,
        thumbnail: Option<RenderTexture>,
        thumb_size: Option<smithay::utils::Size<i32, smithay::utils::Logical>>,
        scale: f64,
        scale_key: i32,
        is_mobile: bool,
        label_band: f32,
        panel_alpha: f32,
        panel_scale: f64,
        panel_center: smithay::utils::Point<i32, smithay::utils::Physical>,
    ) {
        // Reserve the bottom label_band for the title; the remaining area is
        // the tile (thumbnail + bg + close button). Mobile carousels skip the
        // label entirely (the tile already fills the visible area).
        let band = if is_mobile { 0.0 } else { label_band };
        let tile_w_log = cell.width.floor().max(1.0) as i32;
        let tile_h_log = (cell.height - band).floor().max(1.0) as i32;
        let tile_x_log = cell.x.round() as i32;
        let tile_y_log = cell.y.round() as i32;
        let tile_log = Rectangle::<i32, Logical>::new(
            (tile_x_log, tile_y_log).into(),
            (tile_w_log, tile_h_log).into(),
        );
        self.tile_rects.push(tile_log);

        let selected = entry_idx == self.selected;

        // Compute the thumbnail fit rect up front so the outline (selected
        // only) and the close button can anchor to the visible window
        // content rather than the cell. Falls back to the full tile rect
        // when no thumbnail is available (eg. app-icon fallback).
        let inset = 4;
        let avail_w = (tile_w_log - inset * 2).max(1) as f64;
        let avail_h = (tile_h_log - inset * 2).max(1) as f64;
        let fit_log = match thumb_size {
            Some(ws) if ws.w > 0 && ws.h > 0 => {
                let win_aspect = ws.w as f64 / ws.h as f64;
                let (fit_w, fit_h) = if avail_w / avail_h > win_aspect {
                    ((avail_h * win_aspect) as i32, avail_h as i32)
                } else {
                    (avail_w as i32, (avail_w / win_aspect) as i32)
                };
                let fit_x = tile_x_log + (tile_w_log - fit_w) / 2;
                let fit_y = tile_y_log + (tile_h_log - fit_h) / 2;
                Rectangle::<i32, Logical>::new((fit_x, fit_y).into(), (fit_w, fit_h).into())
            }
            _ => tile_log,
        };

        // Selected outline: 3-pixel ring just outside `fit_log` — the
        // outer 2 px are `palette.text_primary`, the inner 1 px is
        // `palette.surface`. Two solid rects stack, the thumbnail / icon
        // covers the centre, and only the 2+1 px perimeter remains visible.
        if selected {
            let palette = ltk::theme_palette();
            let outer = ltk_color_to_rgba8(palette.text_primary);
            let inner = ltk_color_to_rgba8(palette.surface);
            for (offset, color) in [(3, outer), (1, inner)] {
                let w = fit_log.size.w + 2 * offset;
                let h = fit_log.size.h + 2 * offset;
                let r = Rectangle::<i32, Logical>::new(
                    (fit_log.loc.x - offset, fit_log.loc.y - offset).into(),
                    (w, h).into(),
                );
                let phys = r.to_physical_precise_round(scale);
                let tex = self.ensure_solid_rect(renderer, scale, w, h, scale_key, color);
                ForgeElement::add_element_panel_composited(
                    textures,
                    tex,
                    phys.loc,
                    phys,
                    None,
                    scale,
                    panel_alpha,
                    panel_scale,
                    panel_center,
                );
            }
        }

        // Thumbnail (live window texture) preserving aspect.
        let fit_phys = fit_log.to_physical_precise_round(scale);
        let drew_thumbnail = if let Some(tex) = thumbnail.clone() {
            // Scale the texture's *actual* physical geometry to fill the fit
            // rect. Calibrating off the texture geometry (not the logical
            // `thumb_size`) keeps fractionally-scaled clients — whose buffer
            // carries its own `window_scale`, so `geometry()` ≠ size × output
            // scale — from over- or under-shooting the crop, which painted
            // Firefox as a black rectangle.
            let tex_geom = tex.geometry(smithay::utils::Scale::from(scale));
            if tex_geom.size.w > 0 && tex_geom.size.h > 0 {
                let win_scale = fit_phys.size.w as f64 / tex_geom.size.w as f64;
                // The texture carries its own physical location (CSD shadow
                // inset, etc.). RescaleRenderElement scales that location by
                // `win_scale` around (0, 0); subtract the contribution so the
                // texture's natural top-left lands at `fit_phys.loc`.
                let relocate = smithay::utils::Point::<i32, smithay::utils::Physical>::from((
                    fit_phys.loc.x - (tex_geom.loc.x as f64 * win_scale).round() as i32,
                    fit_phys.loc.y - (tex_geom.loc.y as f64 * win_scale).round() as i32,
                ));
                ForgeElement::add_element_panel_composited(
                    textures,
                    tex,
                    relocate,
                    fit_phys,
                    Some(win_scale),
                    scale,
                    panel_alpha,
                    panel_scale,
                    panel_center,
                );
                true
            } else {
                false
            }
        } else {
            false
        };

        if !drew_thumbnail {
            // No live thumbnail yet — fill the tile with a `palette.surface`
            // rectangle and centre the window title (or app_id) on it.
            // Replaces the previous "search the icon cache by app_id"
            // fallback, which never matched apps like Signal whose icon
            // ships under a different name (e.g. "signal-desktop").
            let palette = ltk::theme_palette();
            let surface = ltk_color_to_rgba8(palette.surface);
            let text_c = ltk_color_to_rgba8(palette.text_primary);
            let bg_tex = self.ensure_solid_rect(
                renderer,
                scale,
                fit_log.size.w,
                fit_log.size.h,
                scale_key,
                surface,
            );
            ForgeElement::add_element_panel_composited(
                textures,
                bg_tex,
                fit_phys.loc,
                fit_phys,
                None,
                scale,
                panel_alpha,
                panel_scale,
                panel_center,
            );

            let font_size = (fit_log.size.h as f32 * 0.08).clamp(14.0, 28.0);
            if let Some((tex, tw, th)) =
                self.ensure_label(label, font_size, text_c, renderer, text_renderer, scale)
            {
                let cx_log = fit_log.loc.x + fit_log.size.w / 2;
                let cy_log = fit_log.loc.y + fit_log.size.h / 2;
                let pos_x_phys = (cx_log as f64 * scale) as i32 - tw / 2;
                let pos_y_phys = (cy_log as f64 * scale) as i32 - th / 2;
                let pos = Point::<i32, Physical>::from((pos_x_phys, pos_y_phys));
                let bounds = Rectangle::<i32, Physical>::new(pos, (tw, th).into());
                ForgeElement::add_element_panel_composited(
                    textures,
                    tex,
                    pos,
                    bounds,
                    None,
                    scale,
                    panel_alpha,
                    panel_scale,
                    panel_center,
                );
            }
        }

        // Close button at the top-right of the visible window thumbnail,
        // 5 px in from the top / right edges.
        let close_log = Rectangle::<i32, Logical>::new(
            (
                fit_log.loc.x + fit_log.size.w - CLOSE_BTN_SIZE - CLOSE_BTN_PAD,
                fit_log.loc.y + CLOSE_BTN_PAD,
            )
                .into(),
            (CLOSE_BTN_SIZE, CLOSE_BTN_SIZE).into(),
        );
        if let Some(tex) = self.ensure_close_btn(renderer, scale, scale_key) {
            let close_phys = close_log.to_physical_precise_round(scale);
            ForgeElement::add_element_panel_composited(
                textures,
                tex,
                close_phys.loc,
                close_phys,
                None,
                scale,
                panel_alpha,
                panel_scale,
                panel_center,
            );
        }
        self.close_rects.push(close_log);

        // Title label below the tile (desktop only — mobile carousel skips it).
        if !is_mobile {
            if let Some((tex, tw, th)) =
                self.ensure_label(label, FONT_SIZE, TEXT_COL, renderer, text_renderer, scale)
            {
                let label_x_phys =
                    ((tile_x_log as f64 + (tile_w_log as f64 / 2.0)) * scale) as i32 - tw / 2;
                let label_y_phys = ((tile_y_log + tile_h_log + TEXT_OFFSET) as f64 * scale) as i32;
                let pos = Point::<i32, Physical>::from((label_x_phys, label_y_phys));
                let bounds = Rectangle::<i32, Physical>::new(pos, (tw, th).into());
                ForgeElement::add_element_panel_composited(
                    textures,
                    tex,
                    pos,
                    bounds,
                    None,
                    scale,
                    panel_alpha,
                    panel_scale,
                    panel_center,
                );
            }
        }
    }

    /// Solid-colour rectangle texture cached by `(w, h, scale, rgba)`, used
    /// to paint the selected-tile outline. Square corners + per-frame
    /// reuse keeps the cost flat as the user steps through tiles.
    fn ensure_solid_rect(
        &mut self,
        renderer: &mut GlesRenderer,
        scale: f64,
        w_log: i32,
        h_log: i32,
        scale_key: i32,
        rgba: [u8; 4],
    ) -> RenderTexture {
        let key = (w_log, h_log, scale_key, rgba);
        if let Some(t) = self.solid_rect_cache.get(&key) {
            return t.clone();
        }
        let t = Self::make_rounded_texture(renderer, scale, w_log, h_log, rgba, 0.0);
        self.solid_rect_cache.insert(key, t.clone());
        t
    }

    /// Rasterise the SSD-style `window_button(Close)` once per scale via a
    /// dedicated ltk `UiSurface`, and cache the resulting GL texture. Sharing
    /// one texture across every tile keeps the close button visually in sync
    /// with the title bar without paying per-frame rasterisation.
    fn ensure_close_btn(
        &mut self,
        renderer: &mut GlesRenderer,
        scale: f64,
        scale_key: i32,
    ) -> Option<RenderTexture> {
        if let Some((sk, t)) = &self.close_btn_tex {
            if *sk == scale_key {
                return Some(t.clone());
            }
        }
        let size_px = ((CLOSE_BTN_SIZE as f64) * scale).round().max(1.0) as u32;
        let ui = self
            .close_btn_ui
            .get_or_insert_with(|| ltk::core::UiSurface::new(size_px, size_px));
        if ui.size() != (size_px, size_px) {
            ui.resize(size_px, size_px);
        }
        ui.set_dpi_scale(scale as f32);

        // Wrap the button in a container with the SSD title-bar background
        // colour and a half-side radius so the glyph sits inside a circle
        // that matches the window decoration. `on_press(())` keeps the
        // button in its enabled visual — forge dispatches the actual close
        // via its own click hit-test, so the message is never consumed.
        let bar_bg = ltk::theme::window_controls().bar_bg;
        let tree: ltk::Element<()> = ltk::container(
            ltk::window_button(ltk::WindowButtonKind::Close)
                .size(CLOSE_BTN_SIZE as f32)
                .on_press(()),
        )
        .background(bar_bg)
        .radius(ltk::Corners::all((CLOSE_BTN_SIZE as f32) / 2.0))
        .into();
        let opts = ltk::core::RenderOptions::full_canvas(size_px, size_px)
            .background(ltk::Color::TRANSPARENT);
        let _ = ui.render(&tree, opts);

        let mut rgba = vec![0u8; (size_px as usize) * (size_px as usize) * 4];
        if let ltk::core::Canvas::Software(sc) = ui.canvas() {
            sc.write_to_wayland_buf(&mut rgba, false);
        }
        let tex = Texture::from_buffer(
            renderer,
            scale,
            &rgba,
            size_px as i32,
            size_px as i32,
            false,
        );
        let rt = RenderTexture::new(tex);
        self.close_btn_tex = Some((scale_key, rt.clone()));
        Some(rt)
    }

    /// 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;
        }
        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
    }

    /// Start a touch/pointer drag on the mobile carousel. Tracked even
    /// when only one entry exists so a tap on its close button still
    /// reaches `carousel_drag_end` → `click_at`; horizontal motion is
    /// then clamped to zero in render so the lone tile never scrolls.
    pub fn carousel_drag_begin(
        &mut self,
        pos: smithay::utils::Point<f64, smithay::utils::Logical>,
    ) {
        if self.entries.is_empty() {
            return;
        }
        let now = std::time::Instant::now();
        self.carousel_drag = Some(CarouselDrag {
            start_pos: pos,
            last_pos: pos,
            last_time: now,
            start_offset: self.carousel_offset,
            moved_px: 0.0,
            velocity: 0.0,
            axis: CarouselAxis::Undecided,
            close_idx: None,
        });
        self.carousel_velocity = 0.0;
        self.carousel_snap = None;
        self.close_drag = None;
    }

    /// Hit-test `point` against the last-rendered tile rects, returning the
    /// tile index it lands on (skipping the zero-size off-screen tiles).
    fn tile_index_at(
        &self,
        point: smithay::utils::Point<f64, smithay::utils::Logical>,
    ) -> Option<usize> {
        let (px, py) = (point.x as i32, point.y as i32);
        self.tile_rects.iter().position(|r| {
            r.size.w > 0
                && r.size.h > 0
                && px >= r.loc.x
                && px < r.loc.x + r.size.w
                && py >= r.loc.y
                && py < r.loc.y + r.size.h
        })
    }

    /// Update the carousel from a touch/pointer motion sample. A horizontal
    /// drag pans the row; a vertical drag lifts the grabbed tile toward a
    /// close (mirrors the overview's swipe-up-to-close).
    pub fn carousel_drag_motion(
        &mut self,
        pos: smithay::utils::Point<f64, smithay::utils::Logical>,
    ) {
        let Some(d) = self.carousel_drag.as_mut() else {
            return;
        };
        let now = std::time::Instant::now();
        let dx = (pos.x - d.last_pos.x) as f32;
        let dy = (pos.y - d.last_pos.y) as f32;
        let dt = now.duration_since(d.last_time).as_secs_f32().max(0.001);
        // Low-pass the per-sample velocity so a noisy last sample doesn't
        // dominate the inertia launch.
        d.velocity = 0.7 * d.velocity + 0.3 * (dx / dt);
        d.moved_px += dx.abs() + dy.abs();
        d.last_pos = pos;
        d.last_time = now;
        let total_dx = (pos.x - d.start_pos.x) as f32;
        let total_dy = (pos.y - d.start_pos.y) as f32;
        if d.axis == CarouselAxis::Undecided && d.moved_px > CAROUSEL_AXIS_LOCK_PX {
            d.axis = if total_dy.abs() > total_dx.abs() {
                CarouselAxis::Vertical
            } else {
                CarouselAxis::Horizontal
            };
        }
        let axis = d.axis;
        let mut close_idx = d.close_idx;
        let start_pos = d.start_pos;
        let start_offset = d.start_offset;
        // Resolve the grabbed tile once, while the rects are still at rest —
        // re-hit-testing each frame against the lifting tile makes it flicker.
        if axis == CarouselAxis::Vertical && close_idx.is_none() {
            close_idx = self.tile_index_at(start_pos);
            if let Some(d) = self.carousel_drag.as_mut() {
                d.close_idx = close_idx;
            }
        }
        match axis {
            CarouselAxis::Vertical => {
                self.close_drag = close_idx.map(|i| (i, total_dy.min(0.0)));
            }
            _ => {
                self.close_drag = None;
                self.carousel_offset = start_offset + total_dx;
            }
        }
    }

    /// End the drag. Returns the hit-test result at the press position when
    /// the release was a tap (drag distance below the threshold) so the
    /// caller can route Activate / Close / Dismiss through its normal
    /// dispatch. Returns `None` when the gesture was a real drag — inertia
    /// then takes over.
    pub fn carousel_drag_end(&mut self) -> Option<AppSwitcherClick> {
        let Some(d) = self.carousel_drag.take() else {
            return None;
        };
        if d.moved_px <= CAROUSEL_TAP_PX {
            return Some(self.click_at(d.start_pos));
        }
        if d.axis == CarouselAxis::Vertical {
            let close = self.close_drag.take();
            return match close {
                Some((idx, dy)) if dy <= -CAROUSEL_CLOSE_COMMIT_PX => {
                    Some(AppSwitcherClick::Close(idx))
                }
                // Not lifted far enough: snap the tile back, close nothing.
                _ => None,
            };
        }
        self.carousel_velocity = d.velocity;
        self.carousel_last_tick = Some(std::time::Instant::now());
        None
    }

    /// Advance inertia / snap state by the wall-clock delta since the last
    /// tick. Called once per frame by the host while [`animating`] is true.
    pub fn tick_carousel(&mut self) {
        if self.carousel_drag.is_some() {
            return;
        }
        let now = std::time::Instant::now();
        let dt = self
            .carousel_last_tick
            .map(|t| now.duration_since(t).as_secs_f32().min(0.05))
            .unwrap_or(0.016);
        self.carousel_last_tick = Some(now);

        if self.carousel_velocity.abs() > CAROUSEL_SNAP_VELOCITY {
            self.carousel_offset += self.carousel_velocity * dt;
            let decay = (-CAROUSEL_FRICTION * dt).exp();
            self.carousel_velocity *= decay;
        } else if self.carousel_velocity.abs() > f32::EPSILON {
            // Below the snap threshold — flag the render to adopt the
            // nearest tile as the new selection, then let its snap-ease
            // finish positioning the offset.
            self.carousel_velocity = 0.0;
            self.carousel_last_tick = None;
            self.carousel_settling = true;
        }
    }
}

/// Blocking helper run inside a background thread: reads the file and decodes
/// it to an RGBA byte buffer. Returns `None` on any error.
fn ltk_color_to_rgba8(c: ltk::Color) -> [u8; 4] {
    [
        (c.r.clamp(0.0, 1.0) * 255.0).round() as u8,
        (c.g.clamp(0.0, 1.0) * 255.0).round() as u8,
        (c.b.clamp(0.0, 1.0) * 255.0).round() as u8,
        (c.a.clamp(0.0, 1.0) * 255.0).round() as u8,
    ]
}

/// Cover-scale the decoded lockscreen rgba to the panel size and mask it to the
/// panel's rounded silhouette, returning the composed rgba. Runs on a worker
/// thread (the Lanczos resize is the expensive part); the caller uploads the
/// result to the GPU.
fn compose_panel_bg_rgba(
    rgba: Vec<u8>,
    img_w: u32,
    img_h: u32,
    panel_w_px: u32,
    panel_h_px: u32,
    radius_px: f32,
) -> Vec<u8> {
    let mut canvas = SoftCanvas::new(panel_w_px, panel_h_px);
    canvas.clear();
    // Rounded fallback fill: any transparency in the image falls back to the
    // panel's navy rather than leaving holes in the silhouette.
    let bg = Color::rgba8(
        PANEL_BG_FALLBACK[0],
        PANEL_BG_FALLBACK[1],
        PANEL_BG_FALLBACK[2],
        PANEL_BG_FALLBACK[3],
    );
    canvas.fill_rect(0.0, 0.0, panel_w_px as f32, panel_h_px as f32, bg, radius_px);

    // Cover-scale: image fills both axes preserving aspect; the overflow on the
    // long axis ends up in the corner band the mask wipes anyway.
    let cover = (panel_w_px as f64 / img_w as f64).max(panel_h_px as f64 / img_h as f64);
    let scaled_w = (img_w as f64 * cover).round() as u32;
    let scaled_h = (img_h as f64 * cover).round() as u32;
    if let Some(img_buf) = image::RgbaImage::from_raw(img_w, img_h, rgba) {
        let scaled = image::imageops::resize(
            &img_buf,
            scaled_w.max(1),
            scaled_h.max(1),
            image::imageops::FilterType::Lanczos3,
        );
        let off_x = (panel_w_px as i32 - scaled_w as i32) / 2;
        let off_y = (panel_h_px as i32 - scaled_h as i32) / 2;
        canvas.draw_image_rgba(scaled.as_raw(), scaled_w, scaled_h, off_x, off_y);
    }

    canvas.mask_outside_rounded_rect(radius_px);
    canvas.data().to_vec()
}

fn load_bg_pixels(path: std::path::PathBuf) -> Option<(Vec<u8>, u32, u32)> {
    let is_svg = path
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| e.eq_ignore_ascii_case("svg"))
        .unwrap_or(false);
    let (rgba, w, h) = if is_svg {
        let bytes = match std::fs::read(&path) {
            Ok(b) => b,
            Err(_) => {
                tracing::warn!("app_switcher: failed to read panel bg {}", path.display());
                return None;
            }
        };
        let (rgba_arc, w, h) = match ltk::decode_svg_bytes(&bytes, PANEL_BG_RASTER_SIZE) {
            Some(v) => v,
            None => {
                tracing::warn!("app_switcher: SVG decode failed for {}", path.display());
                return None;
            }
        };
        ((*rgba_arc).clone(), w, h)
    } else {
        let img = match image::open(&path) {
            Ok(i) => i,
            Err(_) => {
                tracing::warn!("app_switcher: image::open failed for {}", path.display());
                return None;
            }
        };
        let rgba = img.into_rgba8();
        let (w, h) = rgba.dimensions();
        (rgba.into_raw(), w, h)
    };

    // Pre-blur the panel background so the switcher reads as a frosted
    // backdrop instead of a sharp wallpaper. Done once on the decode
    // thread; the GPU-side composition stays free of any per-frame blur
    // shader passes.
    let blurred = match image::RgbaImage::from_raw(w, h, rgba) {
        Some(buf) => image::imageops::blur(&buf, 18.0),
        None => return None,
    };
    Some((blurred.into_raw(), w, h))
}

#[cfg(test)]
mod tests {
    use super::*;
    use smithay::utils::{Point, Rectangle, Size};

    fn entry(id: u32, app: &str, title: &str) -> WindowEntry {
        WindowEntry {
            surface_id: id,
            object_id: None,
            app_id: app.into(),
            title: title.into(),
            thumbnail: None,
            thumb_size: None,
        }
    }

    #[test]
    fn begin_selects_next_window() {
        let mut s = AppSwitcher::new();
        s.begin(
            vec![entry(1, "a", ""), entry(2, "b", ""), entry(3, "c", "")],
            1,
            None,
        );
        assert!(s.visible);
        assert_eq!(s.selected, 1);
    }

    #[test]
    fn shift_tab_selects_previous_window() {
        let mut s = AppSwitcher::new();
        s.begin(
            vec![entry(1, "a", ""), entry(2, "b", ""), entry(3, "c", "")],
            -1,
            None,
        );
        assert!(s.visible);
        assert_eq!(s.selected, 2); // -1 mod 3 == 2
    }

    #[test]
    fn cycle_wraps_around() {
        let mut s = AppSwitcher::new();
        s.begin(vec![entry(1, "a", ""), entry(2, "b", "")], 1, None);
        s.step(1); // 0
        s.step(1); // 1
        assert_eq!(s.selected, 1);
        s.step(-1); // 0
        s.step(-1); // 1
        assert_eq!(s.selected, 1);
    }

    #[test]
    fn empty_does_not_show() {
        let mut s = AppSwitcher::new();
        s.begin(vec![], 1, None);
        assert!(!s.visible);
    }

    #[test]
    fn commit_selects_entry_and_hides() {
        let mut s = AppSwitcher::new();
        s.begin(vec![entry(10, "a", ""), entry(20, "b", "")], 1, None);
        // selected = 1 → entry 20
        assert_eq!(s.entries[s.selected].surface_id, 20);
        // commit_object_id() is the production commit path; it starts the
        // fade-out so the switcher stops taking input immediately
        // (is_active false) even though `visible` stays true until the
        // animation finishes.
        let _ = s.commit_object_id();
        assert!(!s.is_active());
        // Driving the fade-out to completion tears the state down.
        s.finish_hide();
        assert!(!s.visible);
    }

    #[test]
    fn cancel_clears_state() {
        let mut s = AppSwitcher::new();
        s.begin(vec![entry(1, "a", ""), entry(2, "b", "")], 0, None);
        assert!(s.is_active());
        s.cancel();
        // cancel() starts the fade-out; no longer active, fade in progress.
        assert!(!s.is_active());
        // Completing the fade-out clears the entries.
        s.finish_hide();
        assert!(!s.visible);
        // With the entries cleared, committing yields nothing.
        assert_eq!(s.commit_object_id(), None);
    }

    #[test]
    fn remove_at_adjusts_selection() {
        let mut s = AppSwitcher::new();
        s.begin(
            vec![entry(1, "a", ""), entry(2, "b", ""), entry(3, "c", "")],
            0,
            None,
        );
        // step(2) explicitly to land on index 2
        s.step(2);
        assert_eq!(s.selected, 2);
        s.remove_at(2);
        assert_eq!(s.entries.len(), 2);
        assert_eq!(s.selected, 1); // clamped
    }

    #[test]
    fn remove_last_cancels_switcher() {
        let mut s = AppSwitcher::new();
        s.begin(vec![entry(1, "a", "")], 0, None);
        assert!(s.is_active());
        s.remove_at(0);
        assert!(!s.is_active());
    }

    #[test]
    fn click_at_returns_dismiss_when_outside() {
        let s = AppSwitcher::new();
        // Switcher not visible
        let click = s.click_at(Point::from((0.0, 0.0)));
        assert_eq!(click, AppSwitcherClick::Dismiss);
    }

    #[test]
    fn click_at_close_takes_precedence_over_tile() {
        let mut s = AppSwitcher::new();
        s.begin(vec![entry(1, "a", ""), entry(2, "b", "")], 0, None);
        // Manually populate hit rects (render() would normally do this).
        s.tile_rects
            .push(Rectangle::new(Point::from((0, 0)), Size::from((100, 100))));
        s.close_rects
            .push(Rectangle::new(Point::from((80, 0)), Size::from((20, 20))));
        // Click in the overlap (close button area)
        assert_eq!(
            s.click_at(Point::from((85.0, 10.0))),
            AppSwitcherClick::Close(0)
        );
        // Click in tile but not close
        assert_eq!(
            s.click_at(Point::from((10.0, 50.0))),
            AppSwitcherClick::Activate(0)
        );
        // Clear the open-grace window: right after begin(), clicks outside
        // the tiles are swallowed (NoOp) so the panel isn't dismissed by the
        // same gesture that opened it. Past the grace, an outside click
        // dismisses.
        s.opened_at = None;
        // Click outside everything
        assert_eq!(
            s.click_at(Point::from((500.0, 500.0))),
            AppSwitcherClick::Dismiss
        );
    }
}
