view modules/ReAction_Action/ReAction_Action.lua @ 87:3499ac7c3a9b

Implemented paged actions and mind control actions, and config menus to suit. There's still some sort of bug in the actionID-selection routine, it doesn't always auto-select the IDs that I think it's going to if you resize a bar several times (especially in the presence of multiple pages).
author Flick <flickerstreak@gmail.com>
date Sat, 28 Jun 2008 00:54:21 +0000
parents 502cdb5666e2
children fc83b3f5b322
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). This is done
  by interacting with the built-in State module to map these features to states via the 
  "statebutton" attribute.
--]]

-- local imports
local ReAction = ReAction
local L = ReAction.L
local _G = _G
local CreateFrame = CreateFrame

ReAction:UpdateRevision("$Revision$")

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

-- Button class declaration
local Button = { }

-- private utility --
local function RefreshLite(bar)
  local btns = module.buttons[bar]
  if btns then
    for _, b in ipairs(btns) do
      b:Refresh()
    end
  end
end

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


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

  ReAction:RegisterBarOptionGenerator(self, "GetBarOptions")

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

end

function module:OnEnable()
  ReAction:RegisterBarType(L["Action Bar"], 
    { 
      type = moduleID,
      defaultButtonSize = 36,
      defaultBarRows = 1,
      defaultBarCols = 12,
      defaultBarSpacing = 3
    }, true)
end

function module:OnDisable()
  ReAction:UnregisterBarType(L["Action Bar"])
end

function module:OnRefreshBar(event, bar, name)
  if bar.config.type == moduleID then
    if self.buttons[bar] == nil then
      self.buttons[bar] = { }
    end
    local btns = self.buttons[bar]
    local profile = self.db.profile
    if profile.buttons[name] == nil then
      profile.buttons[name] = {}
    end
    if profile.bars[name] == nil then
      profile.bars[name] = {}
    end
    local btnCfg = profile.buttons[name]
    local barCfg = profile.bars[name]

    local r, c = bar:GetButtonGrid()
    local n = r*c
    if n ~= #btns then
      for i = 1, n do
        if btnCfg[i] == nil then
          btnCfg[i] = {}
        end
        if btns[i] == nil then
          local b = Button:New(bar, i, btnCfg[i], barCfg)
          btns[i] = b
          bar:AddButton(i,b)
        end
      end
      for i = n+1, #btns do
        if btns[i] then
          bar:RemoveButton(btns[i])
          btns[i] = btns[i]:Destroy()
          if btnCfg[i] then
            btnCfg[i] = nil
          end
        end
      end
    end
    RefreshLite(bar)
  end
end

function module:OnDestroyBar(event, bar, name)
  if self.buttons[bar] then
    local btns = self.buttons[bar]
    for _,b in pairs(btns) do
      if b then
        b:Destroy()
      end
    end
    self.buttons[bar] = nil
  end
end

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

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

  b = self.db.profile.bars
  b[newname], b[oldname] = b[oldname], nil
end

function module:OnConfigModeChanged(event, mode)
  for _, bar in pairs(self.buttons) do
    for _, b in pairs(bar) do
      b:ShowGrid(mode)
      b:ShowActionIDLabel(mode)
    end
  end
end


---- Options ----
do
  local Handler = { }

  local options = {
    hideEmpty = {
      name = L["Hide Empty Buttons"],
      order = 1,
      type = "toggle",
      width = "double",
      get  = "GetHideEmpty",
      set  = "SetHideEmpty",
    },
    pages = {
      name  = L["# Pages"],
      desc  = L["Use the Dynamic State tab to specify page transitions"],
      order = 2,
      type  = "range",
      min   = 1,
      max   = 10,
      step  = 1,
      get   = "GetNumPages",
      set   = "SetNumPages",
    },
    actions = {
      name   = L["Edit Action IDs"],
      order  = 13,
      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",
        }
      }
    },
  }

  function module:GetBarOptions(bar)
    return {
      type = "group",
      name = L["Action Buttons"],
      handler = Handler:New(bar),
      hidden = "Hidden",
      args = options
    }
  end

  -- options handler private
  function Handler:New(bar)
    return setmetatable( { bar = bar }, { __index = Handler } )
  end

  function Handler:Hidden()
    return self.bar.config.type ~= moduleID
  end

  function Handler:SetHideEmpty(info, value)
    local c = GetBarConfig(self.bar)
    if value ~= c.hideEmpty then
      for b in self.bar:IterateButtons() do
        b:ShowGrid(not value)
      end
      c.hideEmpty = value
    end
  end

  function Handler:GetHideEmpty()
    return GetBarConfig(self.bar).hideEmpty
  end

  function Handler:GetNumPages()
    return GetBarConfig(self.bar).nPages
  end

  function Handler:SetNumPages(info, value)
    GetBarConfig(self.bar).nPages = value
    RefreshLite(self.bar)
  end

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

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

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

  function Handler: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 Handler: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 Handler:SetSelectedRow(info, value)
    self.selectedRow = value
  end

  function Handler: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 Handler: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 Handler:SetSelectedColumn(info, value)
    self.selectedColumn = value
  end

  function Handler:IsPageSelectHidden()
    return self.editMethod ~= 1 or (GetBarConfig(self.bar).nPages or 1) < 2
  end

  function Handler:GetPageList()
    local n = GetBarConfig(self.bar).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 Handler:GetSelectedPage()
    local p = self.selectedPage or 1
    if p > (GetBarConfig(self.bar).nPages or 1) then
      p = 1
    end
    self.selectedPage = p
    return p
  end

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

  function Handler: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 = module.buttons[self.bar][n]
    if btn then
      return tostring(btn:GetActionID(self.selectedPage or 1))
    end
  end

  function Handler: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 = module.buttons[self.bar][n]
    if btn then
      btn:SetActionID(tonumber(value), self.selectedPage or 1)
    end
  end

  function Handler: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 Handler:IsMultiIDHidden()
    return self.editMethod ~= 2
  end

  function Handler:GetMultiID()
    local p = { }
    for i = 1, GetBarConfig(self.bar).nPages or 1 do
      local b = { }
      for _, btn in ipairs(module.buttons[self.bar]) 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 Handler:SetMultiID(info, value)
    local btns = module.buttons[self.bar]
    local p = ParseMultiID(#btns, GetBarConfig(self.bar).nPages or 1, value)
    for page, b in ipairs(p) do
      for button, id in ipairs(b) do
        btns[button]:SetActionID(id, page)
      end
    end
  end

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


------ State property options ------
do
  local pageOptions = {
    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. Select the 'Mind Control' option for the rule type to enable."],
      order = 11,
      type = "toggle",
      disabled = "IsMCDisabled",
      hidden = "IsPageHidden",
      width = "double",
      set = "SetProp",
      get = "GetProp",
    },
    page = {
      name  = L["Show Page #"],
      order = 12,
      type  = "select",
      width = "half",
      disabled = "IsPageDisabled",
      hidden   = "IsPageHidden",
      values   = "GetPageValues",
      set      = "SetProp",
      get      = "GetPage",
    },
  }

  local function pageImpl( bar, states )
    local map = { }
    for state, c in pairs(states) do
      if c.mindcontrol then
        map[state] = "mc"
      elseif c.page then
        map[state] = ("page%d"):format(c.page)
      end
    end
    bar:SetStateAttribute("statebutton", map, 1, true)
  end

  local PageOptsHandler = { } -- will inherit properties and methods via State:RegisterStateProperty

  function PageOptsHandler:IsMCDisabled(info)
    if self:IsPageHidden() then
      return true
    end
    -- only allow this if the mind-control selector or custom/keybind is chosen
    -- see State.lua for the structure of the 'rule' config element
    local rule = self.states[self:GetName()].rule
    if rule then
      if rule.type == "custom" or rule.type == "keybind" then
        return false
      else
        if rule.values and rule.values.possess then
          return false
        end
      end
    end
    return true
  end

  function PageOptsHandler:IsPageDisabled()
    -- disabled if not an action button
    return not GetBarConfig(self.bar) or
    -- OR mind-control remapping is enabled
      self.states[self:GetName()].mindcontrol or
    -- OR only one page is enabled
      (GetBarConfig(self.bar).nPages and GetBarConfig(self.bar).nPages < 2)
  end

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

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

  function PageOptsHandler:GetPage(info)
    return self:GetProp(info) or 1
  end
  
  ReAction:GetModule("State"):RegisterStateProperty("page", pageImpl, pageOptions, PageOptsHandler)
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

local frameRecycler = { }

------ Button class ------
function Button:New( bar, idx, config, barConfig )
  -- create new self
  self = setmetatable( { }, {__index = Button} )
  self.bar, self.idx, self.config, self.barConfig = bar, idx, config, barConfig

  local name = config.name or ("ReAction_%s_%s_%d"):format(bar:GetName(),moduleID,idx)
  self.name = name
  config.name = name
  local lastButton = module.buttons[bar][#module.buttons[bar]]
  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 (below). Can't set to nil in the global
  -- table because you end up getting taint
  local parent = bar:GetButtonFrame()
  local f = frameRecycler[name]
  if f then
    f:SetParent(parent)
  else
    f = CreateFrame("CheckButton", name, parent, "ActionBarButtonTemplate")
  end

  f:SetAttribute("action", config.actionID)
  -- install mind control action support for all buttons here just for simplicity
  if self.idx <= 12 then
    f:SetAttribute("*action-mc", 120 + self.idx)
  end

  self.frame = f
  self.normalTexture = getglobal(format("%sNormalTexture",f:GetName()))

  -- 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())

  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.pages 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)
  if self.barConfig.mckeybinds then
    f:SetAttribute("bindings-mc", self.barConfig.mckeybinds[self.idx])
  end
  self:RefreshPages()
end

function Button:GetFrame()
  return self.frame
end

function Button:GetName()
  return self.name
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
    -- new in 2.4.1: can't call ActionButton_ShowGrid/HideGrid because they won't update the attribute
    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
      self.normalTexture:SetVertexColor(1.0, 1.0, 1.0, 0.5);
      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 == "state-parent" or 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