--------------------------------------------------------------- -- Radial handler for pie menus --------------------------------------------------------------- -- This handler configures headers as pie menus, providing API -- to convert an angle in deg. to a menu index and vice versa. -- Keystrokes and specific stick inputs are interrupted by the -- handler and dispatched to the display layer on the header. local Radial, Dispatcher, RadialMixin, _, db = CPAPI.EventHandler(ConsolePortRadial), CreateFrame('Frame'), {}, ...; Mixin(Radial, CPAPI.SecureEnvironmentMixin).Headers = {}; db:Register('Radial', Radial):Execute([[ ---------------------------------------------------------- HEADERS = newtable() -- maintain references to headers STIX = newtable() -- track config name to stick ID BTNS = newtable() -- track config ID to bind name MODS = newtable() -- track modifiers ---------------------------------------------------------- ]]) --------------------------------------------------------------- -- Dispatcher --------------------------------------------------------------- -- This frame is necessary to intercept the stick input, and -- needs to be insecure to propagate input based on stickID. function Dispatcher:OnGamePadStick(stick, x, y, len) local this = self.focusFrame if this and this.interrupt[stick] then if this.intercept[stick] and not self.disabled then this:OnInput(x, y, len, stick) end return self:SetPropagateKeyboardInput(false) end return self:SetPropagateKeyboardInput(true) end function Dispatcher:SetFocus(frame) self.focusFrame = frame; self:EnableGamePadStick(true) end function Dispatcher:ClearFocus(frame) if self.focusFrame ~= frame then return end; self.disabled = true C_Timer.After(db('radialClearFocusTime'), self.Disable) end function Dispatcher:ClearFocusInstantly(frame) if self.focusFrame ~= frame then return end; self.Disable() end function Dispatcher:IsDisabling() return self.disabled end function Dispatcher.Disable() -- callback Dispatcher.disabled = nil; Dispatcher.focusFrame = nil; Dispatcher:EnableGamePadStick(false); end CPAPI.Start(Dispatcher) Dispatcher:SetPropagateKeyboardInput(true) Dispatcher:EnableGamePadStick(false) --------------------------------------------------------------- -- RadialMixin, for headers registered as radials --------------------------------------------------------------- RadialMixin.Env = { GetIndex = [[ local stickID, size = ... return radial:RunAttribute( 'GetIndexForStickPosition', stickID or self:GetAttribute('stick'), size or UpdateSize and self:Run(UpdateSize) or self:GetAttribute('size') ); ]]; GetButtonsHeld = [[ return radial:RunAttribute('GetButtonsHeld') ]]; GetModifiersHeld = [[ return radial:RunAttribute('GetModifiersHeld') ]]; SpaceEvenly = [[ local children = newtable(self:GetChildren()) local radius = math.sqrt(self:GetWidth() * self:GetHeight()) / 2 local count = #children self:SetAttribute('size', count) for i, child in ipairs(children) do child:ClearAllPoints() child:SetPoint('CENTER', radial:RunAttribute('GetPointForIndex', i, count, radius)) end ]]; SetBinding = [[ local btn, mod = ... self:SetBindingClick(true, ((mod or '')..btn):upper(), self, btn) self:CallMethod('OnBindingSet', btn, mod) ]]; SetBindingsForTriggers = [[ local mods = newtable(self:Run(GetModifiersHeld)) local btns = newtable(self:Run(GetButtonsHeld)) table.sort(mods) mods[#mods+1] = table.concat(mods) for _, btn in ipairs(btns) do self:Run(SetBinding, btn) for _, mod in ipairs(mods) do self:Run(SetBinding, btn, mod) end end return #btns > 0; ]]; } function RadialMixin:SetInterrupt(sticks) -- sets the sticks that should be interrupted self.interrupt = sticks and tInvert(sticks) or {} end function RadialMixin:SetIntercept(sticks) -- sets the stick(s) that should be processed, -- 1st argument is used implicitly in secure env self.intercept = sticks and tInvert(sticks) or {} self:SetAttribute('stick', sticks and sticks[1]) end function RadialMixin:SetDynamicSizeFunction(body) self:SetAttribute('UpdateSize', body .. [[ self:SetAttribute('size', size) return size; ]]) self:Execute('UpdateSize = self:GetAttribute("UpdateSize")') end function RadialMixin:GetPointForIndex(index, size, radius) return 'CENTER', Radial:GetPointForIndex(index, size or self:GetAttribute('size'), radius or (self:GetWidth() / 2)) end function RadialMixin:GetIndexForPos(x, y, len, size) return Radial:GetIndexForStickPosition(x, y, len, size or self:GetAttribute('size')) end function RadialMixin:GetValidThreshold() return Radial.VALID_VEC_LEN or .5; end function RadialMixin:OnLoad(data) self:CreateEnvironment() self:SetInterrupt(data.sticks) self:SetIntercept(data.target) self:SetDynamicSizeFunction(data.sizer) if data.clicks then self:RegisterForClicks(data.clicks) end return self end function RadialMixin:OnShow() Dispatcher:SetFocus(self) end function RadialMixin:ClearInstantly() Dispatcher:ClearFocusInstantly(self) end function RadialMixin:OnHide() if not Dispatcher:IsDisabling() then Dispatcher:ClearFocus(self) end end function RadialMixin:OnInput(x, y, len, stick) -- replace with callback end function RadialMixin:OnBindingSet(btn, mod) -- replace with callback end --------------------------------------------------------------- -- Restricted pie slicer --------------------------------------------------------------- Radial:CreateEnvironment({ -- @param a1 : number [0-360], first angle -- @param a2 : number [0-360], second angle -- @return diff : number, difference between angles GetAngleDistance = [[ local a1, a2 = ... return (180 - math.abs(math.abs(a1 - a2) - 180)); ]]; -- @param index : number [1,n], the index -- @param size : number [n>0], how many indices -- @return angle : number [0-360], angle GetAngleForIndex = [[ local index, size = ... local step = 360 / size return ((ANGLE_IDX_ONE + ((index - 1) * step)) % 360) ]]; -- @param angle : number [0,360], the angle -- @param size : number [n>0], how many indices -- @return index : number [1,n], the slot on the pie GetIndexForAngle = [[ local angle, size = ... local step = 360 / size if (angle % step) > 0 then return end local index = (((angle % 360) / step) - (ANGLE_IDX_ONE / step) + 1) return (index < 0 and index + size) or (index > 0 and index) or (size) ]]; -- @param index : number [1,n], the index -- @param size : number [n>0], how many indices -- @param radius : number, multiplier for size (usually half of frame) -- @return x : number, the X-position from CENTER -- @return y : number, the Y-position from CENTER GetPointForIndex = [[ local index, size, radius = ... local angle = self:Run(GetAngleForIndex, index, size) return self:Run(GetPointForAngle, angle, radius) ]]; -- @param angle : number[0, 360], the angle -- @param radius : number, multiplier for size -- @return x : number, the X-position from origin -- @return y : number, the Y-position from origin GetPointForAngle = [[ local angle, radius = ... return COS_DELTA * (radius * cos(angle)), (radius * sin(angle)) ]]; -- @param id : numberID or name -- @return x : number [-1,1], X-position -- @return y : number [-1,1], Y-position -- @return len : number [0,1], length of vector GetStickPosition = [[ local id = ... local gstate = GetGamePadState() local sticks = gstate and gstate.sticks if not sticks then return end local pos = sticks[ tonumber(id) or STIX[id] ] if not pos then return end return pos.x, pos.y, pos.len ]]; -- @param stickID : numberID or name -- @param size : number, how many indices -- @return index : number, the slot on the pie GetIndexForStickPosition = [[ local stickID, size = ... local x, y, len = self:Run(GetStickPosition, stickID) if not len or len < VALID_VEC_LEN then return end local angle = math.deg(math.atan2(x, y)) + ANGLE_IDX_ONE angle = ((angle % 360) + 360) % 360; local offset, index = math.huge for i=1, size do local comp = self:Run(GetAngleForIndex, i, size) local distance = self:Run(GetAngleDistance, angle, comp) if distance < offset then offset, index = distance, i end end return index ]]; -- @return buttons : list of buttons held GetButtonsHeld = [[ local gstate = GetGamePadState() local buttons = gstate and gstate.buttons if not buttons then return end local result = newtable() for id, held in ipairs(buttons) do if held and BTNS[id] and not MODS[ BTNS[id] ] then result[#result+1] = BTNS[id] end end return unpack(result) ]]; -- @return modifiers : list of modifiers held (with suffix) GetModifiersHeld = [[ local gstate = GetGamePadState() local buttons = gstate and gstate.buttons if not buttons then return end local result = newtable() for id, held in ipairs(buttons) do if held and BTNS[id] then result[#result+1] = MODS[ BTNS[id] ] end end return unpack(result) ]]; }) --------------------------------------------------------------- -- Radial handler API --------------------------------------------------------------- function Radial:Register(header, name, ...) header:SetFrameRef('radial', self) header:Execute('radial = self:GetFrameRef("radial")') self.Headers[header] = true; self:SetFrameRef(name, header) self:Execute(('HEADERS["%s"] = self:GetFrameRef("%s")'):format(name, name)) -- upvalue in case predefined methods should be mixed in post load local OnInput, OnBindingSet = header.OnInput, header.OnBindingSet; db('table/mixin')(header, RadialMixin) if OnInput then header.OnInput = OnInput; end if OnBindingSet then header.OnBindingSet = OnBindingSet; end; header:SetScale(db('radialScale')) db:RegisterSafeCallback('Settings/radialScale', header.SetScale, header) return header:OnLoad(...) end function Radial:OnDataLoaded() for attr, val in pairs({ ANGLE_IDX_ONE = 90; VALID_VEC_LEN = 1 - db('Settings/radialActionDeadzone'); -- vector length for valid action COS_DELTA = -db('Settings/radialCosineDelta'); -- delta for the cosine value }) do self:Execute(('%s = %f;'):format(attr, val)) self[attr] = val end return self end function Radial:OnActiveDeviceChanged() self:Execute('wipe(STIX)') for id, name in db:For('Gamepad/Index/Stick/ID') do self:Execute(('STIX["%s"] = %d'):format(name, id)) end local modifiers = db('Gamepad/Index/Modifier/Active') local modkeys = tInvert(modifiers) self:Execute('wipe(BTNS)') for id, set in db:For('Gamepad/Index/Button/Binding') do if not id:match('STICK') then self:Execute(([[ BTNS[%d] = "%s"; BTNS["%s"] = %d; ]]):format(set.ID+1, id, id, set.ID+1)) end end self:Execute('wipe(MODS)') for modifier, key in pairs(modifiers) do if key ~= true then self:Execute(('MODS["%s"] = "%s"'):format(key, modifier)); end end return self end function Radial:GetStickStruct(type) return ({ Movement = {'Left', 'Movement'}; Camera = {'Right', 'Camera'}; Gyro = {'Gyro'}; })[type] end --------------------------------------------------------------- -- Unrestricted data access --------------------------------------------------------------- function Radial:GetAngleDistance(a1, a2) return (180 - math.abs(math.abs(a1 - a2) - 180)); end function Radial:GetAngleForIndex(index, size) local step = 360 / size return ((self.ANGLE_IDX_ONE + ((index - 1) * step)) % 360) end function Radial:GetPointForIndex(index, size, radius) local angle = self:GetAngleForIndex(index, size) return self.COS_DELTA * (radius * cos(angle)), (radius * sin(angle)) end function Radial:GetIndexForStickPosition(x, y, len, size) if not len or len < self.VALID_VEC_LEN then return end local angle = math.deg(math.atan2(x, y)) + self.ANGLE_IDX_ONE angle = ((angle % 360) + 360) % 360; local offset, index = math.huge for i=1, size do local distance = self:GetAngleDistance(angle, self:GetAngleForIndex(i, size)) if distance < offset then offset, index = distance, i end end return index end --------------------------------------------------------------- -- Set environment on handler and feed stick data --------------------------------------------------------------- RadialMixin.CreateEnvironment = Radial.CreateEnvironment; --------------------------------------------------------------- db:RegisterSafeCallback('Gamepad/Active', Radial.OnActiveDeviceChanged, Radial) db:RegisterSafeCallback('Settings/radialActionDeadzone', Radial.OnDataLoaded, Radial) db:RegisterSafeCallback('Settings/radialCosineDelta', Radial.OnDataLoaded, Radial)