--========================================================-- -- Scorpio UIDropDownMenu Widget -- -- -- -- Author : kurapica125@outlook.com -- -- Create Date : 2020/03/05 -- --========================================================-- --========================================================-- Scorpio "Scorpio.Widget.UIDropDownMenu" "1.0.0" --========================================================-- local DELAY_TO_HIDE = 1.5 local _UIDropDownListLevel = {} local _UIDropDownMenuButton = {} local _UIDropDownCountDown = {} local function autoHideMenuList() while #_UIDropDownListLevel > 0 do local mouseover = #_UIDropDownListLevel while mouseover > 0 and not (_UIDropDownListLevel[mouseover]:IsMouseOver() or mouseover > 1 and _UIDropDownMenuButton[mouseover]:IsMouseOver()) do mouseover = mouseover - 1 end local now = GetTime() -- Refreshing the hide delay for i = 1, mouseover do _UIDropDownCountDown[i] = now + DELAY_TO_HIDE end for i = mouseover + 1, #_UIDropDownListLevel do if _UIDropDownCountDown[i] then if _UIDropDownCountDown[i] <= now then for j = #_UIDropDownListLevel, i, -1 do _UIDropDownListLevel[j]:Hide() _UIDropDownListLevel[j] = nil _UIDropDownMenuButton[j]= nil _UIDropDownCountDown[j] = nil end break end else _UIDropDownCountDown[i] = _UIDropDownCountDown[i - 1] or (now + DELAY_TO_HIDE) end end Next() end end local function adjustLocation(self, owner, anchor) anchor = anchor or "ANCHOR_TOPRIGHT" local scale = self:GetEffectiveScale() local width = self:GetWidth() * scale local height = self:GetHeight() * scale local x, y if owner then local ownerScale = owner:GetEffectiveScale() if anchor == "ANCHOR_TOPRIGHT" then x, y = owner:GetRight() * ownerScale, owner:GetTop() * ownerScale elseif anchor == "ANCHOR_RIGHT" then x, y = owner:GetRight() * ownerScale, select(2, owner:GetCenter()) * ownerScale elseif anchor == "ANCHOR_BOTTOMRIGHT" then x, y = owner:GetRight() * ownerScale - width, owner:GetBottom() * ownerScale elseif anchor == "ANCHOR_TOPLEFT" then x, y = owner:GetLeft() * ownerScale - width, owner:GetTop() * ownerScale elseif anchor == "ANCHOR_LEFT" then x, y = owner:GetLeft() * ownerScale - width, select(2, owner:GetCenter()) * ownerScale elseif anchor == "ANCHOR_BOTTOMLEFT" then x, y = owner:GetLeft() * ownerScale, owner:GetBottom() * ownerScale end end if not (x and y) then x, y = GetCursorPosition() end if self.OwnerOffset then x = x + self.OwnerOffset.x * scale y = y + self.OwnerOffset.y * scale end x = math.min(math.max(x, 0), GetScreenWidth() * scale - width) y = math.max(y, height) self:ClearAllPoints() self:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x / scale, y / scale) end local function showMenuList(self) if #_UIDropDownListLevel == 0 then -- Start the scan, don't use OnEnter/OnLeave since it's too hard to keeping tracking Next(autoHideMenuList) end -- Reset the count down wipe(_UIDropDownCountDown) if _UIDropDownListLevel[1] == self then return end for i = #_UIDropDownListLevel, 1, -1 do _UIDropDownListLevel[i]:Hide() _UIDropDownListLevel[i] = nil _UIDropDownMenuButton[i]= nil end _UIDropDownListLevel[1] = self for _, child in UIObject.GetChilds(self) do if Class.IsObjectType(child, UIDropDownMenuButton) then child.MenuLevel = 2 child:SetFrameLevel(self:GetFrameLevel() + 2) end end adjustLocation(self, self.Owner, self.Anchor) self:Show() end local function showSubList(self) local level = self.MenuLevel local submenu = self.SubMenu if not (level and submenu and self:IsShown()) then return end if _UIDropDownListLevel[level] == submenu then return end submenu:SetFrameLevel(self:GetFrameLevel() + 2) for i = #_UIDropDownListLevel, level, -1 do _UIDropDownListLevel[i]:Hide() _UIDropDownListLevel[i] = nil _UIDropDownCountDown[i] = nil _UIDropDownMenuButton[i]= nil end _UIDropDownListLevel[level] = submenu _UIDropDownMenuButton[level]= self for _, child in UIObject.GetChilds(submenu) do if Class.IsObjectType(child, UIDropDownMenuButton) then child.MenuLevel = level + 1 child:SetFrameLevel(submenu:GetFrameLevel() + 2) end end adjustLocation(submenu, self) submenu:Show() end local function closeMenuList(self) if not self or _UIDropDownListLevel[1] == self then for i = #_UIDropDownListLevel, 1, -1 do _UIDropDownListLevel[i]:Hide() _UIDropDownListLevel[i] = nil _UIDropDownCountDown[i] = nil _UIDropDownMenuButton[i]= nil end end end local function closeSubList(self) local level = self.MenuLevel local submenu = self.SubMenu if not (level and submenu and _UIDropDownListLevel[level] == submenu) then return end for i = #_UIDropDownListLevel, level, -1 do _UIDropDownListLevel[i]:Hide() _UIDropDownListLevel[i] = nil _UIDropDownCountDown[i] = nil _UIDropDownMenuButton[i]= nil end end local function tryCloseMenuList() return Next(closeMenuList) end --- Secure hook the gobal mouse event to auto close the drop down list if Scorpio.IsRetail then __SecureHook__() function UIDropDownMenu_HandleGlobalMouseEvent(button, event) -- must use the mouse up here if event == "GLOBAL_MOUSE_UP" and (button == "LeftButton" or button == "RightButton") then return Next(closeMenuList) end end tryCloseMenuList = function() end end ----------------------------------------------------------- -- UIDropDownMenu Widget -- ----------------------------------------------------------- --- The UI drop down menu button template __Sealed__() class "UIDropDownMenuButton" (function(_ENV) inherit "Button" export { IsObjectType = Class.IsObjectType, GetChilds = UIObject.GetChilds } local function refreshCheckState(self) self:GetChild("Check"):Hide() self:GetChild("UnCheck"):Hide() self:GetChild("RadioCheck"):Hide() self:GetChild("RadioUnCheck"):Hide() if self.IsCheckButton then if self:GetParent() and self:GetParent().IsMultiCheck then if self.Checked then self:GetChild("Check"):Show() else self:GetChild("UnCheck"):Show() end else if self.Checked then self:GetChild("RadioCheck"):Show() else self:GetChild("RadioUnCheck"):Show() end end end end local function refreshIcon(self) local icon = self:GetChild("DisplayIcon") if self.MouseOverIcon and self:IsMouseOver() then icon:SetTexture(self.MouseOverIcon) icon:Show() elseif self.Icon then icon:SetTexture(self.Icon) icon:Show() else icon:Hide() end end -- The event handlers local function OnEnter(self) local color = Color.NORMAL self:GetChild("ColorSwatch"):SetVertexColor(color.r, color.g, color.b) self:GetChild("Highlight"):Show() refreshIcon(self) if self.TooltipTitle then GameTooltip:SetOwner(self, "ANCHOR_RIGHT") GameTooltip_SetTitle(GameTooltip, self.TooltipTitle) if self.TooltipText then GameTooltip_AddNormalLine(GameTooltip, self.TooltipText, true) end GameTooltip:Show() end if not self.Disabled and self.SubMenu then showSubList(self) end end local function OnLeave(self) local color = Color.HIGHLIGHT self:GetChild("ColorSwatch"):SetVertexColor(color.r, color.g, color.b) self:GetChild("Highlight"):Hide() refreshIcon(self) GameTooltip:Hide() end local function OnClick(self) if self.Disabled then return true end if self.IsColorButton then local color = self.Color Scorpio.Continue(function() OnColorChoosed(self, Scorpio.PickColor(color)) end) tryCloseMenuList() -- Block the custom onclick return true elseif self.IsCheckButton then if self:GetParent().IsMultiCheck then self.Checked = not self.Checked OnCheckStateChanged(self, self.Checked) elseif not self.Checked then self.Checked = true self:GetParent():OnCheckStateChanged(self.CheckValue) end tryCloseMenuList() return true elseif self.SubMenu then if self.SubMenu:IsShown() then closeSubList(self) else showSubList(self) end return true end tryCloseMenuList() end local function InvisibleButton_OnEnter(self) return OnEnter(self:GetParent()) end local function InvisibleButton_OnLeave(self) return OnLeave(self:GetParent()) end ------------------------------------------------------- -- Event -- ------------------------------------------------------- --- Fired when the color is choosed event "OnColorChoosed" --- Fired when the check state changes event "OnCheckStateChanged" ------------------------------------------------------- -- Property -- ------------------------------------------------------- --- Whether the button is a check button property "IsCheckButton" { type = Boolean, handler = refreshCheckState } --- Whether the button is used for choosing color property "IsColorButton" { type = Boolean, handler = function(self, val) if val then self:GetChild("ColorSwatch"):Show() self:GetChild("ColorSwatchBG"):Show() else self:GetChild("ColorSwatch"):Hide() self:GetChild("ColorSwatchBG"):Hide() end end } --- Whether the button is checked property "Checked" { type = Boolean, handler = refreshCheckState } --- The check value of the menu button property "CheckValue" { type = Any } --- The color property used to hold the color value __Set__(PropertySet.Clone) property "Color" { type = ColorType, handler = function(self, color) if color then self:GetChild("ColorSwatch"):SetColorTexture(color.r, color.g, color.b) else self:GetChild("ColorSwatch"):SetColorTexture(1, 1, 1) end end, } --- The sub menu list property "SubMenu" { type = UIObject, handler = function(self, val) if val then self:GetChild("ExpandArrow"):Show() else self:GetChild("ExpandArrow"):Hide() end end } --- The icon property "Icon" { type = String + Number, handler = refreshIcon } --- The mouse over icon property "MouseOverIcon" { type = String + Number, handler = refreshIcon } --- The tooltip title property "TooltipTitle" { type = String } --- The tooltip text property "TooltipText" { type = String } --- Whether disable the menu button property "Disabled" { type = Boolean, handler = function(self, value) if value then self:Disable() self:GetChild("InvisibleButton"):Show() else self:Enable() self:GetChild("InvisibleButton"):Hide() end end, } --- The menu level property "MenuLevel" { type = Number } --- AsSeparator property "AsSeparator" { type = Boolean, handler = function(self, val) self:GetChild("Separator"):SetShown(val) end } ------------------------------------------------------- -- Method -- ------------------------------------------------------- --- To prevent the auto drop down hidden for sub menu button function HandlesGlobalMouseEvent(self, buttonID, event) return self.SubMenu and buttonID == "LeftButton" end ------------------------------------------------------- -- Constructor -- ------------------------------------------------------- __Template__{ Highlight = Texture, Check = Texture, UnCheck = Texture, RadioCheck = Texture, RadioUnCheck = Texture, DisplayIcon = Texture, ExpandArrow = Texture, ColorSwatchBG = Texture, ColorSwatch = Texture, InvisibleButton = Button, Separator = Line, } function __ctor(self) self:GetChild("Highlight"):Hide() self:GetChild("Check"):Hide() self:GetChild("UnCheck"):Hide() self:GetChild("RadioCheck"):Hide() self:GetChild("RadioUnCheck"):Hide() self:GetChild("DisplayIcon"):Hide() self:GetChild("ColorSwatch"):Hide() self:GetChild("ColorSwatchBG"):Hide() self:GetChild("ExpandArrow"):Hide() self:GetChild("InvisibleButton"):Hide() self:GetChild("Separator"):Hide() self.OnEnter = self.OnEnter + OnEnter self.OnLeave = self.OnLeave + OnLeave self.OnClick = self.OnClick + OnClick self:GetChild("InvisibleButton"):SetScript("OnEnter", InvisibleButton_OnEnter) self:GetChild("InvisibleButton"):SetScript("OnLeave", InvisibleButton_OnLeave) end end) --- The drop down list template __Sealed__() class "UIDropDownMenuList" (function(_ENV) inherit "Button" --- The check state change event, only triggered when IsMultiCheck is false event "OnCheckStateChanged" --- The owner of the drop down list property "Owner" { type = UI } --- The Anchor to the owner property "Anchor" { type = AnchorType, default = "ANCHOR_TOPRIGHT" } --- Whether the check menu buttons on the list is multi choosable property "IsMultiCheck" { type = Boolean, default = true } --- The offsets from the frame's edges used to limit the menu buttons property "ContainerInsets" { type = Inset } --- The x & y offsets to the owner property "OwnerOffset" { type = Dimension } end) --- The drop down list menu template __Sealed__() class "UIDropDownList" { UIDropDownMenuList } ----------------------------------------------------------- -- UIDropDownMenu Style -- ----------------------------------------------------------- Style.UpdateSkin("Default", { [UIDropDownMenuButton] = { size = Size(100, 16), normalFont = GameFontHighlightSmallLeft, highlightFont = GameFontHighlightSmallLeft, disabledFont = GameFontDisableSmallLeft, ButtonText = { location = { Anchor("LEFT", 18, 0), Anchor("RIGHT", -24, 0) }, }, -- Layer Highlight = { drawLayer = "BACKGROUND", file = [[Interface\QuestFrame\UI-QuestTitleHighlight]], alphaMode = "ADD", location = { Anchor("TOPLEFT"), Anchor("BOTTOMRIGHT") }, }, Check = { drawLayer = "ARTWORK", file = [[Interface\Buttons\UI-CheckBox-Check]], size = Size(16, 16), location = { Anchor("LEFT") }, }, UnCheck = { drawLayer = "ARTWORK", file = [[Interface\Buttons\UI-CheckBox-Up]], size = Size(16, 16), location = { Anchor("LEFT") }, }, RadioCheck = { drawLayer = "ARTWORK", file = [[Interface\Common\UI-DropDownRadioChecks]], size = Size(16, 16), location = { Anchor("LEFT") }, texCoords = RectType(0, 0.5, 0.5, 1.0), }, RadioUnCheck = { drawLayer = "ARTWORK", file = [[Interface\Common\UI-DropDownRadioChecks]], size = Size(16, 16), location = { Anchor("LEFT") }, texCoords = RectType(0.5, 1.0, 0.5, 1.0), }, DisplayIcon = { drawLayer = "ARTWORK", size = Size(16, 16), location = { Anchor("RIGHT") }, }, ColorSwatchBG = { drawLayer = "ARTWORK", size = Size(16, 16), location = { Anchor("RIGHT", -6, 0) }, file = [[Interface\ChatFrame\ChatFrameColorSwatch]], }, ColorSwatch = { drawLayer = "OVERLAY", size = Size(14, 14), location = { Anchor("CENTER", 0, 0, "ColorSwatchBG") }, }, ExpandArrow = { drawLayer = "ARTWORK", file = [[Interface\ChatFrame\ChatFrameExpandArrow]], size = Size(16, 16), location = { Anchor("RIGHT", 0, 0) }, }, InvisibleButton = { location = { Anchor("TOPLEFT"), Anchor("BOTTOMLEFT"), Anchor("RIGHT", 0, 0, "ColorSwatch", "LEFT") }, }, Separator = { startPoint = Anchor("LEFT", 0, 0), endPoint = Anchor("RIGHT", 0, 0), thickness = 2, color = Color.DISABLED, }, }, [UIDropDownMenuList] = { frameStrata = "FULLSCREEN_DIALOG", enableMouse = true, Toplevel = true, backdrop = { bgFile = [[Interface\Tooltips\UI-Tooltip-Background]], edgeFile = [[Interface\Tooltips\UI-Tooltip-Border]], tile = true, tileSize = 16, edgeSize = 16, insets = { left = 5, right = 4, top = 4, bottom = 4 } }, backdropBorderColor = Color(1, 1, 1), backdropColor = Color(0.09, 0.09, 0.19), containerInsets = Inset(8, 8, 8, 8), }, [UIDropDownList] = { backdrop = { bgFile = [[Interface\DialogFrame\UI-DialogBox-Background]], edgeFile = [[Interface\DialogFrame\UI-DialogBox-Border]], tile = true, tileSize = 32, edgeSize = 32, insets = { left = 11, right = 12, top = 12, bottom = 11 } }, backdropColor = NIL, containerInsets = Inset(16, 16, 16, 16), ownerOffset = Dimension(0, 0), }, }) ----------------------------------------------------------- -- UIDropDownMenu API -- ----------------------------------------------------------- local _ButtonHolder = CreateFrame("Frame") _ButtonHolder:Hide() local rycDropDownMenuButtons = Recycle(UIDropDownMenuButton, "Scorpio_UIDropDownMenuButton%d", _ButtonHolder) local rycDropDownMenuLists = Recycle(UIDropDownMenuList, "Scorpio_UIDropDownMenuList%d") local rycDropDownLists = Recycle(UIDropDownList, "Scorpio_UIDropDownList%d") function rycDropDownMenuButtons:OnInit(obj) return obj:InstantApplyStyle() end function rycDropDownMenuLists:OnInit(obj) return obj:InstantApplyStyle() end function rycDropDownLists:OnInit(obj) return obj:InstantApplyStyle() end local function refreshMenuSize(self) local insets = self.ContainerInsets local offset = -(insets and insets.top or 0) local leftoff = insets and insets.left or 0 local rightoff = -(insets and insets.right or 0) local maxw = 0 local child for i = 1, #self do child = self[i] child:ClearAllPoints() child:SetPoint("LEFT", leftoff, 0) child:SetPoint("RIGHT", rightoff, 0) child:SetPoint("TOP", 0, offset) offset = offset - child:GetHeight() local ft = child:GetFontString() if ft then local leftw = 0 local rightw = 0 for i = 1, ft:GetNumPoints() do local p, f, r, x, y = ft:GetPoint(i) if f and IsSameUI(f, child) and p and r then if p:match("LEFT") and r:match("LEFT") then leftw = x elseif p:match("RIGHT") and r:match("RIGHT") then rightw = - x end end end maxw = math.max(maxw, (ft:GetStringWidth() or 0) + leftw + rightw) end if child.SubMenu then refreshMenuSize(child.SubMenu) end end maxw = maxw + (insets and (insets.left + insets.right) or 0) offset = math.abs(offset) + (insets and insets.bottom or 0) local minwidth, minheight = self:GetMinResize() local maxwidth, maxheight = self:GetMaxResize() if maxwidth == 0 then maxwidth = nil end if maxheight == 0 then maxheight = nil end maxw = math.min(math.max(maxw, minwidth or 0), maxwidth or math.huge) offset = math.min(math.max(offset, minheight or 0), maxheight or math.huge) self:SetWidth(maxw) self:SetHeight(offset) end local function recycleMenu(self) if self.CallbackOnClose then Continue(self.CallbackOnClose) end for i = #self, 1, -1 do local button = self[i] if button.SubMenu then recycleMenu(button.SubMenu) button.SubMenu = nil end button:SetParent(_ButtonHolder) button:ClearAllPoints() rycDropDownMenuButtons(button) self[i] = nil end self.OnHide = nil self.CallbackOnClose = nil if Class.IsObjectType(self, UIDropDownList) then rycDropDownLists(self) else rycDropDownMenuLists(self) end end local function buildDropDownMenuList(info, dropdown, root) local menu = dropdown and rycDropDownLists() or rycDropDownMenuLists() if root then menu.Owner = info.owner menu.Anchor = info.anchor menu.OnHide = recycleMenu if info.close then menu.CallbackOnClose= info.close end else menu.OnHide = nil end local checkvalue if info.check then menu.IsMultiCheck = false menu.OnCheckStateChanged= function(self, val) return info.check.set(val) end if type(info.check.get) == "function" then checkvalue = info.check.get() else checkvalue = info.check.get end else menu.IsMultiCheck = true menu.OnCheckStateChanged= nil end for i, binfo in ipairs(info) do local button = rycDropDownMenuButtons() button:SetParent(menu) button:SetID(i) menu[i] = button button:SetText(binfo.text) if binfo.color then button.IsColorButton= true local value if type(binfo.color.get) == "function" then value = binfo.color.get() else value = binfo.color.get end value = value and Struct.ValidateValue(ColorType, value) if value then button.Color = value end button.OnColorChoosed = function(self, color) return binfo.color.set(color) end else button.IsColorButton= nil button.OnColorChoosed = nil end if not menu.IsMultiCheck and binfo.checkvalue ~= nil then button.IsCheckButton= true button.CheckValue = binfo.checkvalue button.Checked = checkvalue == binfo.checkvalue button.OnCheckStateChanged = nil elseif binfo.check then button.IsCheckButton= true local value if type(binfo.check.get) == "function" then value = binfo.check.get() else value = binfo.check.get end button.Checked = value and true or false button.OnCheckStateChanged = function(self, checked) return binfo.check.set(checked) end else button.IsCheckButton= false button.OnCheckStateChanged = nil end if binfo.click then button.OnClick = function(self) return Continue(binfo.click) end else button.OnClick = nil end if binfo.disabled then button.Disabled = true else button.Disabled = false end button.Icon = binfo.icon button.MouseOverIcon = binfo.mouseOverIcon button.TooltipTitle = binfo.tiptitle button.TooltipText = binfo.tiptext button.AsSeparator = binfo.separator if binfo.submenu then button.SubMenu = buildDropDownMenuList(binfo.submenu, dropdown) end end return menu end struct "UIDropDownMenuInfo" {} __Sealed__() struct "UIDropDownMenuButtonInfo" { { name = "text", type = String }, { name = "color", type = PropertyAccessor }, { name = "check", type = PropertyAccessor }, { name = "checkvalue", type = Any }, { name = "click", type = Function }, { name = "disabled", type = Boolean }, { name = "submenu", type = UIDropDownMenuInfo }, { name = "separator", type = Boolean }, { name = "icon", type = String + Number }, { name = "mouseovericon", type = String + Number }, { name = "tiptitle", type = String }, { name = "tiptext", type = String }, } __Sealed__() struct "UIDropDownMenuInfo" { { name = "dropdown",type = Boolean }, { name = "owner", type = UIObject }, { name = "anchor", type = AnchorType }, { name = "check", type = PropertyAccessor }, { name = "close", type = Function }, function (self) if #self == 0 then return "%s must contain menu button settings" end for i = 1, #self do local val, message = Struct.ValidateValue(UIDropDownMenuButtonInfo, self[i]) if message then return message:gsub("%%s", "%%s[" .. i .. "]") end end end } --- The API to show the drop down menu __Static__() __Async__() __Arguments__{ UIDropDownMenuInfo } function Scorpio.ShowDropDownMenu(info) local menu = buildDropDownMenuList(info, info.dropdown and info.owner and true or false, true) Next() Next() refreshMenuSize(menu) return showMenuList(menu) end __Static__() Scorpio.CloseDropDownMenu = closeMenuList