-------------------------------------------------------------------------------- -- Nanami-UI: KeyBind Manager -- Profile-based keybinding management (save/load/delete/rename/export/import) -- Covers ALL keybindings, not just action bars. -------------------------------------------------------------------------------- SFrames.KeyBindManager = {} local KBM = SFrames.KeyBindManager -------------------------------------------------------------------------------- -- Data helpers -------------------------------------------------------------------------------- local function EnsureDB() if not SFramesGlobalDB then SFramesGlobalDB = {} end if not SFramesGlobalDB.KeyBindProfiles then SFramesGlobalDB.KeyBindProfiles = {} end end local function GetCharName() return UnitName("player") or "Unknown" end local function GetTimestamp() return time and time() or 0 end -------------------------------------------------------------------------------- -- Collect / Apply bindings -------------------------------------------------------------------------------- function KBM:CollectAllBindings() local result = {} local n = GetNumBindings() for i = 1, n do local command, key1, key2 = GetBinding(i) if command and (key1 or key2) then table.insert(result, { command = command, key1 = key1, key2 = key2, }) end end return result end function KBM:ClearAllBindings() local n = GetNumBindings() for i = 1, n do local command, key1, key2 = GetBinding(i) if key2 then SetBinding(key2, nil) end if key1 then SetBinding(key1, nil) end end end function KBM:ApplyBindings(data) if not data or type(data) ~= "table" then return false end self:ClearAllBindings() local applied = 0 for _, entry in ipairs(data) do if entry.command then if entry.key1 then SetBinding(entry.key1, entry.command) applied = applied + 1 end if entry.key2 then SetBinding(entry.key2, entry.command) applied = applied + 1 end end end SaveBindings(2) if SFrames.ActionBars and SFrames.ActionBars.RefreshAllHotkeys then SFrames.ActionBars:RefreshAllHotkeys() end return true, applied end -------------------------------------------------------------------------------- -- Base64 encode / decode (pure Lua 5.0 compatible) -------------------------------------------------------------------------------- local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local function Base64Encode(src) if not src or src == "" then return "" end local out = {} local len = string.len(src) local i = 1 while i <= len do local a = string.byte(src, i) local b = (i + 1 <= len) and string.byte(src, i + 1) or 0 local c = (i + 2 <= len) and string.byte(src, i + 2) or 0 local remaining = len - i + 1 local n = a * 65536 + b * 256 + c local c1 = math.floor(n / 262144) local c2 = math.floor(math.mod(n, 262144) / 4096) local c3 = math.floor(math.mod(n, 4096) / 64) local c4 = math.mod(n, 64) table.insert(out, string.sub(B64, c1 + 1, c1 + 1)) table.insert(out, string.sub(B64, c2 + 1, c2 + 1)) if remaining >= 2 then table.insert(out, string.sub(B64, c3 + 1, c3 + 1)) else table.insert(out, "=") end if remaining >= 3 then table.insert(out, string.sub(B64, c4 + 1, c4 + 1)) else table.insert(out, "=") end i = i + 3 end return table.concat(out) end local B64_INV = {} for i = 1, 64 do B64_INV[string.sub(B64, i, i)] = i - 1 end local function Base64Decode(src) if not src or src == "" then return "" end src = string.gsub(src, "%s+", "") local out = {} local len = string.len(src) local i = 1 while i <= len do local v1 = B64_INV[string.sub(src, i, i)] or 0 local v2 = B64_INV[string.sub(src, i + 1, i + 1)] or 0 local v3 = B64_INV[string.sub(src, i + 2, i + 2)] local v4 = B64_INV[string.sub(src, i + 3, i + 3)] local n = v1 * 262144 + v2 * 4096 + (v3 or 0) * 64 + (v4 or 0) table.insert(out, string.char(math.floor(n / 65536))) if v3 then table.insert(out, string.char(math.floor(math.mod(n, 65536) / 256))) end if v4 then table.insert(out, string.char(math.mod(n, 256))) end i = i + 4 end return table.concat(out) end -------------------------------------------------------------------------------- -- Serialization (export/import text format, Base64 encoded) -------------------------------------------------------------------------------- local EXPORT_PREFIX = "!NKB1!" local SEP = "\t" local function EncodeRaw(data) local lines = {} for _, entry in ipairs(data) do local line = entry.command if entry.key1 then line = line .. SEP .. entry.key1 else line = line .. SEP end if entry.key2 then line = line .. SEP .. entry.key2 end table.insert(lines, line) end return table.concat(lines, "\n") end local function DecodeRaw(raw) local data = {} for line in string.gfind(raw .. "\n", "(.-)\n") do line = string.gsub(line, "\r", "") if line ~= "" and string.sub(line, 1, 1) ~= "#" then local parts = {} for part in string.gfind(line .. SEP, "(.-)" .. SEP) do table.insert(parts, part) end local command = parts[1] if command and command ~= "" then local key1 = parts[2] local key2 = parts[3] if key1 == "" then key1 = nil end if key2 == "" then key2 = nil end if key1 or key2 then table.insert(data, { command = command, key1 = key1, key2 = key2, }) end end end end return data end function KBM:SerializeBindings(data) if not data then data = self:CollectAllBindings() end local raw = EncodeRaw(data) return EXPORT_PREFIX .. Base64Encode(raw) end function KBM:DeserializeBindings(text) if not text or text == "" then return nil, "文本为空" end text = string.gsub(text, "^%s+", "") text = string.gsub(text, "%s+$", "") if string.len(text) == 0 then return nil, "文本为空" end local raw if string.sub(text, 1, string.len(EXPORT_PREFIX)) == EXPORT_PREFIX then local encoded = string.sub(text, string.len(EXPORT_PREFIX) + 1) raw = Base64Decode(encoded) if not raw or raw == "" then return nil, "解码失败" end else raw = text end local data = DecodeRaw(raw) if table.getn(data) == 0 then return nil, "未找到有效的绑定数据" end return data end -------------------------------------------------------------------------------- -- Profile CRUD -------------------------------------------------------------------------------- function KBM:SaveProfile(name) if not name or name == "" then return false, "方案名不能为空" end EnsureDB() local data = self:CollectAllBindings() SFramesGlobalDB.KeyBindProfiles[name] = { timestamp = GetTimestamp(), charName = GetCharName(), bindings = data, } SFrames:Print("按键绑定方案已保存: |cffffd100" .. name .. "|r (" .. table.getn(data) .. " 条)") return true end function KBM:LoadProfile(name) if not name or name == "" then return false, "方案名不能为空" end EnsureDB() local profile = SFramesGlobalDB.KeyBindProfiles[name] if not profile then return false, "方案不存在: " .. name end local ok, count = self:ApplyBindings(profile.bindings) if ok then SFrames:Print("按键绑定方案已加载: |cffffd100" .. name .. "|r (" .. count .. " 个按键)") end return ok end function KBM:DeleteProfile(name) if not name or name == "" then return false end EnsureDB() if not SFramesGlobalDB.KeyBindProfiles[name] then return false end SFramesGlobalDB.KeyBindProfiles[name] = nil SFrames:Print("按键绑定方案已删除: |cffffd100" .. name .. "|r") return true end function KBM:RenameProfile(oldName, newName) if not oldName or oldName == "" or not newName or newName == "" then return false, "名称不能为空" end EnsureDB() local profiles = SFramesGlobalDB.KeyBindProfiles if not profiles[oldName] then return false, "方案不存在" end if profiles[newName] then return false, "目标名称已存在" end profiles[newName] = profiles[oldName] profiles[oldName] = nil SFrames:Print("按键绑定方案已重命名: |cffffd100" .. oldName .. "|r -> |cffffd100" .. newName .. "|r") return true end function KBM:GetProfileList() EnsureDB() local list = {} for name, _ in pairs(SFramesGlobalDB.KeyBindProfiles) do table.insert(list, name) end table.sort(list) return list end function KBM:GetProfileInfo(name) EnsureDB() local p = SFramesGlobalDB.KeyBindProfiles[name] if not p then return nil end return { name = name, charName = p.charName or "?", timestamp = p.timestamp or 0, count = p.bindings and table.getn(p.bindings) or 0, } end -------------------------------------------------------------------------------- -- Export / Import Dialog UI -------------------------------------------------------------------------------- local exportFrame, importFrame local function CreateDialogFrame(name, titleText, width, height) local f = CreateFrame("Frame", name, UIParent) f:SetWidth(width) f:SetHeight(height) f:SetPoint("CENTER", UIParent, "CENTER", 0, 80) f:SetFrameStrata("TOOLTIP") f:SetFrameLevel(200) f:SetToplevel(true) f:EnableMouse(true) f:SetMovable(true) f:SetClampedToScreen(true) f:RegisterForDrag("LeftButton") f:SetScript("OnDragStart", function() this:StartMoving() end) f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) if SFrames and SFrames.CreateBackdrop then SFrames:CreateBackdrop(f) else f:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", tile = false, edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 }, }) f:SetBackdropColor(0.08, 0.07, 0.1, 0.96) f:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.9) end local font = (SFrames and SFrames.GetFont) and SFrames:GetFont() or "Fonts\\ARIALN.TTF" local title = f:CreateFontString(nil, "OVERLAY") title:SetFont(font, 13, "OUTLINE") title:SetPoint("TOP", f, "TOP", 0, -10) title:SetText(titleText) local _T = SFrames.ActiveTheme if _T and _T.title then title:SetTextColor(_T.title[1], _T.title[2], _T.title[3]) else title:SetTextColor(1, 0.82, 0) end f.title = title local close = CreateFrame("Button", nil, f, "UIPanelCloseButton") close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -2, -2) close:SetWidth(20) close:SetHeight(20) return f, font end local function CreateThemedButton(parent, text, x, y, width, height, onClick) local name = "SFramesKBM_Btn_" .. string.gsub(text, "%s", "") .. "_" .. tostring(math.random(10000, 99999)) local btn = CreateFrame("Button", name, parent, "UIPanelButtonTemplate") btn:SetWidth(width) btn:SetHeight(height) btn:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) btn:SetText(text) btn:SetScript("OnClick", onClick) local font = (SFrames and SFrames.GetFont) and SFrames:GetFont() or "Fonts\\ARIALN.TTF" local _T = SFrames.ActiveTheme if not _T then return btn end local function HideBtnTex(tex) if not tex then return end if tex.SetTexture then tex:SetTexture(nil) end if tex.SetAlpha then tex:SetAlpha(0) end if tex.Hide then tex:Hide() end end HideBtnTex(btn:GetNormalTexture()) HideBtnTex(btn:GetPushedTexture()) HideBtnTex(btn:GetHighlightTexture()) HideBtnTex(btn:GetDisabledTexture()) for _, sfx in ipairs({"Left","Right","Middle"}) do local t = _G[name .. sfx] if t then t:SetAlpha(0); t:Hide() end end btn:SetBackdrop({ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16, edgeSize = 14, insets = { left = 3, right = 3, top = 3, bottom = 3 }, }) btn:SetBackdropColor(_T.btnBg[1], _T.btnBg[2], _T.btnBg[3], _T.btnBg[4]) btn:SetBackdropBorderColor(_T.btnBorder[1], _T.btnBorder[2], _T.btnBorder[3], _T.btnBorder[4]) local fs = btn:GetFontString() if fs then fs:SetFont(font, 11, "OUTLINE") fs:SetTextColor(_T.btnText[1], _T.btnText[2], _T.btnText[3]) end btn:SetScript("OnEnter", function() this:SetBackdropColor(_T.btnHoverBg[1], _T.btnHoverBg[2], _T.btnHoverBg[3], _T.btnHoverBg[4]) this:SetBackdropBorderColor(_T.btnHoverBd[1], _T.btnHoverBd[2], _T.btnHoverBd[3], _T.btnHoverBd[4]) local t = this:GetFontString() if t then t:SetTextColor(_T.btnActiveText[1], _T.btnActiveText[2], _T.btnActiveText[3]) end end) btn:SetScript("OnLeave", function() this:SetBackdropColor(_T.btnBg[1], _T.btnBg[2], _T.btnBg[3], _T.btnBg[4]) this:SetBackdropBorderColor(_T.btnBorder[1], _T.btnBorder[2], _T.btnBorder[3], _T.btnBorder[4]) local t = this:GetFontString() if t then t:SetTextColor(_T.btnText[1], _T.btnText[2], _T.btnText[3]) end end) btn:SetScript("OnMouseDown", function() this:SetBackdropColor(_T.btnDownBg[1], _T.btnDownBg[2], _T.btnDownBg[3], _T.btnDownBg[4]) end) btn:SetScript("OnMouseUp", function() this:SetBackdropColor(_T.btnHoverBg[1], _T.btnHoverBg[2], _T.btnHoverBg[3], _T.btnHoverBg[4]) end) return btn end function KBM:ShowExportDialog() local text = self:SerializeBindings() if not exportFrame then local f, font = CreateDialogFrame("SFramesKBMExport", "|cffffcc00导出按键绑定|r", 480, 340) local desc = f:CreateFontString(nil, "OVERLAY") desc:SetFont(font, 10, "OUTLINE") desc:SetPoint("TOP", f.title, "BOTTOM", 0, -4) desc:SetText("已编码为字符串,Ctrl+A 全选,Ctrl+C 复制") desc:SetTextColor(0.7, 0.7, 0.7) local sf = CreateFrame("ScrollFrame", "SFramesKBMExportScroll", f, "UIPanelScrollFrameTemplate") sf:SetPoint("TOPLEFT", f, "TOPLEFT", 12, -48) sf:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -32, 42) local edit = CreateFrame("EditBox", "SFramesKBMExportEdit", sf) edit:SetWidth(420) edit:SetHeight(200) edit:SetMultiLine(true) edit:SetFont(font, 10, "OUTLINE") edit:SetAutoFocus(false) edit:SetScript("OnEscapePressed", function() f:Hide() end) sf:SetScrollChild(edit) f.edit = edit CreateThemedButton(f, "关闭", 190, -306, 100, 26, function() f:Hide() end) exportFrame = f end exportFrame.edit:SetText(text) exportFrame:Show() exportFrame.edit:HighlightText() exportFrame.edit:SetFocus() end function KBM:ShowImportDialog() if not importFrame then local f, font = CreateDialogFrame("SFramesKBMImport", "|cffffcc00导入按键绑定|r", 480, 370) local desc = f:CreateFontString(nil, "OVERLAY") desc:SetFont(font, 10, "OUTLINE") desc:SetPoint("TOP", f.title, "BOTTOM", 0, -4) desc:SetText("将编码后的字符串粘贴到下方 (Ctrl+V),然后点击导入") desc:SetTextColor(0.7, 0.7, 0.7) local sf = CreateFrame("ScrollFrame", "SFramesKBMImportScroll", f, "UIPanelScrollFrameTemplate") sf:SetPoint("TOPLEFT", f, "TOPLEFT", 12, -48) sf:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -32, 72) local edit = CreateFrame("EditBox", "SFramesKBMImportEdit", sf) edit:SetWidth(420) edit:SetHeight(200) edit:SetMultiLine(true) edit:SetFont(font, 10, "OUTLINE") edit:SetAutoFocus(false) edit:SetScript("OnEscapePressed", function() f:Hide() end) sf:SetScrollChild(edit) f.edit = edit local statusLabel = f:CreateFontString(nil, "OVERLAY") statusLabel:SetFont(font, 10, "OUTLINE") statusLabel:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 14, 46) statusLabel:SetWidth(300) statusLabel:SetJustifyH("LEFT") statusLabel:SetText("") f.statusLabel = statusLabel CreateThemedButton(f, "导入并应用", 120, -336, 120, 26, function() local inputText = f.edit:GetText() if not inputText or inputText == "" then f.statusLabel:SetText("|cffff4444请先粘贴绑定数据|r") return end local data, err = KBM:DeserializeBindings(inputText) if not data then f.statusLabel:SetText("|cffff4444错误: " .. (err or "未知") .. "|r") return end local ok, count = KBM:ApplyBindings(data) if ok then f.statusLabel:SetText("|cff44ff44成功导入 " .. count .. " 个按键绑定|r") SFrames:Print("已从文本导入 " .. count .. " 个按键绑定") else f.statusLabel:SetText("|cffff4444导入失败|r") end end) CreateThemedButton(f, "取消", 250, -336, 100, 26, function() f:Hide() end) importFrame = f end importFrame.edit:SetText("") importFrame.statusLabel:SetText("") importFrame:Show() importFrame.edit:SetFocus() end -------------------------------------------------------------------------------- -- Input name dialog (for save / rename) -------------------------------------------------------------------------------- local inputDialog local function ShowInputDialog(titleText, defaultText, callback) if not inputDialog then local f, font = CreateDialogFrame("SFramesKBMInput", "", 360, 120) local edit = CreateFrame("EditBox", "SFramesKBMInputEdit", f) edit:SetWidth(320) edit:SetHeight(24) edit:SetPoint("TOP", f, "TOP", 0, -40) edit:SetFont(font, 12, "OUTLINE") edit:SetAutoFocus(true) edit:SetMaxLetters(50) edit:SetBackdrop({ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16, edgeSize = 10, insets = { left = 3, right = 3, top = 3, bottom = 3 }, }) edit:SetBackdropColor(0.1, 0.1, 0.1, 0.9) edit:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.8) edit:SetTextInsets(6, 6, 0, 0) f.edit = edit CreateThemedButton(f, "确定", 90, -78, 80, 24, function() local val = f.edit:GetText() if val and val ~= "" and f.callback then f.callback(val) end f:Hide() end) CreateThemedButton(f, "取消", 185, -78, 80, 24, function() f:Hide() end) edit:SetScript("OnEscapePressed", function() f:Hide() end) edit:SetScript("OnEnterPressed", function() local val = f.edit:GetText() if val and val ~= "" and f.callback then f.callback(val) end f:Hide() end) inputDialog = f end inputDialog.title:SetText(titleText) inputDialog.edit:SetText(defaultText or "") inputDialog.callback = callback inputDialog:Show() inputDialog.edit:SetFocus() inputDialog.edit:HighlightText() end -------------------------------------------------------------------------------- -- Confirm dialog -------------------------------------------------------------------------------- local confirmDialog local function ShowConfirmDialog(message, callback) if not confirmDialog then local f, font = CreateDialogFrame("SFramesKBMConfirm", "", 360, 110) local msg = f:CreateFontString(nil, "OVERLAY") msg:SetFont(font, 11, "OUTLINE") msg:SetPoint("TOP", f, "TOP", 0, -34) msg:SetWidth(320) msg:SetJustifyH("CENTER") msg:SetTextColor(0.9, 0.9, 0.9) f.msg = msg CreateThemedButton(f, "确定", 90, -70, 80, 24, function() if f.callback then f.callback() end f:Hide() end) CreateThemedButton(f, "取消", 185, -70, 80, 24, function() f:Hide() end) confirmDialog = f end confirmDialog.title:SetText("|cffffcc00确认|r") confirmDialog.msg:SetText(message) confirmDialog.callback = callback confirmDialog:Show() end KBM.ShowInputDialog = ShowInputDialog KBM.ShowConfirmDialog = ShowConfirmDialog