
  -- rOrbs: click-cast — validate spells, icon, spellbook picker (no passives).

  local addon, ns = ...
  local L = ns.L or {}
  local tinsert = table.insert

  ns.ClickCastSpellUI = ns.ClickCastSpellUI or {}

  local spellListCache = nil
  local listDirty = true

  local SPELL_TYPE_FLYOUT = Enum.SpellBookItemType and Enum.SpellBookItemType.Flyout or 2
  local SPELL_TYPE_FUTURE = Enum.SpellBookItemType and Enum.SpellBookItemType.FutureSpell or 3

  local ev = CreateFrame("Frame")
  ev:RegisterEvent("SPELLS_CHANGED")
  ev:RegisterEvent("LEARNED_SPELL_IN_SKILL_LINE")
  ev:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
  ev:SetScript("OnEvent", function()
    listDirty = true
  end)

  function ns.ClickCastSpellUI.InvalidateSpellBookCache()
    listDirty = true
  end

  local function trim(s)
    if not s or type(s) ~= "string" then return "" end
    return (s:gsub("^%s+", ""):gsub("%s+$", ""))
  end

  --- @return boolean validEmptyOrSpell, number|nil spellID, string|nil name, number|nil iconFileID
  function ns.ClickCastSpellUI.ValidateSpellInput(text)
    local t = trim(text)
    if t == "" then
      return true, nil, nil, nil
    end
    if not C_Spell or not C_Spell.GetSpellInfo then
      return false, nil, nil, nil
    end
    local n = tonumber(t)
    if n then
      local si = C_Spell.GetSpellInfo(n)
      if si and si.name then
        return true, si.spellID or n, si.name, si.iconID
      end
      return false, nil, nil, nil
    end
    local si = C_Spell.GetSpellInfo(t)
    if si and si.name then
      return true, si.spellID, si.name, si.iconID
    end
    return false, nil, nil, nil
  end

  --- Stored DB format: prefer spell ID as string.
  function ns.ClickCastSpellUI.StoredTextFromResolved(spellID, _name)
    if spellID then
      return tostring(spellID)
    end
    return ""
  end

  local function walkSkillLine(skillIndex, seen, list)
    if not C_SpellBook or not C_SpellBook.GetSpellBookSkillLineInfo then return end
    local sl = C_SpellBook.GetSpellBookSkillLineInfo(skillIndex)
    if not sl or sl.shouldHide then return end
    local offset = sl.itemIndexOffset or 0
    local num = sl.numSpellBookItems or 0
    for j = offset + 1, offset + num do
      local info = C_SpellBook.GetSpellBookItemInfo(j, Enum.SpellBookSpellBank.Player)
      if info then
        if info.itemType == SPELL_TYPE_FLYOUT or info.itemType == SPELL_TYPE_FUTURE then
          -- Flyout / not-yet-learned spell — skip.
        elseif info.spellID then
          if not info.isPassive and not info.isOffSpec then
            local sid = info.spellID
            if not seen[sid] then
              seen[sid] = true
              local name = info.name
              local icon = info.iconID
              if (not name or name == "") and C_Spell.GetSpellInfo then
                local si = C_Spell.GetSpellInfo(sid)
                if si then
                  name = si.name
                  icon = si.iconID or icon
                end
              end
              tinsert(list, {
                id = sid,
                name = name or ("#" .. sid),
                icon = icon or 134400,
              })
            end
          end
        end
      end
    end
  end

  function ns.ClickCastSpellUI.GetSpellBookSpellList()
    if spellListCache and not listDirty then
      return spellListCache
    end
    local list = {}
    local seen = {}
    if C_SpellBook and C_SpellBook.GetNumSpellBookSkillLines then
      for i = 1, C_SpellBook.GetNumSpellBookSkillLines() do
        walkSkillLine(i, seen, list)
      end
    end
    if GetProfessions then
      local a, b, c, d, e, f = GetProfessions()
      for _, idx in pairs({ a, b, c, d, e, f }) do
        if type(idx) == "number" then
          walkSkillLine(idx, seen, list)
        end
      end
    end
    table.sort(list, function(x, y)
      return (x.name or ""):lower() < (y.name or ""):lower()
    end)
    spellListCache = list
    listDirty = false
    return list
  end

  -- ---------------------------------------------------------------------------
  --  Spell picker window — base palette; accents = player class (ApplyGuiClassTheme)
  -- ---------------------------------------------------------------------------
  local picker = nil
  local ROW_H = 26
  local NUM_ROWS = 13

  local PC = {
    bg         = { 0.055, 0.055, 0.062, 0.98 },
    bgDeep     = { 0.040, 0.040, 0.046, 1.00 },
    content    = { 0.068, 0.068, 0.075, 0.97 },
    header     = { 0.10,  0.028, 0.028, 1.00 },
    border     = { 0.20,  0.20,  0.22,  1.00 },
    accent     = { 0.90,  0.18,  0.18,  1.00 },
    accentHi   = { 1.00,  0.42,  0.42,  1.00 },
    accentDim  = { 0.55,  0.08,  0.08,  1.00 },
    sectionHdr = { 1.00,  0.47,  0.00,  1.00 },
    text       = { 0.90,  0.90,  0.92,  1.00 },
    tabHover   = { 0.09,  0.03,  0.03,  1.00 },
  }

  local function applyFilter(allSpells, needle)
    needle = trim(needle):lower()
    if needle == "" then return allSpells end
    local out = {}
    for _, e in ipairs(allSpells) do
      if (e.name and e.name:lower():find(needle, 1, true)) or tostring(e.id):find(needle, 1, true) then
        tinsert(out, e)
      end
    end
    return out
  end

  function ns.ClickCastSpellUI.OpenSpellPicker(onPick)
    if type(onPick) ~= "function" then return end

    if not picker then
      ns.ApplyGuiClassTheme(PC)
      local function makeROrbsButton(parent, w, h, text, r, g, b)
        r = r or PC.accentDim[1]
        g = g or PC.accentDim[2]
        b = b or PC.accentDim[3]
        local btn = CreateFrame("Button", nil, parent)
        btn:SetSize(w, h)
        local bg1 = btn:CreateTexture(nil, "BACKGROUND")
        bg1:SetAllPoints()
        bg1:SetColorTexture(r * 0.6, g * 0.6, b * 0.6, 1)
        local br = btn:CreateTexture(nil, "BORDER")
        br:SetAllPoints()
        br:SetColorTexture(PC.border[1], PC.border[2], PC.border[3], 0.85)
        local inner = btn:CreateTexture(nil, "ARTWORK")
        inner:SetPoint("TOPLEFT", btn, "TOPLEFT", 1, -1)
        inner:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -1, 1)
        inner:SetColorTexture(r, g, b, 1)
        btn.inner = inner
        local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        fs:SetAllPoints()
        fs:SetJustifyH("CENTER")
        fs:SetText(text)
        fs:SetTextColor(PC.text[1], PC.text[2], PC.text[3], 1)
        btn.fs = fs
        btn:SetScript("OnEnter", function()
          inner:SetColorTexture(math.min(1, r * 1.35), math.min(1, g * 1.45), math.min(1, b * 1.35), 1)
        end)
        btn:SetScript("OnLeave", function()
          inner:SetColorTexture(r, g, b, 1)
        end)
        return btn
      end

      local function hidePicker()
        picker:Hide()
        picker._callback = nil
      end

      picker = CreateFrame("Frame", "rOrbsClickCastSpellPicker", UIParent)
      picker:SetSize(400, 478)
      picker:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
      picker:SetFrameStrata("DIALOG")
      picker:SetFrameLevel(5000)
      picker:SetClampedToScreen(true)
      picker:SetMovable(true)
      picker:EnableMouse(false)
      picker:Hide()

      local baseBg = picker:CreateTexture(nil, "BACKGROUND", nil, -8)
      baseBg:SetAllPoints()
      baseBg:SetColorTexture(PC.bg[1], PC.bg[2], PC.bg[3], PC.bg[4])

      local vig = picker:CreateTexture(nil, "BACKGROUND", nil, -6)
      vig:SetAllPoints()
      vig:SetColorTexture(0, 0, 0, 0.18)

      local function edge(pt, rp, rw, rh)
        local e = picker:CreateTexture(nil, "BORDER", nil, -5)
        e:SetColorTexture(PC.border[1], PC.border[2], PC.border[3], 0.9)
        e:SetSize(rw, rh)
        e:SetPoint(pt, picker, rp)
      end
      edge("TOPLEFT", "TOPLEFT", 400, 1)
      edge("BOTTOMLEFT", "BOTTOMLEFT", 400, 1)
      edge("TOPLEFT", "TOPLEFT", 1, 478)
      edge("TOPRIGHT", "TOPRIGHT", 1, 478)

      local accentStripe = picker:CreateTexture(nil, "ARTWORK", nil, 2)
      accentStripe:SetPoint("TOPLEFT", picker, "TOPLEFT", 1, -1)
      accentStripe:SetPoint("TOPRIGHT", picker, "TOPRIGHT", -1, -1)
      accentStripe:SetHeight(3)
      accentStripe:SetColorTexture(PC.accent[1], PC.accent[2], PC.accent[3], 0.95)

      local titleBar = CreateFrame("Frame", nil, picker)
      titleBar:SetPoint("TOPLEFT", picker, "TOPLEFT", 1, -4)
      titleBar:SetPoint("TOPRIGHT", picker, "TOPRIGHT", -1, -4)
      titleBar:SetHeight(44)
      titleBar:SetFrameLevel(picker:GetFrameLevel() + 50)
      titleBar:EnableMouse(true)
      titleBar:RegisterForDrag("LeftButton")
      titleBar:SetScript("OnDragStart", function()
        picker:StartMoving()
      end)
      titleBar:SetScript("OnDragStop", function()
        picker:StopMovingOrSizing()
      end)

      local titleBarBg = titleBar:CreateTexture(nil, "BACKGROUND")
      titleBarBg:SetAllPoints()
      titleBarBg:SetColorTexture(PC.header[1], PC.header[2], PC.header[3], 0.92)

      local titleBarLine = titleBar:CreateTexture(nil, "ARTWORK", nil, 1)
      titleBarLine:SetPoint("BOTTOMLEFT", titleBar, "BOTTOMLEFT", 0, 0)
      titleBarLine:SetPoint("BOTTOMRIGHT", titleBar, "BOTTOMRIGHT", 0, 0)
      titleBarLine:SetHeight(1)
      titleBarLine:SetColorTexture(PC.accent[1], PC.accent[2], PC.accent[3], 0.5)

      local title = titleBar:CreateFontString(nil, "OVERLAY", nil, 4)
      title:SetPoint("LEFT", titleBar, "LEFT", 14, 0)
      title:SetPoint("RIGHT", titleBar, "RIGHT", -52, 0)
      title:SetJustifyH("LEFT")
      title:SetFontObject("GameFontNormalLarge")
      title:SetTextColor(PC.sectionHdr[1], PC.sectionHdr[2], PC.sectionHdr[3], 1)
      title:SetText(L["CLICKCAST_PICKER_TITLE"])

      local closeBtn = makeROrbsButton(titleBar, 30, 28, "×", PC.accent[1], PC.accent[2], PC.accent[3])
      closeBtn:SetPoint("TOPRIGHT", titleBar, "TOPRIGHT", -8, -8)
      closeBtn:SetFrameLevel(titleBar:GetFrameLevel() + 30)
      closeBtn.fs:SetFont("Fonts\\FRIZQT__.TTF", 18, "")
      closeBtn:SetScript("OnClick", hidePicker)
      picker._closeBtn = closeBtn

      local searchLabel = picker:CreateFontString(nil, "OVERLAY", nil, 3)
      searchLabel:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 14, -12)
      searchLabel:SetFontObject("GameFontHighlightSmall")
      searchLabel:SetTextColor(PC.text[1], PC.text[2], PC.text[3], 0.85)
      searchLabel:SetText(L["CLICKCAST_PICKER_SEARCH"])

      local searchWrap = CreateFrame("Frame", nil, picker)
      searchWrap:SetSize(340, 28)
      searchWrap:SetPoint("TOPLEFT", searchLabel, "BOTTOMLEFT", -2, -6)
      searchWrap:SetFrameLevel(picker:GetFrameLevel() + 20)
      local swB = searchWrap:CreateTexture(nil, "BACKGROUND")
      swB:SetAllPoints()
      swB:SetColorTexture(PC.border[1], PC.border[2], PC.border[3], 1)
      local swI = searchWrap:CreateTexture(nil, "ARTWORK")
      swI:SetPoint("TOPLEFT", searchWrap, "TOPLEFT", 1, -1)
      swI:SetPoint("BOTTOMRIGHT", searchWrap, "BOTTOMRIGHT", -1, 1)
      swI:SetColorTexture(PC.bgDeep[1], PC.bgDeep[2], PC.bgDeep[3], 1)

      local search = CreateFrame("EditBox", nil, searchWrap)
      search:SetPoint("TOPLEFT", swI, "TOPLEFT", 6, -4)
      search:SetPoint("BOTTOMRIGHT", swI, "BOTTOMRIGHT", -6, 4)
      search:SetAutoFocus(false)
      search:SetFontObject("ChatFontNormal")
      search:SetTextColor(PC.text[1], PC.text[2], PC.text[3], 1)
      search:EnableMouse(true)

      local listPanel = CreateFrame("Frame", nil, picker)
      listPanel:SetPoint("TOPLEFT", searchWrap, "BOTTOMLEFT", -2, -10)
      listPanel:SetPoint("BOTTOMRIGHT", picker, "BOTTOMRIGHT", -14, 48)
      listPanel:SetFrameLevel(picker:GetFrameLevel() + 15)
      listPanel:EnableMouse(true)
      local lpB = listPanel:CreateTexture(nil, "BACKGROUND")
      lpB:SetAllPoints()
      lpB:SetColorTexture(PC.border[1], PC.border[2], PC.border[3], 1)
      local lpI = listPanel:CreateTexture(nil, "ARTWORK")
      lpI:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 1, -1)
      lpI:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -1, 1)
      lpI:SetColorTexture(PC.content[1], PC.content[2], PC.content[3], PC.content[4])

      local scroll = CreateFrame("ScrollFrame", "rOrbsClickCastPickerScroll", listPanel, "UIPanelScrollFrameTemplate")
      scroll:SetPoint("TOPLEFT", listPanel, "TOPLEFT", 4, -4)
      scroll:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -22, 4)
      scroll:SetFrameLevel(listPanel:GetFrameLevel() + 5)

      local content = CreateFrame("Frame", nil, scroll)
      content:SetSize(320, NUM_ROWS * ROW_H + 8)
      scroll:SetScrollChild(content)

      -- Eigene Scrollbar (virtuelles Scrollen via _offset)
      local SB_PAD = 3
      local SB_W = 12
      local sbTrack = CreateFrame("Frame", nil, listPanel)
      sbTrack:SetWidth(SB_W + 2)
      sbTrack:SetPoint("TOPRIGHT", listPanel, "TOPRIGHT", -SB_PAD, -SB_PAD)
      sbTrack:SetPoint("BOTTOMRIGHT", listPanel, "BOTTOMRIGHT", -SB_PAD, SB_PAD)
      sbTrack:SetFrameLevel(listPanel:GetFrameLevel() + 30)
      sbTrack:EnableMouse(true)
      local sbTrackBr = sbTrack:CreateTexture(nil, "BACKGROUND")
      sbTrackBr:SetAllPoints()
      sbTrackBr:SetColorTexture(PC.border[1], PC.border[2], PC.border[3], 1)
      local sbGutter = sbTrack:CreateTexture(nil, "ARTWORK")
      sbGutter:SetPoint("TOPLEFT", sbTrack, "TOPLEFT", 1, -1)
      sbGutter:SetPoint("BOTTOMRIGHT", sbTrack, "BOTTOMRIGHT", -1, 1)
      sbGutter:SetColorTexture(PC.bgDeep[1], PC.bgDeep[2], PC.bgDeep[3], 1)

      local sbThumb = CreateFrame("Button", nil, sbTrack)
      sbThumb:SetWidth(SB_W - 2)
      sbThumb:SetFrameLevel(sbTrack:GetFrameLevel() + 2)
      sbThumb:EnableMouse(true)
      local sbThBr = sbThumb:CreateTexture(nil, "BACKGROUND")
      sbThBr:SetAllPoints()
      sbThBr:SetColorTexture(PC.border[1], PC.border[2], PC.border[3], 0.95)
      local sbThIn = sbThumb:CreateTexture(nil, "ARTWORK")
      sbThIn:SetPoint("TOPLEFT", sbThumb, "TOPLEFT", 1, -1)
      sbThIn:SetPoint("BOTTOMRIGHT", sbThumb, "BOTTOMRIGHT", -1, 1)
      sbThIn:SetColorTexture(PC.accentDim[1], PC.accentDim[2], PC.accentDim[3], 1)
      sbThumb.inner = sbThIn
      sbThumb:SetScript("OnEnter", function(self)
        if not self.dragging then
          sbThIn:SetColorTexture(PC.accent[1] * 0.88, PC.accent[2] * 0.88, PC.accent[3] * 0.88, 1)
        end
      end)
      sbThumb:SetScript("OnLeave", function(self)
        if not self.dragging then
          sbThIn:SetColorTexture(PC.accentDim[1], PC.accentDim[2], PC.accentDim[3], 1)
        end
      end)

      picker._sbTrack = sbTrack
      picker._sbThumb = sbThumb

      local footerBtn = makeROrbsButton(picker, 140, 26, L["CLICKCAST_PICKER_CLOSE"] or _G.CLOSE or "Close", PC.accentDim[1], PC.accentDim[2], PC.accentDim[3])
      footerBtn:SetPoint("BOTTOM", picker, "BOTTOM", 0, 12)
      footerBtn:SetFrameLevel(picker:GetFrameLevel() + 60)
      footerBtn:SetScript("OnClick", hidePicker)

      picker._content = content
      picker._scroll = scroll
      picker._search = search
      picker._filtered = {}
      picker._offset = 0
      picker._rows = {}

      for i = 1, NUM_ROWS do
        local btn = CreateFrame("Button", nil, content)
        btn:SetSize(312, ROW_H - 2)
        btn:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -(i - 1) * ROW_H)

        local rowBg = btn:CreateTexture(nil, "BACKGROUND")
        rowBg:SetAllPoints()
        rowBg:SetColorTexture(PC.bgDeep[1], PC.bgDeep[2], PC.bgDeep[3], 0.55)
        btn.rowBg = rowBg

        local icon = btn:CreateTexture(nil, "ARTWORK", nil, 1)
        icon:SetSize(22, 22)
        icon:SetPoint("LEFT", btn, "LEFT", 6, 0)

        local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall", 2)
        fs:SetPoint("LEFT", icon, "RIGHT", 10, 0)
        fs:SetPoint("RIGHT", btn, "RIGHT", -10, 0)
        fs:SetJustifyH("LEFT")

        btn.icon = icon
        btn.fs = fs
        btn:SetScript("OnEnter", function(self)
          self.rowBg:SetColorTexture(PC.tabHover[1], PC.tabHover[2], PC.tabHover[3], 0.95)
          local sid = self.spellId
          if sid then
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            local ok = pcall(function()
              if GameTooltip.SetSpellByID then
                GameTooltip:SetSpellByID(sid)
              elseif GameTooltip.SetSpellBySpellID then
                GameTooltip:SetSpellBySpellID(sid)
              else
                error("no spell tooltip api")
              end
            end)
            if not ok then
              GameTooltip:ClearLines()
              if C_Spell and C_Spell.GetSpellInfo then
                local si = C_Spell.GetSpellInfo(sid)
                if si and si.name then
                  GameTooltip:AddLine(si.name, 1, 1, 1)
                  if C_Spell.GetSpellDescription then
                    local desc = C_Spell.GetSpellDescription(sid)
                    if desc and desc ~= "" then
                      GameTooltip:AddLine(desc, 1, 1, 1, true)
                    end
                  end
                end
              end
            end
            GameTooltip:Show()
          end
        end)
        btn:SetScript("OnLeave", function(self)
          self.rowBg:SetColorTexture(PC.bgDeep[1], PC.bgDeep[2], PC.bgDeep[3], 0.55)
          GameTooltip:Hide()
        end)
        btn:SetScript("OnClick", function(self)
          if self.spellId and picker._callback then
            local cb = picker._callback
            picker._callback = nil
            picker:Hide()
            cb(self.spellId, self.spellName, self.iconFile)
          end
        end)
        picker._rows[i] = btn
      end

      local function syncSpellPickerScrollbar()
        local n = #picker._filtered
        local maxOff = picker._maxOff or 0
        if maxOff <= 0 or n <= NUM_ROWS then
          sbTrack:Hide()
          return
        end
        sbTrack:Show()
        local padT, padB = 2, 2
        local th = sbTrack:GetHeight() - padT - padB
        if th < 24 then th = 24 end
        local thumbH = math.max(20, math.floor(th * (NUM_ROWS / n)))
        thumbH = math.min(thumbH, th)
        local yRange = math.max(0, th - thumbH)
        local t = maxOff > 0 and (picker._offset / maxOff) or 0
        sbThumb:SetHeight(thumbH)
        sbThumb:ClearAllPoints()
        sbThumb:SetPoint("TOP", sbGutter, "TOP", 0, -padT - t * yRange)
        sbThumb:Show()
      end

      local function layoutContent()
        local n = #picker._filtered
        local maxOff = math.max(0, n - NUM_ROWS)
        if picker._offset > maxOff then picker._offset = maxOff end
        if picker._offset < 0 then picker._offset = 0 end
        picker._maxOff = maxOff
        local sb = _G[scroll:GetName() .. "ScrollBar"]
        if sb then
          sb:Hide()
        end
        for i = 1, NUM_ROWS do
          local idx = picker._offset + i
          local e = picker._filtered[idx]
          local row = picker._rows[i]
          if e then
            row:Show()
            row.spellId = e.id
            row.spellName = e.name
            row.iconFile = e.icon
            if e.icon then
              row.icon:SetTexture(e.icon)
            else
              row.icon:SetTexture(134400)
            end
            row.fs:SetText((e.name or "?") .. "  |cff888888(" .. tostring(e.id) .. ")|r")
          else
            row:Hide()
            row.spellId = nil
          end
        end
        syncSpellPickerScrollbar()
      end

      picker._layoutContent = layoutContent

      sbThumb:SetScript("OnMouseDown", function(self, button)
        if button ~= "LeftButton" then return end
        if (picker._maxOff or 0) <= 0 then return end
        self.dragging = true
        local uis = UIParent:GetEffectiveScale()
        self.dragAnchorY = select(2, GetCursorPosition()) / uis
        self.dragAnchorOff = picker._offset
        sbThIn:SetColorTexture(PC.accentHi[1], PC.accentHi[2], PC.accentHi[3], 1)
        sbTrack:SetScript("OnUpdate", function()
          if not self.dragging or not IsMouseButtonDown("LeftButton") then
            self.dragging = false
            sbTrack:SetScript("OnUpdate", nil)
            if not self:IsMouseOver() then
              sbThIn:SetColorTexture(PC.accentDim[1], PC.accentDim[2], PC.accentDim[3], 1)
            end
            return
          end
          local cy = select(2, GetCursorPosition()) / uis
          local dy = cy - self.dragAnchorY
          local padT, padB = 2, 2
          local trackInnerH = sbTrack:GetHeight() - padT - padB
          local thumbH = self:GetHeight()
          local trackRange = math.max(1, trackInnerH - thumbH)
          local maxOff = picker._maxOff or 0
          -- WoW Y grows upward: cursor moves "down" → negative dy → offset should increase.
          local deltaOff = -(dy / trackRange) * maxOff
          local newOff = math.floor(self.dragAnchorOff + deltaOff + 0.5)
          newOff = math.max(0, math.min(maxOff, newOff))
          if newOff ~= picker._offset then
            picker._offset = newOff
            layoutContent()
          end
        end)
      end)

      sbTrack:SetScript("OnMouseDown", function(_, button)
        if button ~= "LeftButton" then return end
        if sbThumb:IsMouseOver() then return end
        local maxOff = picker._maxOff or 0
        if maxOff <= 0 then return end
        local uis = UIParent:GetEffectiveScale()
        local cy = select(2, GetCursorPosition()) / uis
        local mid = ((sbThumb:GetTop() + sbThumb:GetBottom()) / 2)
        if cy > mid then
          picker._offset = math.max(0, picker._offset - NUM_ROWS)
        else
          picker._offset = math.min(maxOff, picker._offset + NUM_ROWS)
        end
        layoutContent()
      end)

      scroll:EnableMouseWheel(true)
      scroll:SetScript("OnMouseWheel", function(_, delta)
        local maxOff = picker._maxOff or 0
        if delta > 0 then
          picker._offset = math.max(0, picker._offset - 3)
        else
          picker._offset = math.min(maxOff, picker._offset + 3)
        end
        layoutContent()
      end)

      search:SetScript("OnTextChanged", function(self)
        local all = ns.ClickCastSpellUI.GetSpellBookSpellList()
        picker._filtered = applyFilter(all, self:GetText())
        picker._offset = 0
        layoutContent()
      end)
      search:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
      search:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
        hidePicker()
      end)

      picker:SetScript("OnHide", function(self)
        self._callback = nil
      end)

      picker:SetScript("OnShow", function(self)
        self:Raise()
      end)

      picker:EnableKeyboard(true)
      picker:SetScript("OnKeyDown", function(self, key)
        if key == "ESCAPE" then
          hidePicker()
          self:SetPropagateKeyboardInput(false)
        else
          self:SetPropagateKeyboardInput(true)
        end
      end)

      tinsert(UISpecialFrames, picker:GetName())
    end

    picker._callback = onPick
    local allSpells = ns.ClickCastSpellUI.GetSpellBookSpellList()
    picker._filtered = applyFilter(allSpells, picker._search:GetText() or "")
    picker._offset = 0
    picker._layoutContent()

    picker:ClearAllPoints()
    local settingsPanel = _G.rOrbsSettingsPanel
    if settingsPanel and settingsPanel:IsShown() then
      picker:SetPoint("TOPLEFT", settingsPanel, "TOPRIGHT", 2, -18)
    else
      picker:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    end

    picker:Show()
    picker:Raise()
  end
