-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLootPro_Core.lua
More file actions
1763 lines (1590 loc) · 69.9 KB
/
Copy pathLootPro_Core.lua
File metadata and controls
1763 lines (1590 loc) · 69.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
local addonName, ns = ...
local addon = ns.addon
local LSM = LibStub and LibStub("LibSharedMedia-3.0", true)
local DEFAULT_FONT = "Fonts\\FRIZQT__.TTF"
local _tonumber, _tostring = tonumber, tostring
local _match, _format, _gsub, _find = string.match, string.format, string.gsub, string.find
local _select = select
local _GetTime = GetTime
local _After = C_Timer and C_Timer.After
local _NewTicker = C_Timer and C_Timer.NewTicker
local _GetItemCount = (C_Item and C_Item.GetItemCount) or GetItemCount
local _GetItemInfo = (C_Item and C_Item.GetItemInfo) or GetItemInfo
local _GetItemInfoInstant = (C_Item and C_Item.GetItemInfoInstant) or GetItemInfoInstant
local _GetItemQualityByID = C_Item and C_Item.GetItemQualityByID
local _GetItemNameByID = C_Item and C_Item.GetItemNameByID
local _GetCurrencyInfo = C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo
local _RequestItemData = C_Item and C_Item.RequestLoadItemDataByID
-- 12.0 "secret values": guard before any string op on a CHAT_MSG payload (nil pre-12.0).
local _issecret = issecretvalue
local NEW_APPEARANCE_TAG = " |cff66ccff(new look)|r"
local UPGRADE_TAG = " |cff1eff00(upgrade)|r"
local FADE_SCALE_PER_LINE = 0.6
local FADE_SCALE_MAX = 30
addon._badFonts = addon._badFonts or {}
local _badFontCount = 0
local _BAD_FONT_LIMIT = 50
local function SafeSetFont(region, path, size, flags)
local ok = pcall(region.SetFont, region, path, size, flags)
if ok then return true end
if not addon._badFonts[path or "?"] and _badFontCount < _BAD_FONT_LIMIT then
addon._badFonts[path or "?"] = true
_badFontCount = _badFontCount + 1
print("|cFFFF6060[LootPro]|r Failed to load font '".._tostring(path).."', falling back to default.")
end
pcall(region.SetFont, region, DEFAULT_FONT, size, flags)
return false
end
addon._SafeSetFont = SafeSetFont
local function ToPattern(s)
if not s then return nil end
s = s:gsub("([%.%[%]%(%)%+%-%?%^%$])", "%%%1")
s = s:gsub("%%s", "(.-)")
s = s:gsub("%%d", "([%%d%%p%%s]+)")
return s
end
local PAT_FACTION_UP = ToPattern(_G.FACTION_STANDING_INCREASED or "Reputation with %s increased by %d.")
local PAT_FACTION_DOWN = ToPattern(_G.FACTION_STANDING_DECREASED or "Reputation with %s decreased by %d.")
-- 11.0+ account-wide (Warband) reputations use a differently-worded line ("Your Warband's reputation with...") that the base patterns cannot match. The globals are absent on Classic, so ToPattern returns nil there.
local PAT_FACTION_UP_AW = ToPattern(_G.FACTION_STANDING_INCREASED_ACCOUNT_WIDE)
local PAT_FACTION_DOWN_AW = ToPattern(_G.FACTION_STANDING_DECREASED_ACCOUNT_WIDE)
local function LeadIn(fmt)
if not fmt then return nil end
local head = fmt:match("^(.-)%%s")
if not head or head == "" then return nil end
return head
end
local function EscapeLiteral(s)
return (s:gsub("([%.%[%]%(%)%+%-%?%^%$%%])", "%%%1"))
end
local LOOT_PREFIX_PATS = {}
for _, fmt in ipairs({
_G.LOOT_ITEM_SELF, _G.LOOT_ITEM_PUSHED_SELF, _G.CURRENCY_GAINED,
_G.LOOT_ITEM_SELF_MULTIPLE, _G.LOOT_ITEM_PUSHED_SELF_MULTIPLE,
}) do
local head = LeadIn(fmt)
if head then LOOT_PREFIX_PATS[#LOOT_PREFIX_PATS+1] = "^"..EscapeLiteral(head) end
end
LOOT_PREFIX_PATS[#LOOT_PREFIX_PATS+1] = "^You receive loot: "
LOOT_PREFIX_PATS[#LOOT_PREFIX_PATS+1] = "^You receive item: "
LOOT_PREFIX_PATS[#LOOT_PREFIX_PATS+1] = "^You receive currency: "
LOOT_PREFIX_PATS[#LOOT_PREFIX_PATS+1] = "^You loot "
do
local seen = {}
local deduped = {}
for _, pat in ipairs(LOOT_PREFIX_PATS) do
if not seen[pat] then
seen[pat] = true
deduped[#deduped + 1] = pat
end
end
LOOT_PREFIX_PATS = deduped
seen = nil
end
-- Locale-aware: derive money patterns from the GOLD/SILVER/COPPER_AMOUNT globals so parsing works on non-English clients.
local function MoneyPattern(fmt)
if not fmt then return nil end
fmt = fmt:gsub("([%.%[%]%(%)%+%-%?%^%$])", "%%%1")
fmt = fmt:gsub("%%d", "([%%d%%p]+)")
return fmt
end
local PAT_GOLD = MoneyPattern(_G.GOLD_AMOUNT or "%d Gold")
local PAT_SILVER = MoneyPattern(_G.SILVER_AMOUNT or "%d Silver")
local PAT_COPPER = MoneyPattern(_G.COPPER_AMOUNT or "%d Copper")
local function MoneyAmount(s)
if not s then return 0 end
return _tonumber((_gsub(s, "%D", ""))) or 0
end
-- Map link RGB -> quality. The link color is the ACTUAL (bonus-adjusted) quality; GetItemQualityByID returns BASE quality (a downscaled epic-base green would wrongly trip the rare alert) and needs the item cache.
local QUALITY_BY_RGB = {}
do
local qc = _G.ITEM_QUALITY_COLORS
if qc then
for q = 0, 10 do
local c = qc[q]
if c and c.hex then
QUALITY_BY_RGB[c.hex:sub(-6):lower()] = q
end
end
end
end
local function IsSelfLoot(msg)
if not msg or type(msg) ~= "string" then return false end
for _, pat in ipairs(LOOT_PREFIX_PATS) do
if _find(msg, pat) then return true end
end
return false
end
local function GetIconString(msg)
if not msg or type(msg) ~= "string" then return "" end
local itemID = _match(msg, "item:(%d+)")
if itemID and LootProConfig.showLootIcons then
local icon
if _GetItemInfoInstant then
local _, _, _, _, _icon = _GetItemInfoInstant(itemID)
icon = _icon
else
icon = _select(10, _GetItemInfo(itemID))
end
if icon then
return "|T" .. icon .. ":0|t "
end
end
return ""
end
local function TrailerRepl(m)
if m == "" then return m end
if _find(m, "x%d") or _find(m, "%.") then return "" end
return m
end
local function CleanMessage(msg, event)
if not msg or type(msg) ~= "string" then return msg end
if event == "CHAT_MSG_COMBAT_FACTION_CHANGE" then
if PAT_FACTION_UP then
local fac = _match(msg, PAT_FACTION_UP)
if fac then return fac end
end
if PAT_FACTION_DOWN then
local fac = _match(msg, PAT_FACTION_DOWN)
if fac then return fac end
end
elseif event == "CHAT_MSG_COMBAT_XP_GAIN" then
local amount = _match(msg, "([%d%p%s]*%d)")
if amount then return "+ " .. amount .. " XP" end
elseif _find(event, "CHAT_MSG_LOOT") or _find(event, "CHAT_MSG_CURRENCY") then
local cleaned = msg
local n
for _, pat in ipairs(LOOT_PREFIX_PATS) do
cleaned, n = _gsub(cleaned, pat, "")
if n > 0 then break end
end
-- Retail loot messages embed their own |T..|t icon; strip it since GetIconString prepends ours (else double icons).
cleaned = _gsub(cleaned, "|T[^|]-|t%s*", "")
cleaned = _gsub(cleaned, "[%[%]]", "")
cleaned = _gsub(cleaned, "x?%d*%s*%.?%s*$", TrailerRepl)
return cleaned
end
return msg
end
-- These report XP or an instant consumable as the "quantity", not a stack count, so render them without a count.
local NO_COUNT_PATTERNS = {
"Companion XP",
"Companion Experience",
"Boon of Power",
}
local function IsNoCountItem(cleanName)
if not cleanName then return false end
for _, pat in ipairs(NO_COUNT_PATTERNS) do
if cleanName:find(pat, 1, true) then return true end
end
return false
end
-- Some items fire both CHAT_MSG_LOOT and CHAT_MSG_CURRENCY (vendor buys), or CURRENCY twice (delve events); dedup by name within a tight window so the line shows once.
local recentLoot = {}
local recentCurrency = {}
local DEDUP_WINDOW = 0.25
local CURRENCY_DEDUP_WINDOW = 0.5
-- Loot marks must outlive the currency deferral (which waits DEDUP_WINDOW) or a same-frame loot+currency pair reads the mark as just-expired and the line shows twice.
local LOOT_MARK_TTL = DEDUP_WINDOW * 2
local function ExtractItemName(s)
if not s then return nil end
return _match(s, "|h%[(.-)%]|h")
end
local function MarkLootSeen(name)
if name then recentLoot[name] = _GetTime() end
end
local function IsRecentLoot(name)
if not name then return false end
local t = recentLoot[name]
if not t then return false end
if _GetTime() - t > LOOT_MARK_TTL then
recentLoot[name] = nil
return false
end
return true
end
local function MarkCurrencyShown(name)
if name then recentCurrency[name] = _GetTime() end
end
local function IsRecentCurrency(name)
if not name then return false end
local t = recentCurrency[name]
if not t then return false end
if _GetTime() - t > CURRENCY_DEDUP_WINDOW then
recentCurrency[name] = nil
return false
end
return true
end
-- Final dedup on the fully-rendered line (count included) catches doubled LOOT lines the name dedup misses; a real second drop differs by count, so it still shows.
-- Lines that render no count are identical for two same-tick drops, so those callers pass the live bag count as a salt on its own ring.
local DISPLAY_DEDUP_WINDOW = 0.3
local DISP_RING = 8
local _dispStr, _dispSalt, _dispTime, _dispIdx = {}, {}, {}, 0
local function IsDuplicateDisplay(line, salt)
local now = _GetTime()
for i = 1, DISP_RING do
if _dispStr[i] == line and _dispSalt[i] == salt and (now - _dispTime[i]) <= DISPLAY_DEDUP_WINDOW then
return true
end
end
_dispIdx = (_dispIdx % DISP_RING) + 1
_dispStr[_dispIdx] = line
_dispSalt[_dispIdx] = salt
_dispTime[_dispIdx] = now
return false
end
-- Lazy expiry-on-read leaks entries for items looted once and never seen again; this sweep clears stale dedup entries every 60s.
local function _SweepDedup()
local now = _GetTime()
for name, t in pairs(recentLoot) do
if now - t > LOOT_MARK_TTL then
recentLoot[name] = nil
end
end
for name, t in pairs(recentCurrency) do
if now - t > CURRENCY_DEDUP_WINDOW then
recentCurrency[name] = nil
end
end
end
local _dedupTicker = _NewTicker and _NewTicker(60, _SweepDedup) or nil
-- Optional per-feed framed-row renderer (toggled by framedLoot/framedCombat): a separate path from the text feed using a fixed, self-fading row pool.
local ROW_GAP, ROW_FADE = 2, 1
local _GetItemQualityColor = C_Item and C_Item.GetItemQualityColor
local function QualityRGB(q)
if q and _GetItemQualityColor then
local r, g, b = _GetItemQualityColor(q)
if r then return r, g, b end
end
local qc = _G.ITEM_QUALITY_COLORS and q and _G.ITEM_QUALITY_COLORS[q]
if qc then return qc.r, qc.g, qc.b end
return 1, 1, 1
end
local function LootCategory(link, itemID)
local src = link or itemID
if not src then return "" end
local _, _, _, _, _, _, subType, _, equipLoc = _GetItemInfo(src)
if not subType then return "" end
-- Non-equippable items map equipLoc to an empty-string global, and "" is truthy in Lua, so guard it or the slot prefix becomes a bare ", ".
local slot = equipLoc and equipLoc ~= "" and _G[equipLoc]
if slot and slot ~= "" then
return slot .. ", " .. subType
end
return subType
end
local ROW_BACKDROP = {
bgFile = "Interface\\ChatFrame\\ChatFrameBackground",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 14,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
}
local ICON_BACKDROP = { edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 10 }
-- Forward declares: the row mouse handlers (defined before BuildRow) pause/resume fades (defined after).
local PauseRowFades, ResumeRowFades
-- Masque is optional and never vendored. Resolve lazily since it may load after us, and fall back to our own icon border when absent.
local masqueGroup
local function GetMasqueGroup()
if masqueGroup == nil then
local Masque = LibStub and LibStub("Masque", true)
masqueGroup = (Masque and Masque:Group("Loot Pro", "Loot Icons")) or false
end
return masqueGroup or nil
end
local function LayoutRows(f)
local host = f.rowHost
for i, row in ipairs(f.rowActive) do
row:ClearAllPoints()
if i == 1 then
row:SetPoint("TOPLEFT", host, "TOPLEFT", 0, 0)
row:SetPoint("TOPRIGHT", host, "TOPRIGHT", 0, 0)
else
local prev = f.rowActive[i - 1]
row:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", 0, -ROW_GAP)
row:SetPoint("TOPRIGHT", prev, "BOTTOMRIGHT", 0, -ROW_GAP)
end
end
end
local function RowFade_OnFinished(ag)
local row = ag.row
local f = row._owner
row:Hide()
row._free = true
row:SetScript("OnUpdate", nil)
if f and f._moneyRow == row then f._moneyRow, f._moneyCopper = nil, nil end
if f and f.rowActive then
for i = #f.rowActive, 1, -1 do
if f.rowActive[i] == row then table.remove(f.rowActive, i) break end
end
LayoutRows(f)
end
end
local function StartRowFade(f, row)
local ag = row.fadeAG
ag:Stop()
row:SetAlpha(1)
if addon.isTesting then return end
-- Hover-pause holds rows at full alpha. OnLeave restarts the countdown.
if LootProConfig.hoverPause and f.IsMouseOver and f:IsMouseOver() then return end
local s = LootProConfig[f.configKey]
local base = (s and s.fade) or 6
if LootProConfig.fadeScale then
base = base + (#f.rowActive - 1) * FADE_SCALE_PER_LINE
if base > FADE_SCALE_MAX then base = FADE_SCALE_MAX end
end
row.fadeAnim:SetStartDelay(base)
ag:Play()
end
local function Row_OnEnter(row)
local f = row._owner
if LootProConfig.hoverPause and not addon.isTesting then PauseRowFades(f) end
if row.itemLink then
GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
pcall(GameTooltip.SetHyperlink, GameTooltip, row.itemLink)
GameTooltip:Show()
end
end
local function Row_OnLeave(row)
GameTooltip:Hide()
local f = row._owner
-- Guard resume with IsMouseOver so moving between rows does not restart the fade. Resume only once the cursor leaves the feed.
if LootProConfig.hoverPause and not addon.isTesting and not f:IsMouseOver() then
ResumeRowFades(f)
end
end
local function Row_OnClick(row)
local link = row.itemLink
if not link then return end
-- HandleModifiedItemClick inserts the link only when a chat box is already open. On a declined shift-click, open one pre-filled with the link so it always works.
if not HandleModifiedItemClick(link) and IsModifiedClick("CHATLINK") then
ChatFrame_OpenChat(link)
end
end
-- Unlocked, dragging a row moves the whole readout. Locked, drags are ignored so clicks link items instead.
local function Row_OnDragStart(row)
if not LootProConfig.locked then row._owner:StartMoving() end
end
local function Row_OnDragStop(row)
if LootProConfig.locked then return end
local h = row._owner:GetScript("OnDragStop")
if h then h(row._owner) end
end
-- Rows are mouse-enabled children, so they keep taking clicks even when the locked parent stops. Motion stays on so hover-pause still gets OnEnter with clicks off.
local function ApplyRowMouse(row)
if LootProConfig.locked and LootProConfig.rowClickThrough then
if LootProConfig.hoverPause and row.SetMouseMotionEnabled and row.SetMouseClickEnabled then
row:SetMouseClickEnabled(false)
row:SetMouseMotionEnabled(true)
row._noHover = false
else
row:EnableMouse(false)
-- No OnLeave can fire with the mouse off, so the manual tooltip refresh must skip this row or it strands a tooltip nothing closes.
row._noHover = true
end
else
row:EnableMouse(true)
if row.SetMouseClickEnabled then row:SetMouseClickEnabled(true) end
row._noHover = false
end
end
local function BuildRow(f)
local row = CreateFrame("Button", nil, f.rowHost, "BackdropTemplate")
ApplyRowMouse(row)
row:RegisterForClicks("AnyUp")
row:RegisterForDrag("LeftButton")
row:SetScript("OnEnter", Row_OnEnter)
row:SetScript("OnLeave", Row_OnLeave)
row:SetScript("OnClick", Row_OnClick)
row:SetScript("OnDragStart", Row_OnDragStart)
row:SetScript("OnDragStop", Row_OnDragStop)
row:SetBackdrop(ROW_BACKDROP)
row:SetBackdropColor(0, 0, 0, 0.85)
-- Icon is a mouse-disabled Button (Masque skins Buttons cleanly) so clicks and hover still fall through to the row.
local iconFrame = CreateFrame("Button", nil, row, "BackdropTemplate")
iconFrame:EnableMouse(false)
iconFrame:SetPoint("LEFT", 4, 0)
iconFrame:SetSize(32, 32)
local icon = iconFrame:CreateTexture(nil, "ARTWORK")
icon:SetPoint("TOPLEFT", 2, -2)
icon:SetPoint("BOTTOMRIGHT", -2, 2)
icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
row.iconFrame, row.icon = iconFrame, icon
local qty = iconFrame:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall")
qty:SetPoint("BOTTOMRIGHT", -1, 1)
row.qty = qty
-- Masque skins the icon when installed, otherwise draw our own tintable border.
local mg = GetMasqueGroup()
if mg and pcall(mg.AddButton, mg, iconFrame, { Icon = icon, Count = qty }) then
row._masque = true
else
iconFrame:SetBackdrop(ICON_BACKDROP)
end
-- Seed a font object at creation: ApplyRowFont only re-fonts row.cat for two-line item rows, so a text row's first SetText on row.cat would otherwise throw "Font not set".
row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
row.cat = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
local ag = row:CreateAnimationGroup()
local anim = ag:CreateAnimation("Alpha")
anim:SetFromAlpha(1)
anim:SetToAlpha(0)
anim:SetDuration(ROW_FADE)
anim:SetSmoothing("OUT")
ag.row = row
ag:SetScript("OnFinished", RowFade_OnFinished)
row.fadeAG, row.fadeAnim = ag, anim
row._owner = f
row._free = true
return row
end
local function EnsureRows(f)
if f.rowHost then return end
local host = CreateFrame("Frame", nil, f)
host:SetPoint("TOPLEFT", f, "TOPLEFT", 8, -8)
host:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -8, 8)
host:Hide()
f.rowHost, f.rowPool, f.rowActive = host, {}, {}
end
local function AcquireRow(f)
for _, row in ipairs(f.rowPool) do
if row._free then row._free = false return row end
end
local row = BuildRow(f)
row._free = false
f.rowPool[#f.rowPool + 1] = row
return row
end
local function ClearRows(f)
if not f.rowActive then return end
f._moneyRow, f._moneyCopper = nil, nil
for i = #f.rowActive, 1, -1 do
local row = f.rowActive[i]
row.fadeAG:Stop()
row:SetScript("OnUpdate", nil)
row:Hide()
row._free = true
f.rowActive[i] = nil
end
end
function addon:ClearFramedRows()
if self.combatFrame then ClearRows(self.combatFrame) end
if self.lootFrame then ClearRows(self.lootFrame) end
end
local function TakeRow(f)
local maxLines = (LootProConfig[f.configKey] and LootProConfig[f.configKey].maxLines) or 4
if maxLines < 1 then maxLines = 1 end
local row
if #f.rowActive >= maxLines then
row = table.remove(f.rowActive)
row.fadeAG:Stop()
else
row = AcquireRow(f)
end
table.insert(f.rowActive, 1, row)
ApplyRowMouse(row)
return row
end
local function ApplyRowFont(f, row, twoLine)
local s = LootProConfig[f.configKey]
local fontPath = (LSM and LSM:Fetch("font", s.font)) or DEFAULT_FONT
local flags = (s.outline == "NONE") and "" or (s.outline or "OUTLINE")
SafeSetFont(row.name, fontPath, s.size, flags)
if twoLine then
SafeSetFont(row.cat, fontPath, math.max(9, math.floor(s.size * 0.55)), flags)
end
if flags == "" then
row.name:SetShadowColor(0, 0, 0, 0.6); row.name:SetShadowOffset(1, -1)
else
row.name:SetShadowColor(0, 0, 0, 0); row.name:SetShadowOffset(0, 0)
end
return s
end
-- Size the row to its wrapped name plus category so a long name grows the row instead of overflowing. Name width is explicit so GetStringHeight returns the real wrapped height.
local function LayoutItemRow(f, row)
local s = LootProConfig[f.configKey]
local catSize = math.max(9, math.floor(s.size * 0.55))
local baseH = s.size + catSize + 12
local rowWidth = (s.width or 200) - 16
local nameLeft
if row._hasIcon then
local iconSize = baseH - 6
row.iconFrame:SetSize(iconSize, iconSize)
-- Masque skins at the button's size, so re-skin only when the size changes, not on every loot.
if row._masque and row.iconFrame._skinnedSize ~= iconSize then
row.iconFrame._skinnedSize = iconSize
local mg = GetMasqueGroup()
if mg then pcall(mg.ReSkin, mg, row.iconFrame) end
end
nameLeft = 4 + iconSize + 6
else
nameLeft = 8
end
local nameWidth = math.max(20, rowWidth - nameLeft - 6)
row.name:ClearAllPoints()
row.name:SetPoint("TOPLEFT", row, "TOPLEFT", nameLeft, -5)
row.name:SetWidth(nameWidth)
row.cat:ClearAllPoints()
row.cat:SetPoint("TOPLEFT", row.name, "BOTTOMLEFT", 0, -1)
row.cat:SetWidth(nameWidth)
local nameH = row.name:GetStringHeight()
local catH = row.cat:IsShown() and row.cat:GetStringHeight() or 0
local textH = nameH + (catH > 0 and catH + 2 or 0) + 12
row:SetHeight(math.max(baseH, textH))
end
-- SetText-based so the count tween never fights Masque's Count region.
local COUNT_TWEEN_DUR = 0.3
local function RowCountTween(row, elapsed)
row._tweenT = (row._tweenT or 0) + elapsed
local p = row._tweenT / COUNT_TWEEN_DUR
if p >= 1 then
local to = row._tweenTo or 0
row.qty:SetText(to > 1 and to or "")
row:SetScript("OnUpdate", nil)
return
end
local from = row._tweenFrom or 0
local v = math.floor(from + ((row._tweenTo or 0) - from) * p + 0.5)
row.qty:SetText(v > 1 and v or "")
end
local function AnimateRowCount(row, fromVal, toVal)
if not row._hasIcon or fromVal == toVal then return end
row._tweenFrom, row._tweenTo, row._tweenT = fromVal, toVal, 0
row:SetScript("OnUpdate", RowCountTween)
end
local function FindActiveRow(f, mergeKey)
if not (mergeKey and f.rowActive) then return nil end
for i = 1, #f.rowActive do
local row = f.rowActive[i]
if not row._isText and row.mergeKey == mergeKey then return row end
end
return nil
end
-- The qty badge is a child of the icon, so an iconless row has nowhere to show a tally but its own name.
local function RowNameText(row, count)
local text = row._baseName or ""
local n = row._mergeAmt or 1
if not row._hasIcon and n > 1 then text = text .. " x" .. n end
if count then text = text .. " (" .. count .. ")" end
return text .. (row._marker or "")
end
local function MergeIntoRow(f, row, amt, count, icon, link, marker)
local from = row._mergeAmt or 1
local to = from + (amt or 1)
row._mergeAmt = to
-- Every marker source is per-drop and cache-dependent, so a later drop can resolve a tag the first one could not.
local newMark = marker and marker ~= "" and marker ~= row._marker
if newMark then row._marker = marker end
if count or not row._hasIcon or newMark then
row.name:SetText(RowNameText(row, count))
-- A wider count can re-wrap the name and change the row height, so remeasure. Rows below reflow on their own relative anchors.
LayoutItemRow(f, row)
end
if icon and row._hasIcon then row.icon:SetTexture(icon) end
row.itemLink = link
AnimateRowCount(row, from, to)
StartRowFade(f, row)
if not row._noHover and row:IsMouseOver() then Row_OnEnter(row) end
end
-- Border is quality-tinted, or the passed color for currency and money.
local function RowItem(f, icon, quality, name, category, amt, count, r, g, b, link, mergeKey, marker)
EnsureRows(f)
-- Junk collapses many different grays into one row, so it has no single owned count, link or marker.
if mergeKey == "junk" then count, link, marker = nil, nil, nil end
local existing = FindActiveRow(f, mergeKey)
if existing then
MergeIntoRow(f, existing, amt, count, icon, link, marker)
return
end
local row = TakeRow(f)
row:SetScript("OnUpdate", nil)
row._isText = false
row._isMoney = false
row._hasIcon = icon and true or false
row.itemLink = link
row.mergeKey = mergeKey
row._mergeAmt = amt or 1
row._baseName = name or ""
row._marker = marker
ApplyRowFont(f, row, true)
local br, bg, bb
if quality then br, bg, bb = QualityRGB(quality) else br, bg, bb = r or 1, g or 1, b or 1 end
row:SetBackdropBorderColor(br, bg, bb, 1)
if icon then
if not row._masque then row.iconFrame:SetBackdropBorderColor(br, bg, bb, 1) end
row.icon:SetTexture(icon)
row.iconFrame:Show()
if amt and amt > 1 then row.qty:SetText(amt) else row.qty:SetText("") end
else
row.iconFrame:Hide()
row.qty:SetText("")
end
row.name:SetWordWrap(true)
row.name:SetJustifyH("LEFT")
row.name:SetJustifyV("TOP")
row.name:SetText(RowNameText(row, count))
row.name:SetTextColor(br, bg, bb)
row.cat:SetJustifyH("LEFT")
if category and category ~= "" then
row.cat:SetText(category)
row.cat:SetTextColor(0.7, 0.7, 0.7)
row.cat:Show()
else
row.cat:SetText("")
row.cat:Hide()
end
LayoutItemRow(f, row)
row:Show()
LayoutRows(f)
StartRowFade(f, row)
-- A recycled row can swap items under a resting cursor without firing OnEnter, so refresh its tooltip.
if not row._noHover and row:IsMouseOver() then Row_OnEnter(row) end
end
local function RowText(f, text, r, g, b, isMoney)
EnsureRows(f)
local row = TakeRow(f)
row:SetScript("OnUpdate", nil)
row._isText = true
row._isMoney = isMoney and true or false
row._hasIcon = false
row.itemLink = nil
row.mergeKey = nil
local s = ApplyRowFont(f, row, false)
row:SetHeight(s.size + 12)
row:SetBackdropBorderColor(r or 1, g or 1, b or 1, 1)
row.iconFrame:Hide()
row.qty:SetText("")
row.cat:SetText(""); row.cat:Hide()
row.name:ClearAllPoints()
row.name:SetPoint("LEFT", row, "LEFT", 6, 0)
row.name:SetWidth((s.width or 200) - 28)
row.name:SetWordWrap(false)
row.name:SetJustifyH("CENTER")
row.name:SetJustifyV("MIDDLE")
row.name:SetText(text or "")
row.name:SetTextColor(r or 1, g or 1, b or 1)
row:Show()
LayoutRows(f)
StartRowFade(f, row)
-- A recycled row now holds a non-item line, so drop any item tooltip left open over it.
if row:IsMouseOver() then GameTooltip:Hide() end
return row
end
local function MoneyText(copper)
copper = copper or 0
if LootProConfig.showMoneyIcons then
local g = math.floor(copper / 10000)
local s = math.floor((copper % 10000) / 100)
local c = copper % 100
local st = ""
if g > 0 then st = st .. g .. " |TInterface\\MoneyFrame\\UI-GoldIcon:0|t " end
if s > 0 then st = st .. s .. " |TInterface\\MoneyFrame\\UI-SilverIcon:0|t " end
if c > 0 then st = st .. c .. " |TInterface\\MoneyFrame\\UI-CopperIcon:0|t " end
return "+ " .. st
end
return "+ " .. addon:RecapFormatMoney(copper)
end
-- Accumulate rapid money loots into one running row while it is still visible.
local function MoneyRowEmit(f, copper, r, g, b)
EnsureRows(f)
local row = f._moneyRow
if row and not row._free and row._isMoney then
f._moneyCopper = (f._moneyCopper or 0) + copper
row.name:SetText(MoneyText(f._moneyCopper))
row.name:SetTextColor(r, g, b)
StartRowFade(f, row)
else
f._moneyCopper = copper
f._moneyRow = RowText(f, MoneyText(copper), r, g, b, true)
end
end
-- Re-apply font and size to a rendered row so the size slider updates framed rows live, re-measuring wrapped height for item rows.
local function RestyleRow(f, row)
ApplyRowMouse(row)
local s = ApplyRowFont(f, row, not row._isText)
if row._isText then
row:SetHeight(s.size + 12)
row.name:SetWidth((s.width or 200) - 28)
else
LayoutItemRow(f, row)
end
end
local function RestyleRows(f)
if not f.rowActive then return end
-- Trim to the current maxLines so lowering the slider drops excess rows at once, matching the text feed.
local maxLines = (LootProConfig[f.configKey] and LootProConfig[f.configKey].maxLines) or 4
if maxLines < 1 then maxLines = 1 end
while #f.rowActive > maxLines do
local row = table.remove(f.rowActive)
row.fadeAG:Stop()
row:Hide()
row._free = true
end
for _, row in ipairs(f.rowActive) do RestyleRow(f, row) end
LayoutRows(f)
end
function PauseRowFades(f)
if not f.rowActive then return end
for _, row in ipairs(f.rowActive) do
row.fadeAG:Stop()
row:SetAlpha(1)
end
end
function ResumeRowFades(f)
if not f.rowActive then return end
for _, row in ipairs(f.rowActive) do
StartRowFade(f, row)
end
end
local function FramedFor(configKey)
return (configKey == "loot" and LootProConfig.framedLoot)
or (configKey == "combat" and LootProConfig.framedCombat)
end
local function CombatEmit(text, r, g, b)
local f = addon.combatFrame
if LootProConfig.framedCombat then RowText(f, text, r, g, b)
else f.display:AddMessage(text, r, g, b) end
end
local function LootTextEmit(text, r, g, b)
local f = addon.lootFrame
if LootProConfig.framedLoot then RowText(f, text, r, g, b)
else f.display:AddMessage(text, r, g, b) end
end
-- Pooled param tables + pre-bound timer fns (rotated per event) so the hot loot path allocates zero closures; a slot is reused only after POOL_SIZE events.
local POOL_SIZE = 16
local _curParams = {}
local _curFns = {}
local _lootParams = {}
local _lootFns = {}
local _curSlot, _lootSlot = 0, 0
local function CountSuffix(nn)
nn = _tonumber(nn) or 0
if nn <= 0 then return "" end
return " (" .. nn .. ")"
end
local function PostCurrency(p)
if IsRecentLoot(p.currencyName) then return end
local display = addon.lootFrame.display
local cap = p.capStr or ""
local line
if p.noCount then
line = p.iconStr .. p.text .. cap
elseif p.cleanMode then
line = "+" .. p.amt .. " " .. p.iconStr .. p.text .. CountSuffix(p.total) .. cap
else
line = p.iconStr .. p.text .. CountSuffix(p.total) .. cap
end
if IsDuplicateDisplay(line) then return end
if LootProConfig.framedLoot then
-- Mirror text mode: no-count currencies (e.g. Companion XP) hide the amount and running total.
local cAmt = not p.noCount and p.amt or nil
local cTot = not p.noCount and p.total or nil
RowItem(addon.lootFrame, p.iconTex, nil, p.cName or p.text, cap ~= "" and cap or nil, cAmt, cTot, p.cR, p.cG, p.cB, nil)
else
display:AddMessage(line, p.cR, p.cG, p.cB)
end
end
local function ShowLoot(p, countStr)
local f = addon.lootFrame
local marker = p.marker or ""
local line
if p.noCount then
line = p.iconStr .. p.cleaned .. marker
else
line = "+" .. p.amt .. " " .. p.iconStr .. p.cleaned .. countStr .. marker
end
local salt
if (p.noCount or countStr == "") and p.itemID and _GetItemCount then
salt = _GetItemCount(p.itemID, true)
end
if IsDuplicateDisplay(line, salt) then return end
if LootProConfig.framedLoot then
-- No-count items carry an XP or charge figure in amt, not a stack size, so it must not reach the qty badge or the row tally.
RowItem(f, p.fIcon, p.fQuality, p.fName or p.cleaned, p.fCategory, (not p.noCount) and p.amt or 1, p.fCount, p.cR, p.cG, p.cB, p.fLink, p.fMergeKey, marker)
else
f.display:AddMessage(line, p.cR, p.cG, p.cB)
end
end
local function PostDeferredLoot(p)
-- At +0.1s BAG_UPDATE has landed so GetItemCount is already post-loot. Take the larger of it and the pre-loot snapshot plus amt so we neither double-count nor under-count.
local live = (_GetItemCount and _GetItemCount(p.itemID, true)) or 0
local cnt = math.max((p.preCount or 0) + p.amt, live)
p.fCount = (not p.noCount) and cnt or nil
ShowLoot(p, CountSuffix(cnt))
end
for i = 1, POOL_SIZE do
_curParams[i] = {}
_lootParams[i] = {}
local idx = i
_curFns[i] = function() PostCurrency(_curParams[idx]) end
_lootFns[i] = function() PostDeferredLoot(_lootParams[idx]) end
end
local _lootSync = {}
-- Hover-pause calls SetFading(false), which snaps every buffered (faded) line back to full alpha; clear the buffer once the feed has fully faded so a later mouse-over can't resurrect old lines.
local FADE_OUT = 1 -- must match SetFadeDuration below
local SWEEP_PAD = 0.3
local function LineLife(disp, configKey)
local s = LootProConfig and LootProConfig[configKey]
return (disp._timeVisible or (s and s.fade) or 6) + FADE_OUT + SWEEP_PAD
end
local function SweepReadout(f)
f._sweepPending = false
if addon.isTesting then return end
local disp = f.display
if disp:GetNumMessages() == 0 then return end
local idle = _GetTime() - (disp._lastAdd or 0)
local life = LineLife(disp, f.configKey)
if idle < life or (f.IsMouseOver and f:IsMouseOver()) then
f._sweepPending = true
if _After then _After(math.max(0.3, life - idle), f._sweepFn) end
return
end
disp:Clear()
end
local function ScheduleSweep(f)
if f._sweepPending or not _After then return end
f._sweepPending = true
_After(LineLife(f.display, f.configKey), f._sweepFn)
end
local function CreateReadoutFrame(name, labelText, defaultY, configKey)
local f = CreateFrame("Frame", name.."Anchor", UIParent, "BackdropTemplate")
f.configKey = configKey
f.defaultY = defaultY
f:SetPoint("CENTER", 0, defaultY)
f:SetMovable(true)
f:SetClampedToScreen(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", f.StartMoving)
f:SetScript("OnDragStop", function(self)
self:StopMovingOrSizing()
if addon:IsReady() then
local p, _, rp, x, y = self:GetPoint()
LootProConfig[self.configKey].point = p
LootProConfig[self.configKey].relativePoint = rp or p
LootProConfig[self.configKey].x = x
LootProConfig[self.configKey].y = y
end
end)
f.label = f:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
f.label:SetPoint("CENTER")
f.label:SetText(labelText)
f.label:Hide()
f.display = CreateFrame("ScrollingMessageFrame", name.."Display", f)
f.display:SetPoint("TOPLEFT", f, "TOPLEFT", 10, -10)
f.display:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -10, 10)
f.display:SetInsertMode("TOP")
f.display:SetFading(true)
f.display:SetFadeDuration(1)
f.display:SetJustifyH("CENTER")
f.display:SetJustifyV("TOP")
f._sweepFn = function() SweepReadout(f) end
f:SetScript("OnEnter", function(self)
if not (LootProConfig and LootProConfig.hoverPause) or addon.isTesting then return end
if FramedFor(self.configKey) then
PauseRowFades(self)
return
end
local disp = self.display
if _GetTime() - (disp._lastAdd or 0) >= LineLife(disp, self.configKey) then
disp:Clear()
else
disp:SetFading(false)
end
end)
f:SetScript("OnLeave", function(self)
if not (LootProConfig and LootProConfig.hoverPause) then return end
if FramedFor(self.configKey) then
if not addon.isTesting and not self:IsMouseOver() then ResumeRowFades(self) end
return
end
self.display:SetFading(not addon.isTesting)
if not addon.isTesting then ScheduleSweep(self) end
end)
hooksecurefunc(f.display, "AddMessage", function(disp)
disp._lastAdd = _GetTime()
local s = LootProConfig and LootProConfig[configKey]
local base = (s and s.fade) or 6
if LootProConfig and LootProConfig.fadeScale then
local n = disp:GetNumMessages() or 1
local t = base + (n - 1) * FADE_SCALE_PER_LINE