Files
pfUI/modules/share.lua
T
Brues 796f20eadf Show chat copy and the share box in ClassicAPI's scrolling edit box
pfUIScrollingEditBoxTemplate (api/ui-widgets.xml) is ScrollingEditBoxTemplate
with pfUIScrollBarTemplate beside the text. Its mixin's SetFont gives the
box a font object of its own, since the edit box sets its font by object on
every SetText and pfUI fonts are paths. ui-widgets.lua now loads before the
XML, which needs the mixin.

The share box no longer recomputes its scroll child on every text change.

Chat copy reads each chat frame's messages with ClassicAPI's
GetMessageInfo when it opens, so the AddMessage hook, its 100-line copy of
the history and the skip of combat frames are gone. It shows every line
the frame keeps, lines from before login included. It puts the caret at
the end to scroll to the newest line instead of three OnUpdate passes, and
Escape now leaves copy mode on every chat frame.
2026-10-30 00:38:54 -05:00

381 lines
13 KiB
Lua

pfUI:RegisterModule("share", function ()
-- Profile sharing rides ClassicAPI's C_EncodingUtil:
-- export: diff vs defaults -> SerializeCBOR -> CompressString (zlib)
-- -> EncodeBase64, prefixed with the format magic
-- import: the reverse, into a plain table — no loadstring, so a
-- pasted profile can't execute code
-- The Decode/Encode button converts between the transport blob and an
-- editable JSON view of the same table.
local MAGIC = "!pf1!"
-- Deep-diff `tbl` against `default`, keeping only changed or added
-- values. `ignored` keys are skipped at the top level only (matches the
-- old serializer: "position"/"disabled" when Ignore Layout is checked).
-- Returns nil when nothing differs.
local function DiffConfig(tbl, default, ignored)
local diff = nil
for k, v in pairs(tbl) do
if not ( ignored and ignored[k] ) then
local dv = default and default[k]
if type(v) == "table" then
local sub = DiffConfig(v, type(dv) == "table" and dv or nil)
if sub then
diff = diff or {}
diff[k] = sub
end
elseif v ~= dv and ( type(v) == "string" or type(v) == "number" or type(v) == "boolean" ) then
diff = diff or {}
diff[k] = v
end
end
end
return diff
end
-- EditBoxes don't soft-wrap one giant unbroken line; chunk the blob.
local function wrap(str, width)
local out = {}
for i = 1, strlen(str), width do
table.insert(out, strsub(str, i, i + width - 1))
end
return table.concat(out, "\n")
end
local function Encode(tbl)
return MAGIC .. wrap(C_EncodingUtil.EncodeBase64(
C_EncodingUtil.CompressString(
C_EncodingUtil.SerializeCBOR(tbl))), 92)
end
local function Decode(text)
if not text then return nil end
text = gsub(text, "%s", "")
if strsub(text, 1, strlen(MAGIC)) ~= MAGIC then return nil end
local ok, result = pcall(function()
return C_EncodingUtil.DeserializeCBOR(
C_EncodingUtil.DecompressString(
C_EncodingUtil.DecodeBase64(strsub(text, strlen(MAGIC) + 1))))
end)
if ok and type(result) == "table" then return result end
return nil
end
local function DecodeJSON(text)
if not text or gsub(text, "%s", "") == "" then return nil end
local ok, result = pcall(function()
return C_EncodingUtil.DeserializeJSON(text)
end)
if ok and type(result) == "table" then return result end
return nil
end
-- Legacy import (pre-!pf1! exports): base64 -> LZW -> Lua source
-- "pfUI_config = {...}". The base64 layer is standard and handled by
-- C_EncodingUtil.DecodeBase64; only the custom LZW format needs the old
-- Lua decoder. Import-only — new exports always use the CBOR pipeline.
local function decompress(input)
-- based on Rochet2's lzw compression
if type(input) ~= "string" or strlen(input) < 1 then
return nil
end
local control = strsub(input, 1, 1)
if control == "u" then
return strsub(input, 2)
elseif control ~= "c" then
return nil
end
input = strsub(input, 2)
local len = strlen(input)
if len < 2 then
return nil
end
local dict = {}
for i = 0, 255 do
local ic, iic = strchar(i), strchar(i, 0)
dict[iic] = ic
end
local a, b = 0, 1
local result = {}
local n = 1
local last = strsub(input, 1, 2)
result[n] = dict[last]
n = n+1
for i = 3, len, 2 do
local code = strsub(input, i, i+1)
local lastStr = dict[last]
if not lastStr then
return nil
end
local toAdd = dict[code]
if toAdd then
result[n] = toAdd
n = n+1
local str = lastStr..strsub(toAdd, 1, 1)
if a >= 256 then
a, b = 0, b+1
if b >= 256 then
dict = {}
b = 1
end
end
dict[strchar(a,b)] = str
a = a+1
else
local str = lastStr..strsub(lastStr, 1, 1)
result[n] = str
n = n+1
if a >= 256 then
a, b = 0, b+1
if b >= 256 then
dict = {}
b = 1
end
end
dict[strchar(a,b)] = str
a = a+1
end
last = code
end
return table.concat(result)
end
local function DecodeLegacy(text)
if not text or gsub(text, "%s", "") == "" then return nil end
-- encoded blob? peel base64 + LZW down to Lua source. Raw (already
-- decoded) source pastes are accepted as-is.
local source = text
local stripped = gsub(text, "%s", "")
local ok, decoded = pcall(function()
return C_EncodingUtil.DecodeBase64(stripped)
end)
if ok and decoded then
local decompressed = decompress(decoded)
if decompressed then source = decompressed end
end
local chunk = loadstring(source)
if not chunk then return nil end
-- Sandbox the chunk: an empty environment means it can assign its
-- config table but can't reach any global or API function — a legacy
-- profile string is data, never code.
local sandbox = {}
setfenv(chunk, sandbox)
if not pcall(chunk) then return nil end
if type(sandbox.pfUI_config) == "table" then return sandbox.pfUI_config end
return nil
end
do -- Window
local f = CreateFrame("Frame", "pfShare", UIParent)
f:Hide()
f:SetPoint("CENTER", 0, 0)
f:SetWidth(580)
f:SetHeight(420)
f:SetMovable(true)
f:EnableMouse(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function() f:StartMoving() end)
f:SetScript("OnDragStop", function() f:StopMovingOrSizing() end)
f:SetFrameStrata("DIALOG")
f:SetScript("OnShow", function()
if pfUI.gui and pfUI.gui:IsShown() then
f.hadGUI = true
pfUI.gui:Hide()
else
f.hadGUI = nil
end
end)
f:SetScript("OnHide", function()
if f.hadGUI then
pfUI.gui:Show()
end
end)
CreateBackdrop(f, nil, true, 0.8)
CreateBackdropShadow(f)
table.insert(UISpecialFrames, "pfShare")
do -- Edit Box
f.scroll = CreateFrame("Frame", "pfShareScroll", f, "pfUIScrollingEditBoxTemplate")
f.scroll:SetPoint("TOPLEFT", f, "TOPLEFT", 10, -30)
f.scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -10, 50)
f.scroll:SetFont(pfUI.media["font:RobotoMono.ttf"], 9)
f.scroll:SetTextInsets(15,15,15,15)
-- the ScrollBox clips its children, so the backdrop is a sibling
f.scroll.backdrop = CreateFrame("Frame", "pfShareScrollBackdrop", f)
f.scroll.backdrop:SetPoint("TOPLEFT", f.scroll, "TOPLEFT", -5, 5)
f.scroll.backdrop:SetPoint("BOTTOMRIGHT", f.scroll, "BOTTOMRIGHT", 5, -5)
pfUI.api.CreateBackdrop(f.scroll.backdrop, nil, true)
f.scroll.backdrop.tint = f.scroll.backdrop:CreateTexture(nil, "ARTWORK")
f.scroll.backdrop.tint:SetAllPoints(f.scroll)
f.scroll.backdrop.tint:SetTexture(1,1,1,.05)
f.scroll:RegisterCallback("OnTextChanged", function()
local text = f.scroll:GetText()
local blob = Decode(text)
local json = not blob and DecodeJSON(text) or nil
local legacy = not blob and not json and DecodeLegacy(text) or nil
if blob or json or legacy then
f.loadButton:Enable()
f.loadButton.text:SetTextColor(.5,1,.5,1)
else
f.loadButton:Disable()
f.loadButton.text:SetTextColor(1,.5,.5,1)
end
if blob or legacy then
-- decoding a legacy string yields the JSON view; re-encoding it
-- from there produces a new-format blob (migration path)
f.readButton:Enable()
f.readButton.text:SetText(T["Decode"])
f.readButton.func = function()
local current = f.scroll:GetText()
local config = Decode(current) or DecodeLegacy(current)
if config then
f.scroll:SetText(C_EncodingUtil.SerializeJSON(config))
end
end
elseif json then
f.readButton:Enable()
f.readButton.text:SetText(T["Encode"])
f.readButton.func = function()
local config = DecodeJSON(f.scroll:GetText())
if config then
f.scroll:SetText(Encode(config))
end
end
else
f.readButton:Disable()
f.readButton.text:SetText(T["N/A"])
end
end, f)
end
do -- button: close
f.closeButton = CreateFrame("Button", "pfShareClose", f)
f.closeButton:SetPoint("TOPRIGHT", -5, -5)
f.closeButton:SetHeight(12)
f.closeButton:SetWidth(12)
f.closeButton.texture = f.closeButton:CreateTexture("pfQuestionDialogCloseTex")
f.closeButton.texture:SetTexture(pfUI.media["img:close"])
f.closeButton.texture:ClearAllPoints()
f.closeButton.texture:SetAllPoints(f.closeButton)
f.closeButton.texture:SetVertexColor(1,.25,.25,1)
pfUI.api.SkinButton(f.closeButton, 1, .5, .5)
f.closeButton:SetScript("OnClick", function()
this:GetParent():Hide()
end)
end
do -- checkbox: ignore positions
f.ignorePosition = CreateFrame("CheckButton", "pfShareIgnorePosition", f, "UICheckButtonTemplate")
f.ignorePosition:SetNormalTexture("")
f.ignorePosition:SetPushedTexture("")
f.ignorePosition:SetHighlightTexture("")
CreateBackdrop(f.ignorePosition, nil, true)
f.ignorePosition:SetWidth(14)
f.ignorePosition:SetHeight(14)
f.ignorePosition:SetPoint("BOTTOMLEFT", 10, 10)
f.ignorePositionCaption = f.ignorePosition:CreateFontString("Status", "LOW", "GameFontNormal")
f.ignorePositionCaption:SetFont(pfUI.font_default, C.global.font_size + 2, "OUTLINE")
f.ignorePositionCaption:SetPoint("LEFT", f.ignorePosition, "RIGHT", 5, 0)
f.ignorePositionCaption:SetFontObject(GameFontWhite)
f.ignorePositionCaption:SetJustifyH("LEFT")
f.ignorePositionCaption:SetText(T["Ignore Layout"])
end
do -- button: load
f.loadButton = CreateFrame("Button", "pfShareLoad", f)
pfUI.api.SkinButton(f.loadButton)
f.loadButton:SetPoint("BOTTOMRIGHT", -5, 5)
f.loadButton:SetWidth(75)
f.loadButton:SetHeight(25)
f.loadButton.text = f.loadButton:CreateFontString("Caption", "LOW", "GameFontWhite")
f.loadButton.text:SetAllPoints(f.loadButton)
f.loadButton.text:SetFont(pfUI.font_default, pfUI_config.global.font_size, "OUTLINE")
f.loadButton.text:SetText(T["Import"])
f.loadButton:SetScript("OnClick", function()
local text = f.scroll:GetText()
local config = Decode(text) or DecodeJSON(text) or DecodeLegacy(text)
if not config then return end
_G.pfUI_config = config
C = _G.pfUI_config
pfUI:LoadConfig()
-- Skip firstrun wizard when importing a shared profile
-- The imported config is a complete setup, no wizard needed
if pfUI.firstrun and pfUI.firstrun.steps then
for _, step in pairs(pfUI.firstrun.steps) do
pfUI_init[step.name] = true
end
end
CreateQuestionDialog(T["Some settings need to reload the UI to take effect.\nDo you want to reloadUI now?"], ReloadUI)
end)
end
do -- button: read
f.readButton = CreateFrame("Button", "pfShareDecode", f)
pfUI.api.SkinButton(f.readButton)
f.readButton:SetPoint("RIGHT", f.loadButton, "LEFT", -10, 0)
f.readButton:SetWidth(75)
f.readButton:SetHeight(25)
f.readButton.text = f.readButton:CreateFontString("Caption", "LOW", "GameFontWhite")
f.readButton.text:SetAllPoints(f.readButton)
f.readButton.text:SetFont(pfUI.font_default, pfUI_config.global.font_size, "OUTLINE")
f.readButton.text:SetText(T["N/A"])
f.readButton:SetScript("OnClick", function()
this.func()
end)
end
do -- button: export
f.exportButton = CreateFrame("Button", "pfShareExport", f)
pfUI.api.SkinButton(f.exportButton)
f.exportButton:SetPoint("RIGHT", f.readButton, "LEFT", -10, 0)
f.exportButton:SetWidth(75)
f.exportButton:SetHeight(25)
f.exportButton.text = f.exportButton:CreateFontString("Caption", "LOW", "GameFontWhite")
f.exportButton.text:SetAllPoints(f.exportButton)
f.exportButton.text:SetFont(pfUI.font_default, pfUI_config.global.font_size, "OUTLINE")
f.exportButton.text:SetText(T["Export"])
f.exportButton:SetScript("OnClick", function()
-- generate a default config
local myconfig = CopyTable(pfUI_config)
_G.pfUI_config = {}
pfUI:LoadConfig()
local defconfig = CopyTable(pfUI_config)
-- restore config and references
_G.pfUI_config = CopyTable(myconfig)
C = _G.pfUI_config
local ignored = {}
ignored["position"] = f.ignorePosition:GetChecked()
ignored["disabled"] = f.ignorePosition:GetChecked()
local encoded = Encode(DiffConfig(myconfig, defconfig, ignored) or {})
f.scroll:SetText(encoded)
end)
end
pfUI.api.RegisterSlashCommand("PFEXPORT", { "/export", "/import", "/share" }, function(msg, editbox)
f:Show()
end, true)
end
end)