// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel:
// WM_MOUSEMOVE (hover + tooltip timing + the live drag), drop-target/gesture
// classification, cursor cues, button-up drop dispatch, and right-click menu routing.
// Its PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test), with
// core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only the
// live rects, modifier state, and side effects. Per-mouse-move work stays plain
// free-function calls — no interface, no virtual dispatch.
//
// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called
// directly here; REAPER SDK types arrive via panel_state.h.

#include <cstdlib>  // std::abs (drag threshold)
#include <string>
#include <vector>

#include "shell/panel/panel_state.h"

#include "shell/bank_ops/bank_ops.h"             // persistBankOp — shared undo-block wrapper
#include "shell/actions/drag_out_win.h"          // OLE / SWELL drag-out initiation seam
#include "shell/actions/instrument_drop_win.h"   // resolveFxDropTarget / performInstrumentDrop

namespace reasampler::panel {

constexpr int kDragThreshold = 5;  // px the pointer must move to begin a drag

namespace {

// Resolves the drop target under client (x, y) during a drag, updating dropKind /
// dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank;
// anywhere else -> none.
void updateDropTarget(int x, int y) {
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;

    g_panel.dropKind = DropKind::None;
    g_panel.dropBankId.clear();

    if (banksShown()) {
        const RECT br = banksRegionRect(w, h);
        const TabStripRect strip = banksTabStripRect(br);
        const std::vector<const Bank*> tabs = namedBanks();
        const TabHit hit = hitTestTabStrip(x, y, strip,
                                           static_cast<int>(tabs.size()), kTabSpec,
                                           g_panel.tabScroll);
        if (hit.kind == TabHitKind::Tab) {
            g_panel.dropKind = DropKind::Tab;
            g_panel.dropBankId = tabs[static_cast<std::size_t>(hit.index)]->id;
            return;
        }
        // Tab takes precedence; otherwise the whole grid is a drop zone for the shown bank.
        if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) {
            if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) {
                g_panel.dropKind = DropKind::BanksRegion;
                g_panel.dropBankId = g_panel.shownBankId;
                return;
            }
        }
    }
    if (poolShown()) {
        const RECT pr = poolRegionRect(w, h);
        const RECT grid = regionGridRect(pr, false);
        if (x >= grid.left && x < grid.right && y >= grid.top && y < grid.bottom) {
            g_panel.dropKind = DropKind::PoolRegion;
            return;
        }
    }
}

// The destination bank id under the current drop target (pool id for PoolRegion; the
// tab/shown-bank id for Tab/BanksRegion; "" for no target).
std::string dropTargetBankId() {
    switch (g_panel.dropKind) {
        case DropKind::PoolRegion: return std::string(kPoolBankId);
        case DropKind::Tab:
        case DropKind::BanksRegion: return g_panel.dropBankId;
        case DropKind::None: return {};
    }
    return {};
}

// Classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop)
// the target slot. Call AFTER updateDropTarget so dropKind/dropBankId are current.
// card_drag::decideCardGesture owns the precedence (other-bank -> move/copy; same-bank
// grid -> reorder/replace); this only supplies the region verdict, target slot +
// occupancy, and modifier state (the OS-drag-out boundary is handled earlier, in
// onMouseMove, so here the pointer is always inside).
void classifyCardDrag(int x, int y) {
    g_panel.cardGesture = CardGesture::None;
    g_panel.dragTargetSlot = -1;

    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;
    const PanelClientRect client{cr.left, cr.top, w, h};

    const std::string destBank = dropTargetBankId();
    DragModifiers mods;
    mods.ctrl = ctrlDown();
    mods.alt  = altDown();

    if (!destBank.empty() && destBank == g_panel.dragSourceBankId) {
        // Same-bank grid: a reorder/replace target. Resolve the slot + occupancy in the
        // SOURCE bank's display. computeSlotRectsForDrop adds one trailing row past
        // maxSlot so a drop beyond the last card resolves to a valid slot, not a -1 miss.
        mods.region = DropRegion::SameBankGrid;
        const bool isBanks = g_panel.dragSourceRegion == Region::Banks;
        const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
        const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion);
        const RECT grid = regionGridRect(region, isBanks);
        const int gridW = grid.right - grid.left;
        const std::vector<SlotCellRect> dropRects =
            computeSlotRectsForDrop(disp.bank ? disp.bank->slots.maxSlot() : -1,
                                    gridW, kGrid);
        // Translate the drop rects to client space (matching regionDisplay's translation).
        std::vector<SlotCellRect> dropRectsClient = dropRects;
        for (SlotCellRect& r : dropRectsClient) { r.x += grid.left; r.y += grid.top; }
        const int slot = hitTestSlot(x, y, dropRectsClient);
        mods.targetSlot   = slot;
        mods.slotOccupied = slot >= 0 && !disp.idAtSlot(slot).empty();
        g_panel.dragTargetSlot = slot;
    } else if (!destBank.empty()) {
        mods.region = DropRegion::OtherBankOrTab;  // move/copy to a different bank/tab
    } else {
        mods.region = DropRegion::DeadSpace;  // header/footer/gap — a no-op drop
    }

    const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
    g_panel.cardGesture = decideCardGesture(x, y, client, st, mods);
}

// Maps the pure cursor cue to a SWELL stock cursor (vendor/WDL/WDL/swell/swell-types.h,
// mirroring Win32 OCR_*) and sets it; the cue decision is pure (card_drag::cursorForGesture).
// Copy has no stock cross-platform cursor, so IDC_UPARROW is the closest distinct stock cue
// (a bespoke resource was deliberately not added). OsDragOut leaves the cursor alone — the
// OS drag loop owns it once handed off, and this branch is never actually seen.
void applyDragCursor(CardGesture g) {
    const char* idc = IDC_ARROW;
    switch (cursorForGesture(g)) {
        case CursorCue::Reorder:   idc = IDC_SIZEALL; break;
        case CursorCue::Move:      idc = IDC_HAND;    break;
        case CursorCue::Copy:      idc = IDC_UPARROW; break;
        case CursorCue::Replace:   idc = IDC_SIZEWE;  break;
        case CursorCue::OsDragOut: return;  // OS drag owns the cursor; do not fight it
        case CursorCue::Default:   idc = IDC_ARROW;   break;
    }
    SetCursor(LoadCursor(nullptr, idc));
}

// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback,
// mirroring handleClick's precedence exactly (so the element that lights on hover is
// the one a click would hit). Returns HoverKind::None for the grid / dead space (the
// grid cells carry their own selection/focus chrome, not a kit hover surface).
Hover resolveHover(int x, int y) {
    if (!g_panel.hwnd) return Hover{};
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;

    // TOP toolbar, then footer, then BOTTOM toolbar, matching handleClick's precedence.
    {
        const MenuButtonRect mb = topMenuButtonRect(w);
        if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1};
        const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows());
        if (hit >= 0) return Hover{HoverKind::TopBarButton, hit};
    }
    {
        const int seg = footerToggleSegmentHit(x, y, w, h);
        if (seg >= 0) return Hover{HoverKind::ModeSegment, seg};
        const FooterBarLayout fb = footerBarLayoutFor(w, h);
        if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1};
        const ButtonRect pb = pruneButtonRectFor(w, h);
        if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1};
    }
    {
        const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows());
        if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit};
    }
    if (poolShown()) {
        const RECT pr = poolRegionRect(w, h);
        const RECT ftb = fullHtBtnRect(pr);
        if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom)
            return Hover{HoverKind::FullHtPool, -1};
    }
    if (banksShown()) {
        const RECT br = banksRegionRect(w, h);
        const RECT ftb = fullHtBtnRect(br);
        if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom)
            return Hover{HoverKind::FullHtBanks, -1};
        const RECT cb = createBtnRect(br);
        if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom)
            return Hover{HoverKind::CreateBank, -1};
        const TabStripRect strip = banksTabStripRect(br);
        const std::vector<const Bank*> tabs = namedBanks();
        const TabHit hit = hitTestTabStrip(x, y, strip, static_cast<int>(tabs.size()),
                                           kTabSpec, g_panel.tabScroll);
        if (hit.kind == TabHitKind::Tab) return Hover{HoverKind::Tab, hit.index};
    }
    return Hover{};
}

// Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no
// per-move jank). A hover CHANGE also resets the tooltip timer (hoverSinceTick) and hides
// any shown tooltip, so the tooltip only appears after the pointer rests kTooltipDelayMs
// on ONE element (applied by the poll tick in maybeShowTooltip); a move within the SAME
// element leaves the timer running.
void updateHover(int x, int y) {
    const Hover next = resolveHover(x, y);
    if (next != g_panel.hovered) {
        g_panel.hovered = next;
        g_panel.hoverSinceTick = GetTickCount();
        if (g_panel.tooltipShown) { g_panel.tooltipShown = false; }
        invalidatePanel();
    }
}

}  // namespace

// Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past
// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from
// the OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated
// timer; updateHover resets the timer on every move, so a moving pointer never trips it.
void maybeShowTooltip() {
    if (g_panel.tooltipShown) return;
    const HoverKind k = g_panel.hovered.kind;
    if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return;
    const unsigned int now = GetTickCount();
    if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) {
        g_panel.tooltipShown = true;
        invalidatePanel();
    }
}

void onMouseMove(int x, int y) {
    // Hover feedback: resolve + repaint-on-change, but NOT during a drag (the drag owns
    // the visual feedback then — a drop-target highlight, not a hover).
    if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y);

    if (g_panel.dragArmed && !g_panel.dragging) {
        if (std::abs(x - g_panel.dragStartX) > kDragThreshold ||
            std::abs(y - g_panel.dragStartY) > kDragThreshold) {
            // Threshold crossed — begin the drag. Snapshot the payload NOW.
            g_panel.dragging = true;
            g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion);
            g_panel.dragSampleIds = focusedSelectionIds();
            // The single card actually grabbed = the focus ordinal's id — the in-grid
            // reorder/replace subject (see onLBtnUp), distinct from the multi-select
            // move/copy payload in dragSampleIds.
            {
                const RegionDisplay disp = focusedDisplay();
                const int f = g_panel.selection.focus;
                g_panel.dragPrimaryId =
                    (f >= 0 && f < disp.occupiedCount())
                        ? disp.orderedIds[static_cast<std::size_t>(f)] : std::string{};
            }
            g_panel.hovered = Hover{};  // clear hover — the drag owns the visual feedback now
            g_panel.tooltipShown = false;  // a drag never shows a tooltip
            // SetCapture was already called at drag-arm time (handleClick); no re-capture needed.
        }
    }
    if (g_panel.dragging) {
        // Inside the client rect it stays the internal bank-to-bank drag. Once it LEAVES,
        // drag_out::decideGesture splits three ways: single-capture over REAPER's OWN UI ->
        // InstrumentDrop; multi-capture or fully outside REAPER -> OsDrag; inside -> Internal.
        RECT cr{};
        GetClientRect(g_panel.hwnd, &cr);
        const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
        const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom);

        DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
        st.singleCapture = (g_panel.dragSampleIds.size() == 1);

        // Only resolved OUTSIDE the client rect and for a single-capture payload, so the SDK
        // hit-test costs nothing on the common internal-drag path.
        FxDropTarget fx;
        if (!inside && st.singleCapture) {
            POINT sp{x, y};
            ClientToScreen(g_panel.hwnd, &sp);
            fx = resolveFxDropTarget(sp.x, sp.y);
            st.overReaperUi = fx.overReaperUi;
        }

        const DragGesture gesture = decideGesture(x, y, client, st);

        if (gesture == DragGesture::InstrumentDrop) {
            // Track the FX hotspot for the release. Unlike OsDrag this does NOT hand off to
            // a modal OS loop, so the internal-drag capture stays alive; clear any bank
            // drop-target highlight so the panel doesn't paint that cue too.
            g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr;
            g_panel.dropKind = DropKind::None;
            g_panel.dropBankId.clear();
            invalidatePanel();
            return;
        }

        // Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target.
        g_panel.instrumentDropTrack = nullptr;

        if (gesture == DragGesture::OsDrag) {
            if (g_panel.dragOsHandoffBlocked) {
                // Already known un-hand-off-able for this gesture (empty/unresolvable payload,
                // or the OS wasn't ready) — skip the fs::exists work and the readiness probe on
                // every move; keep the drag alive with no drop-target highlight.
                g_panel.dropKind = DropKind::None;
                g_panel.dropBankId.clear();
                invalidatePanel();
                return;
            }

            // Resolve the payload to existing on-disk paths BEFORE tearing down internal
            // drag state (the resolver reads dragSourceBankId / dragSampleIds), then let the
            // pure rule couple the two side effects: an unresolvable payload must leave the
            // internal drag intact rather than wind it down for a hand-off that never runs —
            // a half-torn-down drag reads to the user as "the drag did nothing, try again".
            // canInitiateDragOut extends the same coupling to the OS-readiness failure modes
            // (OLE unavailable, HDROP build failure) that the pure decision cannot see.
            const std::vector<std::string> paths = resolveDragPathsForOs();
            const ui::OsHandoff handoff = ui::decideOsHandoff(paths);
            if (!handoff.handOffToOs || !canInitiateDragOut(paths)) {
                g_panel.dragOsHandoffBlocked = true;
                g_panel.dropKind = DropKind::None;
                g_panel.dropBankId.clear();
                invalidatePanel();
                return;
            }

            // DoDragDrop runs its own modal loop and takes over mouse capture, so the internal
            // drag must be fully wound down first.
            if (GetCapture() == g_panel.hwnd) ReleaseCapture();
            resetDragState();
            invalidatePanel();

            initiateDragOut(g_panel.hwnd, paths);  // COPY-ONLY; blocking on Windows
            return;
        }
        // Inside the client: classify the in-grid gesture (reorder/replace vs move/copy) and
        // reflect it as a cursor cue. updateDropTarget first so dropKind/dropBankId are
        // current for classifyCardDrag's same-vs-other-bank decision.
        updateDropTarget(x, y);
        classifyCardDrag(x, y);
        applyDragCursor(g_panel.cardGesture);
        invalidatePanel();
    }
}

// In-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap-
// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the
// pure BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z. A no-op reorder
// opens no undo point. Selection reasons over slot order, so it is cleared after.
namespace {

void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) {
    if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return;
    if (!book()->reorderSample(id, bankId, targetSlot)) return;  // rejected/no-op: no undo point
    persistBankOp(*g_panel.session, "ReaSampler: reorder sample");
    g_panel.selection = Selection{};
    invalidatePanel();
}

// Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is
// removed from the bank's index (index-only, file untouched — pool guard enforced in the
// pure BankBook::replaceSample). Rejected = a true NO-OP: no fallback insert, no undo point.
void doReplaceDrop(const std::string& newId, const std::string& oldId,
                   const std::string& bankId) {
    if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return;
    if (!book()->replaceSample(newId, oldId, bankId)) return;  // pool-guard reject: NO-OP
    persistBankOp(*g_panel.session, "ReaSampler: replace sample");
    g_panel.selection = Selection{};
    invalidatePanel();
}

}  // namespace

// Clears all drag-state fields to their resting values. Called from every exit path
// (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields
// stays consistent across all four sites.
void resetDragState() {
    g_panel.dragArmed = false;
    g_panel.dragging = false;
    g_panel.dropKind = DropKind::None;
    g_panel.dropBankId.clear();
    g_panel.cardGesture = CardGesture::None;
    g_panel.dragTargetSlot = -1;
    g_panel.dragPrimaryId.clear();
    g_panel.instrumentDropTrack = nullptr;
    g_panel.dragOsHandoffBlocked = false;
}

// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides:
//   * Reorder / Replace -> in-grid, within the source bank; one Ctrl-Z each.
//   * Move / Copy       -> the cross-bank transfer (Ctrl = copy).
//   * None              -> a drop over dead space / the source-bank gap = no-op.
// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove.
void onLBtnUp(int x, int y) {
    if (g_panel.dragging) {
        // Drop-and-load: a release over a valid FX hotspot instantiates a ReaSampler 9000 on
        // that track preloaded with the dragged capture — NOT a bank move, NOT an OS drag,
        // NEVER a timeline insert. Takes priority over the in-grid / cross-bank drop.
        // Single-capture only, so dragSampleIds.front() is the capture.
        //
        // Re-resolve at the RELEASE point rather than trusting only the hover-tracked target:
        // WM_MOUSEMOVE is coalesced, so a fast drag onto a dense surface (an FX chain row, a
        // container) can release over a hotspot no processed move ever reported. Gated on
        // !inside exactly like onMouseMove's live resolve — GetThingFromPoint can return a
        // track+FX hit at a release point that is still inside the panel's own client rect, and
        // an in-grid release must always go through the reorder/replace/move/copy path below,
        // never be reinterpreted as an FX add. Strictly additive otherwise — a release-point
        // miss falls back to the tracked target, so the hover-then-release-on-the-FX-button
        // path is untouched.
        const bool singleCapture = g_panel.dragSampleIds.size() == 1;
        MediaTrack* dropTrack = g_panel.instrumentDropTrack;
        RECT cr{};
        GetClientRect(g_panel.hwnd, &cr);
        const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom);
        if (!inside && singleCapture) {
            POINT sp{x, y};
            ClientToScreen(g_panel.hwnd, &sp);
            const FxDropTarget fx = resolveFxDropTarget(sp.x, sp.y);
            if (fx.valid()) dropTrack = fx.track;
        }
        if (!inside && dropTrack && singleCapture) {
            const std::string sampleId = g_panel.dragSampleIds.front();
            performInstrumentDrop(dropTrack, buildInstrumentDropPreset(sampleId));
            // Read-only over the bank + arrange: the only mutations are the new FX instance +
            // its state (both undoable in performInstrumentDrop).
        } else {
            updateDropTarget(x, y);
            classifyCardDrag(x, y);  // re-resolve at the drop point (modifiers may have changed)
            const CardGesture g = g_panel.cardGesture;

            if (g == CardGesture::Reorder) {
                doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId,
                              g_panel.dragTargetSlot);
            } else if (g == CardGesture::Replace) {
                // Replace targets the OCCUPANT of the target slot with the single grabbed card.
                const bool isBanks = g_panel.dragSourceRegion == Region::Banks;
                const int w = cr.right - cr.left, h = cr.bottom - cr.top;
                const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
                const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion);
                const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot);
                // Replace only makes sense for a single grabbed card over a DIFFERENT occupant.
                if (!occupant.empty() && occupant != g_panel.dragPrimaryId)
                    doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId);
            } else if (g == CardGesture::Move || g == CardGesture::Copy) {
                const std::string destId = dropTargetBankId();
                if (!destId.empty() && destId != g_panel.dragSourceBankId &&
                    !g_panel.dragSampleIds.empty()) {
                    transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId,
                                    /*copy=*/g == CardGesture::Copy);
                }
            }
        }
        // CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None).
        SetCursor(LoadCursor(nullptr, IDC_ARROW));  // restore the arrow on drop
        if (GetCapture() == g_panel.hwnd) ReleaseCapture();
    } else if (g_panel.dragArmed) {
        // Press-release on a selected cell with no drag: treat as a plain click that
        // collapses the multi-selection to the pressed cell (standard behavior).
        // Release capture acquired at arm time (handleClick) — drag never started.
        if (GetCapture() == g_panel.hwnd) ReleaseCapture();
        const BankModel* idx = indexForRegion(g_panel.focusedRegion);
        const int count = idx ? static_cast<int>(idx->size()) : 0;
        const int focus = g_panel.selection.focus;
        if (focus >= 0)
            g_panel.selection = applyClick(g_panel.selection, focus, false, false, count);
    }
    resetDragState();
    invalidatePanel();
}

// A right-click: on a named tab -> the tab management menu; on a grid cell of the
// focused region with a selection -> the move/copy menu.
void handleRightClick(int x, int y) {
    RECT cr{};
    GetClientRect(g_panel.hwnd, &cr);
    const int w = cr.right - cr.left, h = cr.bottom - cr.top;

    if (banksShown()) {
        const RECT br = banksRegionRect(w, h);
        const TabStripRect strip = banksTabStripRect(br);
        const std::vector<const Bank*> tabs = namedBanks();
        const TabHit hit = hitTestTabStrip(x, y, strip,
                                           static_cast<int>(tabs.size()), kTabSpec,
                                           g_panel.tabScroll);
        if (hit.kind == TabHitKind::Tab) {
            POINT pt{x, y};
            ClientToScreen(g_panel.hwnd, &pt);
            showTabMenu(pt.x, pt.y, tabs[static_cast<std::size_t>(hit.index)]->id);
            return;
        }
    }

    // Grid selection menu (move/copy). Only when the right-click lands in the focused
    // region's grid and there is a selection.
    Region reg = Region::Pool;
    if (regionAt(x, y, reg) && reg == g_panel.focusedRegion &&
        !g_panel.selection.empty()) {
        POINT pt{x, y};
        ClientToScreen(g_panel.hwnd, &pt);
        showSelectionMenu(pt.x, pt.y);
    }
}

}  // namespace reasampler::panel
