view modules/Action.lua @ 109:410d036c43b2

- reorganize modularity file structure (part 1)
author Flick <flickerstreak@gmail.com>
date Thu, 08 Jan 2009 00:57:27 +0000
parents modules/ReAction_Action/ReAction_Action.lua@b2fb8f7dc780
children 77bb68eb402b
line wrap: on
line source
--[[
  ReAction Action button module.

  The button module implements standard action button functionality by wrapping Blizzard's 
  ActionBarButtonTemplate frame and associated functions.

  It also provides action remapping support for multiple pages and possessed targets
  (Mind Control, Eyes of the Beast, Karazhan Chess event, various quests, etc).
--]]

-- local imports
local ReAction = ReAction
local L = ReAction.L
local _G = _G
local CreateFrame = CreateFrame
local format = string.format
local wipe = wipe

ReAction:UpdateRevision("$Revision$")

-- libraries
local KB = LibStub("LibKeyBound-1.0")
local LBF -- initialized later

-- module declaration
local moduleID = "Action"
local module = ReAction:NewModule( moduleID )

-- Class declarations
local Button = { }
local Handle = { }
local PropHandler = { }

-- Event handlers
function module:OnInitialize()
  self.db = ReAction.db:RegisterNamespace( moduleID,
    { 
      profile = {
        bars = { },
      }
    }
  )
  self.handles = setmetatable({ }, weak)

  ReAction:RegisterBarOptionGenerator(self, "GetBarOptions")

  ReAction.RegisterCallback(self, "OnCreateBar")
  ReAction.RegisterCallback(self, "OnRefreshBar")
  ReAction.RegisterCallback(self, "OnDestroyBar")
  ReAction.RegisterCallback(self, "OnEraseBar")
  ReAction.RegisterCallback(self, "OnRenameBar")
  ReAction.RegisterCallback(self, "OnConfigModeChanged")

  LBF = LibStub("LibButtonFacade",true)

  KB.RegisterCallback(self, "LIBKEYBOUND_ENABLED")
  KB.RegisterCallback(self, "LIBKEYBOUND_DISABLED")
  KB.RegisterCallback(self, "LIBKEYBOUND_MODE_COLOR_CHANGED","LIBKEYBOUND_ENABLED")
end

function module:OnEnable()
  ReAction:RegisterBarType(L["Action Bar"], 
    { 
      type = moduleID,
      defaultButtonSize = 36,
      defaultBarRows = 1,
      defaultBarCols = 12,
      defaultBarSpacing = 3
    }, true)
  ReAction:GetModule("State"):RegisterStateProperty("page", nil, PropHandler.GetOptions(), PropHandler)
end

function module:OnDisable()
  ReAction:UnregisterBarType(L["Action Bar"])
  ReAction:GetModule("State"):UnregisterStateProperty("page")
end

function module:OnCreateBar(event, bar, name)
  if bar.config.type == moduleID then
    local profile = self.db.profile
    if profile.bars[name] == nil then
      profile.bars[name] = {
        buttons = { }
      }
    end
    if self.handles[bar] == nil then
      self.handles[bar] = Handle:New(bar, profile.bars[name])
    end
  end
end

function module:OnRefreshBar(event, bar, name)
  if self.handles[bar] then
    self.handles[bar]:Refresh()
  end
end

function module:OnDestroyBar(event, bar, name)
  if self.handles[bar] then
    self.handles[bar]:Destroy()
    self.handles[bar] = nil
  end
end

function module:OnEraseBar(event, bar, name)
  self.db.profile.bars[name] = nil
end

function module:OnRenameBar(event, bar, oldname, newname)
  b = self.db.profile.bars
  b[newname], b[oldname] = b[oldname], nil
end

function module:OnConfigModeChanged(event, mode)
  for _, h in pairs(self.handles) do
    h:SetConfigMode(mode)
  end
end

function module:LIBKEYBOUND_ENABLED(evt)
  for _, h in pairs(self.handles) do
    h:ShowGrid(true)
    h:SetKeybindMode(true)
  end
end

function module:LIBKEYBOUND_DISABLED(evt)
  for _, h in pairs(self.handles) do
    h:ShowGrid(false)
    h:SetKeybindMode(false)
  end
end


---- Interface ----
function module:GetBarOptions(bar)
  local h = self.handles[bar]
  if h then
    return h:GetOptions()
  end
end


---- Bar Handle ----

do
  local options = {
    hideEmpty = {
      name = L["Hide Empty Buttons"],
      order = 1,
      type = "toggle",
      width = "double",
      get  = "GetHideEmpty",
      set  = "SetHideEmpty",
    },
    lockButtons = {
      name = L["Lock Buttons"],
      desc = L["Prevents picking up/dragging actions.|nNOTE: This setting is overridden by the global setting in Blizzard's Action Buttons tab"],
      order = 2,
      type = "toggle",
      disabled = "LockButtonsDisabled",
      get = "GetLockButtons",
      set = "SetLockButtons",
    },
    lockOnlyCombat = {
      name = L["Only in Combat"],
      desc = L["Only lock the buttons when in combat"],
      order = 3,
      type = "toggle",
      disabled = "LockButtonsCombatDisabled",
      get = "GetLockButtonsCombat",
      set = "SetLockButtonsCombat",
    },
    pages = {
      name  = L["# Pages"],
      desc  = L["Use the Dynamic State tab to specify page transitions"],
      order = 4,
      type  = "range",
      min   = 1,
      max   = 10,
      step  = 1,
      get   = "GetNumPages",
      set   = "SetNumPages",
    },
    mindcontrol = {
      name = L["Mind Control Support"],
      desc = L["When possessing a target (e.g. via Mind Control), map the first 12 buttons of this bar to the possessed target's actions."],
      order = 5,
      type = "toggle",
      width = "double",
      set = "SetMindControl",
      get = "GetMindControl",
    },
    actions = {
      name   = L["Edit Action IDs"],
      order  = 6,
      type   = "group",
      inline = true,
      args   = {
        method = {
          name   = L["Assign"],
          order  = 1,
          type   = "select",
          width  = "double",
          values = { [0] = L["Choose Method..."],
                     [1] = L["Individually"],
                     [2] = L["All at Once"], },
          get    = "GetActionEditMethod",
          set    = "SetActionEditMethod",
        },
        rowSelect = {
          name   = L["Row"],
          desc   = L["Rows are numbered top to bottom"],
          order  = 2,
          type   = "select",
          width  = "half",
          hidden = "IsButtonSelectHidden",
          values = "GetRowList",
          get    = "GetSelectedRow",
          set    = "SetSelectedRow",
        },
        colSelect = {
          name   = L["Col"],
          desc   = L["Columns are numbered left to right"],
          order  = 3,
          type   = "select",
          width  = "half",
          hidden = "IsButtonSelectHidden",
          values = "GetColumnList",
          get    = "GetSelectedColumn",
          set    = "SetSelectedColumn",
        },
        pageSelect = {
          name   = L["Page"],
          order  = 4,
          type   = "select",
          width  = "half",
          hidden = "IsPageSelectHidden",
          values = "GetPageList",
          get    = "GetSelectedPage",
          set    = "SetSelectedPage",
        },
        single = {
          name   = L["Action ID"],
          usage  = L["Specify ID 1-120"],
          order  = 5,
          type   = "input",
          width  = "half",
          hidden = "IsButtonSelectHidden",
          get    = "GetActionID",
          set    = "SetActionID",
          validate = "ValidateActionID",
        },
        multi = {
          name   = L["ID List"],
          usage  = L["Specify a comma-separated list of IDs for each button in the bar (in order). Separate multiple pages with semicolons (;)"],
          order  = 6,
          type   = "input",
          multiline = true,
          width  = "double",
          hidden = "IsMultiIDHidden",
          get    = "GetMultiID",
          set    = "SetMultiID",
          validate = "ValidateMultiID",
        },
      },
    },
  }

  local weak  = { __mode="k" }
  local meta = { __index = Handle }

  function Handle:New( bar, config )
    local self = setmetatable(
      {
        bar = bar,
        config = config,
        btns = { }
      }, 
      meta)
    
    if self.config.buttons == nil then
      self.config.buttons = { }
    end
    self:Refresh()
    self:SetKeybindMode(ReAction:GetKeybindMode())
    return self
  end

  function Handle:Refresh()
    local r, c = self.bar:GetButtonGrid()
    local n = r*c
    local btnCfg = self.config.buttons
    if n ~= #self.btns then
      for i = 1, n do
        if btnCfg[i] == nil then
          btnCfg[i] = {}
        end
        if self.btns[i] == nil then
          local b = Button:New(self, i, btnCfg[i], self.config)
          self.btns[i] = b
          self.bar:AddButton(i,b)
        end
      end
      for i = n+1, #self.btns do
        if self.btns[i] then
          self.bar:RemoveButton(self.btns[i])
          self.btns[i]:Destroy()
          self.btns[i] = nil
          btnCfg[i] = nil
        end
      end
    end
    local f = self.bar:GetFrame()
    for _, b in ipairs(self.btns) do
      b:Refresh()
    end
    f:SetAttribute("mindcontrol",self.config.mindcontrol)
    f:Execute(
      [[
      doMindControl = self:GetAttribute("mindcontrol")
      control:ChildUpdate()
      ]])

    f:SetAttribute("_onstate-mindcontrol",
      -- function _onstate-mindcontrol(self, stateid, newstate)
      [[
        control:ChildUpdate()
      ]])
    RegisterStateDriver(f, "mindcontrol", "[bonusbar:5] mc; none")
    self:UpdateButtonLock()
  end

  function Handle:Destroy()
    for _,b in pairs(self.btns) do
      if b then
        b:Destroy()
      end
    end
  end

  function Handle:SetConfigMode(mode)
    for _, b in pairs(self.btns) do
      b:ShowGrid(mode)
      b:ShowActionIDLabel(mode)
    end
  end

  function Handle:ShowGrid(show)
    for _, b in pairs(self.btns) do
      b:ShowGrid(show)
    end
  end

  function Handle:UpdateButtonLock()
    local f = self.bar:GetFrame()
    f:SetAttribute("lockbuttons",self.config.lockButtons)
    f:SetAttribute("lockbuttonscombat",self.config.lockButtonsCombat)
    f:Execute(
      [[
        lockButtons = self:GetAttribute("lockbuttons")
        lockButtonsCombat = self:GetAttribute("lockbuttonscombat")
      ]])
  end

  function Handle:SetKeybindMode(mode)
    for _, b in pairs(self.btns) do
      if mode then
        -- set the border for all buttons to the keybind-enable color
      	b.border:SetVertexColor(KB:GetColorKeyBoundMode())
        b.border:Show()
      elseif IsEquippedAction(b:GetActionID()) then
        b.border:SetVertexColor(0, 1.0, 0, 0.35) -- from ActionButton.lua
      else
        b.border:Hide()
      end
    end
  end

  function Handle:GetLastButton()
    return self.btns[#self.btns]
  end

    -- options handlers
  function Handle:GetOptions()
    return {
      type = "group",
      name = L["Action Buttons"],
      handler = self,
      args = options
    }
  end

  function Handle:SetHideEmpty(info, value)
    if value ~= self.config.hideEmpty then
      self.config.hideEmpty = value
      self:ShowGrid(not value)
    end
  end

  function Handle:GetHideEmpty()
    return self.config.hideEmpty
  end

  function Handle:GetLockButtons()
    return LOCK_ACTIONBAR == "1" or self.config.lockButtons
  end

  function Handle:SetLockButtons(info, value)
    self.config.lockButtons = value
    self:UpdateButtonLock()
  end

  function Handle:LockButtonsDisabled()
    return LOCK_ACTIONBAR == "1"
  end

  function Handle:GetLockButtonsCombat()
    return self.config.lockButtonsCombat
  end

  function Handle:SetLockButtonsCombat(info, value)
    self.config.lockButtonsCombat = value
    self:UpdateButtonLock()
  end

  function Handle:LockButtonsCombatDisabled()
    return LOCK_ACTIONBAR == "1" or not self.config.lockButtons
  end

  function Handle:GetNumPages()
    return self.config.nPages
  end

  function Handle:SetNumPages(info, value)
    self.config.nPages = value
    self:Refresh()
  end

  function Handle:GetMindControl()
    return self.config.mindcontrol
  end

  function Handle:SetMindControl(info, value)
    self.config.mindcontrol = value
    self:Refresh()
  end

  function Handle:GetActionEditMethod()
    return self.editMethod or 0
  end

  function Handle:SetActionEditMethod(info, value)
    self.editMethod = value
  end

  function Handle:IsButtonSelectHidden()
    return self.editMethod ~= 1
  end

  function Handle:GetRowList()
    local r,c = self.bar:GetButtonGrid()
    if self.rowList == nil or #self.rowList ~= r then
      local list = { }
      for i = 1, r do
        table.insert(list,i)
      end
      self.rowList = list
    end
    return self.rowList
  end

  function Handle:GetSelectedRow()
    local r, c = self.bar:GetButtonGrid()
    local row = self.selectedRow or 1
    if row > r then
      row = 1
    end
    self.selectedRow = row
    return row
  end

  function Handle:SetSelectedRow(info, value)
    self.selectedRow = value
  end

  function Handle:GetColumnList()
    local r,c = self.bar:GetButtonGrid()
    if self.columnList == nil or #self.columnList ~= c then
      local list = { }
      for i = 1, c do
        table.insert(list,i)
      end
      self.columnList = list
    end
    return self.columnList
  end

  function Handle:GetSelectedColumn()
    local r, c = self.bar:GetButtonGrid()
    local col = self.selectedColumn or 1
    if col > c then
      col = 1
    end
    self.selectedColumn = col
    return col
  end

  function Handle:SetSelectedColumn(info, value)
    self.selectedColumn = value
  end

  function Handle:IsPageSelectHidden()
    return self.editMethod ~= 1 or (self.config.nPages or 1) < 2
  end

  function Handle:GetPageList()
    local n = self.config.nPages or 1
    if self.pageList == nil or #self.pageList ~= n then
      local p = { }
      for i = 1, n do
        table.insert(p,i)
      end
      self.pageList = p
    end
    return self.pageList
  end

  function Handle:GetSelectedPage()
    local p = self.selectedPage or 1
    if p > (self.config.nPages or 1) then
      p = 1
    end
    self.selectedPage = p
    return p
  end

  function Handle:SetSelectedPage(info, value)
    self.selectedPage = value
  end

  function Handle:GetActionID()
    local row = self.selectedRow or 1
    local col = self.selectedColumn or 1
    local r, c = self.bar:GetButtonGrid()
    local n = (row-1) * c + col
    local btn = self.btns[n]
    if btn then
      return tostring(btn:GetActionID(self.selectedPage or 1))
    end
  end

  function Handle:SetActionID(info, value)
    local row = self.selectedRow or 1
    local col = self.selectedColumn or 1
    local r, c = self.bar:GetButtonGrid()
    local n = (row-1) * c + col
    local btn = self.btns[n]
    if btn then
      btn:SetActionID(tonumber(value), self.selectedPage or 1)
    end
  end

  function Handle:ValidateActionID(info, value)
    value = tonumber(value)
    if value == nil or value < 1 or value > 120 then
      return L["Specify ID 1-120"]
    end
    return true
  end

  function Handle:IsMultiIDHidden()
    return self.editMethod ~= 2
  end

  function Handle:GetMultiID()
    local p = { }
    for i = 1, self.config.nPages or 1 do
      local b = { }
      for _, btn in ipairs(self.btns) do
        table.insert(b, btn:GetActionID(i))
      end
      table.insert(p, table.concat(b,","))
    end
    return table.concat(p,";\n")
  end


  local function ParseMultiID(nBtns, nPages, s)
    if s:match("[^%d%s,;]") then
      ReAction:Print("items other than digits, spaces, commas, and semicolons in string",s)
      return nil
    end
    local p = { }
    for list in s:gmatch("[^;]+") do
      local pattern = ("^%s?$"):format(("%s*(%d+)%s*,"):rep(nBtns))
      local ids = { list:match(pattern) }
      if #ids ~= nBtns then
        ReAction:Print("found",#ids,"buttons instead of",nBtns)
        return nil
      end
      table.insert(p,ids)
    end
    if #p ~= nPages then
      ReAction:Print("found",#p,"pages instead of",nPages)
      return nil
    end
    return p
  end

  function Handle:SetMultiID(info, value)
    local p = ParseMultiID(#self.btns, self.config.nPages or 1, value)
    for page, b in ipairs(p) do
      for button, id in ipairs(b) do
        self.btns[button]:SetActionID(id, page)
      end
    end
  end

  function Handle:ValidateMultiID(info, value)
    local bad = L["Invalid action ID list string"]
    if value == nil or ParseMultiID(#self.btns, self.config.nPages or 1, value) == nil then
      return bad
    end
    return true
  end
end


------ State property options ------
do
  local pageOptions = {
    page = {
      name     = L["Show Page #"],
      order    = 11,
      type     = "select",
      width    = "half",
      disabled = "IsPageDisabled",
      hidden   = "IsPageHidden",
      values   = "GetPageValues",
      set      = "SetProp",
      get      = "GetPage",
    },
  }

  local function GetBarConfig(bar)
    return module.db.profile.bars[bar:GetName()]
  end

  function PropHandler.GetOptions()
    return pageOptions
  end

  function PropHandler:IsPageDisabled()
    local c = GetBarConfig(self.bar)
    local n = c and c.nPages or 1
    return not (n > 1)
  end

  function PropHandler:IsPageHidden()
    return not GetBarConfig(self.bar)
  end

  function PropHandler:GetPageValues()
    if not self._pagevalues then
      self._pagevalues = { }
    end
    local c = GetBarConfig(self.bar)
    if c then
      local n = c.nPages
        -- cache the results
      if self._npages ~= n then
        self._npages = n
        wipe(self._pagevalues)
        for i = 1, n do
          self._pagevalues["page"..i] = i
        end
      end
    end
    return self._pagevalues
  end

  function PropHandler:GetPage(info)
    return self:GetProp(info) or 1
  end

end

------ ActionID allocation ------
-- this needs to be high performance when requesting new IDs,
-- or certain controls will become sluggish. However, the new-request
-- infrastructure can be built lazily the first time that a new request
-- comes in (which will only happen at user config time: at static startup
-- config time all actionIDs should already have been assigned and stored
-- in the config file)

local IDAlloc
do
  local n = 120
  
  IDAlloc = setmetatable({ wrap = 1, freecount = n }, {__index = function() return 0 end})

  function IDAlloc:Acquire(id, hint)
    id = tonumber(id)
    hint = tonumber(hint)
    if id and (id < 1 or id > n) then
      id = nil
    end
    if hint and (hint < 1 or hint > n) then
      hint = nil
    end
    if id == nil then
      -- get a free ID
      if hint and self[hint] == 0 then
        -- use the hint if it's free
        id = hint
      elseif self.freecount > 0 then
        -- if neither the id nor the hint are defined or free, but
        -- the list is known to have free IDs, then start searching
        -- at the hint for a free one
        for i = hint or 1, n do
          if self[i] == 0 then
            id = i
            break
          end
        end
        -- self.wrap the search
        if id == nil and hint and hint > 1 then
          for i = 1, hint - 1 do
            if self[i] == 0 then
              id = i
              break
            end
          end
        end
      end
      if id == nil then
        -- if there are no free IDs, start wrapping at 1
        id = self.wrap
        self.wrap = id + 1
        if self.wrap > n then
          self.wrap = 1
        end
      end
    end
    if self[id] == 0 then
      self.freecount = self.freecount - 1
    end
    self[id] = self[id] + 1
    return id
  end

  function IDAlloc:Release(id)
    id = tonumber(id)
    if id and (id >= 1 or id <= n) then
      self[id] = self[id] - 1
      if self[id] == 0 then
        self.freecount = self.freecount + 1
        self.wrap = 1
      end
    end
  end
end

------ Button class ------

do
  local frameRecycler = { }
  local trash = CreateFrame("Frame")
  local OnUpdate, KBAttach, GetActionName, GetHotkey, SetKey, FreeKey, ClearBindings, GetBindings
  do
    local ATTACK_BUTTON_FLASH_TIME = ATTACK_BUTTON_FLASH_TIME
    local IsActionInRange = IsActionInRange

    local buttonLookup = setmetatable({},{__mode="kv"})

    function OnUpdate(frame, elapsed)
      -- note: This function taints frame.flashtime and frame.rangeTimer. Both of these
      --       are only read by ActionButton_OnUpdate (which this function replaces). In
      --       all other places they're just written, so it doesn't taint any secure code.
      if frame.flashing == 1 then
        frame.flashtime = frame.flashtime - elapsed
        if frame.flashtime <= 0 then
          local overtime = -frame.flashtime
          if overtime >= ATTACK_BUTTON_FLASH_TIME then
            overtime = 0
          end
          frame.flashtime = ATTACK_BUTTON_FLASH_TIME - overtime

          local flashTexture = frame.flash
          if flashTexture:IsShown() then
            flashTexture:Hide()
          else
            flashTexture:Show()
          end
        end
      end
      
      if frame.rangeTimer then
        frame.rangeTimer = frame.rangeTimer - elapsed;

        if frame.rangeTimer <= 0 then
          if IsActionInRange(frame.action) == 0 then
            frame.icon:SetVertexColor(1.0,0.1,0.1)
          else
            ActionButton_UpdateUsable(frame)
          end
          frame.rangeTimer = 0.1
        end
      end
    end

    -- Use KeyBound-1.0 for binding, but use Override bindings instead of
    -- regular bindings to support multiple profile use. This is a little
    -- weird with the KeyBound dialog box (which has per-char selector as well
    -- as an OK/Cancel box) but it's the least amount of effort to implement.
    function GetActionName(f)
      local b = buttonLookup[f]
      if b then
        return format("%s:%s", b.bar:GetName(), b.idx)
      end
    end

    function GetHotkey(f)
      local b = buttonLookup[f]
      if b then
        return KB:ToShortKey(b:GetConfig().hotkey)
      end
    end

    function SetKey(f, key)
      local b = buttonLookup[f]
      if b then
        local c = b:GetConfig()
        if c.hotkey then
          SetOverrideBinding(f, false, c.hotkey, nil)
        end
        if key then
          SetOverrideBindingClick(f, false, key, f:GetName(), nil)
        end
        c.hotkey = key
        b:DisplayHotkey(GetHotkey(f))
      end
    end

    function FreeKey(f, key)
      local b = buttonLookup[f]
      if b then
        local c = b:GetConfig()
        if c.hotkey == key then
          local action = f:GetActionName()
          SetOverrideBinding(f, false, c.hotkey, nil)
          c.hotkey = nil
          b:DisplayHotkey(nil)
          return action
        end
      end
      return ReAction:FreeOverrideHotkey(key)
    end

    function ClearBindings(f)
      SetKey(f, nil)
    end

    function GetBindings(f)
      local b = buttonLookup[f]
      if b then
        return b:GetConfig().hotkey
      end
    end

    function KBAttach( button )
      local f = button:GetFrame()
      f.GetActionName = GetActionName
      f.GetHotkey     = GetHotkey
      f.SetKey        = SetKey
      f.FreeKey       = FreeKey
      f.ClearBindings = ClearBindings
      f.GetBindings   = GetBindings
      buttonLookup[f] = button
      f:SetKey(button:GetConfig().hotkey)
      ReAction:RegisterKeybindFrame(f)
      if ReAction:GetKeybindMode() then
      	button.border:SetVertexColor(KB:GetColorKeyBoundMode())
        button.border:Show()
      end
    end
  end

  local meta = {__index = Button}

  function Button:New( handle, idx, config, barConfig )
    local bar = handle.bar

    -- create new self
    self = setmetatable( 
      { 
        bar = bar,
        idx = idx,
        config = config,
        barConfig = barConfig,
      }, meta )

    local name = config.name or ("ReAction_%s_%s_%d"):format(bar:GetName(),moduleID,idx)
    self.name = name
    config.name = name
    local lastButton = handle:GetLastButton()
    config.actionID = IDAlloc:Acquire(config.actionID, lastButton and lastButton.config.actionID) -- gets a free one if none configured
    self.nPages = 1
    
    -- have to recycle frames with the same name: CreateFrame() doesn't overwrite
    -- existing globals. Can't set to nil in the global because it's then tainted.
    local parent = bar:GetFrame()
    local f = frameRecycler[name]
    if f then
      f:SetParent(parent)
    else
      f = CreateFrame("CheckButton", name, parent, "ActionBarButtonTemplate")
      -- ditch the old hotkey text because it's tied in ActionButton_Update() to the
      -- standard binding. We use override bindings.
      local hotkey = _G[name.."HotKey"]
      hotkey:SetParent(trash)
      hotkey = f:CreateFontString(nil, "ARTWORK", "NumberFontNormalSmallGray")
      hotkey:SetWidth(36)
      hotkey:SetHeight(18)
      hotkey:SetJustifyH("RIGHT")
      hotkey:SetJustifyV("TOP")
      hotkey:SetPoint("TOPLEFT",f,"TOPLEFT",-2,-2)
      f.hotkey = hotkey
      f.icon = _G[name.."Icon"]
      f.flash = _G[name.."Flash"]
      f:SetScript("OnUpdate",OnUpdate)
    end

    self.hotkey = f.hotkey
    self.border = _G[name.."Border"]

    f:SetAttribute("action", config.actionID)
    -- install mind control actions for all buttons just for simplicity
    if self.idx <= 12 then
      f:SetAttribute("*action-mc", 120 + self.idx)
    end
    
    -- set a _childupdate handler, called within the header's context
    f:SetAttribute("_childupdate", 
      -- function _childupdate(self, snippetid, message)
      [[
        local action = "action"
        if doMindControl and GetBonusBarOffset() == 5 then
          action = "*action-mc"
        elseif page and state and page[state] then
          action = "*action-"..page[state]
        end
        local value = self:GetAttribute(action)
        self:SetAttribute("action",value)
      ]])

    -- install drag wrappers to lock buttons 
    bar:GetFrame():WrapScript(f, "OnDragStart",
      -- OnDragStart(self, button, kind, value, ...)
      [[
        if lockButtons and (PlayerInCombat() or not lockButtonsCombat) and not IsModifiedClick("PICKUPACTION") then
          return "clear"
        end
      ]])

    self.frame = f


    -- initialize the hide state
    f:SetAttribute("showgrid",0)
    self:ShowGrid(not barConfig.hideEmpty)
    if ReAction:GetConfigMode() then
      self:ShowGrid(true)
    end

    -- show the ID label if applicable
    self:ShowActionIDLabel(ReAction:GetConfigMode())

    -- attach the keybinder
    KBAttach(self)

    -- attach to skinner
    bar:SkinButton(self,
      {
        HotKey = self.hotkey,
      }
    )

    self:Refresh()
    return self
  end

  function Button:Destroy()
    local f = self.frame
    f:UnregisterAllEvents()
    f:Hide()
    f:SetParent(UIParent)
    f:ClearAllPoints()
    if self.name then
      frameRecycler[self.name] = f
    end
    if self.config.actionID then
      IDAlloc:Release(self.config.actionID)
    end
    if self.config.pageactions then
      for _, id in ipairs(self.config.pageactions) do
        IDAlloc:Release(id)
      end
    end
    self.frame = nil
    self.config = nil
    self.bar = nil
  end

  function Button:Refresh()
    local f = self.frame
    self.bar:PlaceButton(self, 36, 36)
    self:RefreshPages()
  end

  function Button:GetFrame()
    return self.frame
  end

  function Button:GetName()
    return self.name
  end

  function Button:GetConfig()
    return self.config
  end

  function Button:GetActionID(page)
    if page == nil then
      -- get the effective ID
      return self.frame.action -- kept up-to-date by Blizzard's ActionButton_CalculateAction()
    else
      if page == 1 then
        return self.config.actionID
      else
        return self.config.pageactions and self.config.pageactions[page] or self.config.actionID
      end
    end
  end

  function Button:SetActionID( id, page )
    id = tonumber(id)
    page = tonumber(page)
    if id == nil or id < 1 or id > 120 then
      error("Button:SetActionID - invalid action ID")
    end
    if page and page ~= 1 then
      if not self.config.pageactions then
        self.config.pageactions = { }
      end
      if self.config.pageactions[page] then
        IDAlloc:Release(self.config.pageactions[page])
      end
      self.config.pageactions[page] = id
      IDAlloc:Acquire(self.config.pageactions[page])
      self.frame:SetAttribute(("*action-page%d"):format(page),id)
    else
      IDAlloc:Release(self.config.actionID)
      self.config.actionID = id
      IDAlloc:Acquire(self.config.actionID)
      self.frame:SetAttribute("action",id)
      if self.config.pageactions then
        self.config.pageactions[1] = id
        self.frame:SetAttribute("*action-page1",id)
      end
    end
  end

  function Button:RefreshPages( force )
    local nPages = self.barConfig.nPages
    if nPages and (nPages ~= self.nPages or force) then
      local f = self:GetFrame()
      local c = self.config.pageactions
      if nPages > 1 and not c then
        c = { }
        self.config.pageactions = c
      end
      for i = 1, nPages do
        if i > 1 then
          c[i] = IDAlloc:Acquire(c[i], self.config.actionID + (i-1)*self.bar:GetNumButtons())
        else
          c[i] = self.config.actionID  -- page 1 is the same as the base actionID
        end
        f:SetAttribute(("*action-page%d"):format(i),c[i])
      end
      for i = nPages+1, #c do
        IDAlloc:Release(c[i])
        c[i] = nil
        f:SetAttribute(("*action-page%d"):format(i),nil)
      end
      self.nPages = nPages
    end
  end

  function Button:ShowGrid( show )
    if not InCombatLockdown() then
      local f = self.frame
      local count = f:GetAttribute("showgrid")
      if show then
        count = count + 1
      else
        count = count - 1
      end
      if count < 0 then
        count = 0
      end
      f:SetAttribute("showgrid",count)

      if count >= 1 and not f:GetAttribute("statehidden") then
        if LBF then
          LBF:SetNormalVertexColor(self.frame, 1.0, 1.0, 1.0, 0.5)
        else
          self.frame:GetNormalTexture():SetVertexColor(1.0, 1.0, 1.0, 0.5);
        end
        f:Show()
      elseif count < 1 and not HasAction(self:GetActionID()) then
        f:Hide()
      end
    end
  end

  function Button:ShowActionIDLabel( show )
    local f = self:GetFrame()
    if show then
      local id = self:GetActionID()
      if not f.actionIDLabel then
        local label = f:CreateFontString(nil,"OVERLAY","GameFontNormalLarge")
        label:SetAllPoints()
        label:SetJustifyH("CENTER")
        label:SetShadowColor(0,0,0,1)
        label:SetShadowOffset(2,-2)
        f.actionIDLabel = label -- store the label with the frame for recycling

        f:HookScript("OnAttributeChanged", 
          function(frame, attr, value)
            if label:IsVisible() and attr:match("action") then
              label:SetText(tostring(frame.action))
            end
          end)
      end
      f.actionIDLabel:SetText(tostring(id))
      f.actionIDLabel:Show()
    elseif f.actionIDLabel then
      f.actionIDLabel:Hide()
    end
  end

  function Button:DisplayHotkey( key )
    self.hotkey:SetText(key or "")
  end
end