Repository navigation
Expand file tree
/
Copy pathEllesmereUI_PixelPerfect.lua
More file actions
2224 lines (2090 loc) · 116 KB
/
Copy pathEllesmereUI_PixelPerfect.lua
File metadata and controls
2224 lines (2090 loc) · 116 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
if EUI_CLIENT_BLOCKED then return end -- pre-12.1 client failsafe (EllesmereUI_ClientGate.lua)
-------------------------------------------------------------------------------
-- EllesmereUI_PixelPerfect.lua
-- Pixel perfect system (PP), panel pixel perfect (PanelPP) and the border
-- texture system. Loads before EllesmereUI.lua, which reads PP at load time.
-------------------------------------------------------------------------------
local EllesmereUI = _G.EllesmereUI
-- The options page pool's table (EllesmereUIOptions/EllesmereUI_FramePool.lua
-- fills it in place when the options addon loads): made here, by the first
-- core file that reads it, and read through file locals by every core path,
-- so nothing written to the public slot later can reach those paths.
local Pool = EllesmereUI._Pool
if type(Pool) ~= "table" then
Pool = { CreateFrame = function(...) return CreateFrame(...) end }
EllesmereUI._Pool = Pool
end
local PP
-------------------------------------------------------------------------------
-- Pixel Perfect System
-- Snaps UI elements to exact physical pixel boundaries regardless of UI scale, resolution, or element scale.
-- perfect = 768 / physicalScreenHeight (1 pixel in WoW's 768-based coord)
-- mult = perfect / UIParent:GetScale() (1 physical pixel at current scale)
-- Scale(x) snaps any value to the nearest mult boundary.
-- Usage: local PP = EllesmereUI.PP
-- PP.Size/Width/Height/Point, PP.SetInside/SetOutside(obj, anchor, x, y),
-- PP.DisablePixelSnap(texture), PP.Scale(x) -> snapped value
-------------------------------------------------------------------------------
do
local GetPhysicalScreenSize = GetPhysicalScreenSize
local InCombatLockdown = InCombatLockdown
local type = type
local PP = {}
EllesmereUI.PP = PP
---------------------------------------------------------------------------
-- Core pixel-perfect values
---------------------------------------------------------------------------
-- perfect = 1 physical pixel in WoW's 768-based coords at scale 1.0. Keep the last known-
-- good height when GetPhysicalScreenSize() reports 0/nil mid display-mode change (768/0 is infinite, and perfect divides every pixel snap -> NaN sizes suite-wide).
function PP.RefreshPhysical()
local pw, ph = GetPhysicalScreenSize()
if ph and ph > 0 then
PP.physicalWidth, PP.physicalHeight = pw, ph
elseif not PP.physicalHeight then
PP.physicalWidth, PP.physicalHeight = 1920, 1080
end
PP.perfect = 768 / PP.physicalHeight
end
PP.RefreshPhysical()
-- mult = 1 physical pixel at current UIParent scale. When UIParent scale ==
-- perfect, mult == 1 and every integer is pixel-perfect with no snapping.
PP.mult = PP.perfect / (UIParent and UIParent:GetScale() or 1)
--- Returns the ideal pixel-perfect scale, clamped to WoW's valid range.
function PP.PixelBestSize()
return max(0.4, min(PP.perfect, 1.15))
end
--- Recalculate mult after a scale or resolution change.
function PP.UpdateMult()
PP.RefreshPhysical()
local uiScale = EllesmereUIDB and EllesmereUIDB.ppUIScale or PP.PixelBestSize()
PP.mult = PP.perfect / uiScale
end
--- Apply a new UI scale. Stores it, sets UIParent, recalculates mult.
--- Defers to PLAYER_REGEN_ENABLED if called during combat.
function PP.SetUIScale(newScale)
if InCombatLockdown() then
EllesmereUI.CombatQueue.Defer("SetUIScale", function() PP.SetUIScale(newScale) end)
return
end
if not EllesmereUIDB then EllesmereUIDB = {} end
local currentScale = UIParent and UIParent:GetScale() or 1
local scaleChanged = math.abs(currentScale - newScale) > 0.0001
EllesmereUIDB.ppUIScale = newScale
UIParent:SetScale(newScale)
PP.UpdateMult()
if scaleChanged then
-- Exact-size borders are pixels at UIParent scale (this path never fires
-- UI_SCALE_CHANGED, so the watcher below cannot do it).
if EllesmereUI.ReapplyPxBorders then EllesmereUI.ReapplyPxBorders() end
-- Re-snap all stored values to the new pixel grid
if EllesmereUI.SnapProfilePositions then
local activeName = EllesmereUIDB.activeProfile or "Default"
local profData = EllesmereUIDB.profiles and EllesmereUIDB.profiles[activeName]
if profData then EllesmereUI.SnapProfilePositions(profData) end
end
if _G._EUF_ReloadFrames then _G._EUF_ReloadFrames() end
if _G._ERB_Apply then _G._ERB_Apply() end
if _G._EAB_Apply then _G._EAB_Apply() end
if _G._ECME_Apply then _G._ECME_Apply() end
if _G._EDM_Rescale then _G._EDM_Rescale() end
if _G._ERF_PixelGridChanged then _G._ERF_PixelGridChanged() end
-- Re-sync width/height matches against the new grid. UIParent:SetScale()
-- does NOT fire UI_SCALE_CHANGED (that event is CVar-tied), so no listener
-- catches this path. Debounced: the Options slider calls this repeatedly
-- while dragged and re-matching on every call never converges.
if EllesmereUI.ApplyAllWidthHeightMatches then
if PP._scaleMatchDebounce then PP._scaleMatchDebounce:Cancel() end
PP._scaleMatchDebounce = C_Timer.NewTimer(0.3, function()
PP._scaleMatchDebounce = nil
EllesmereUI.ApplyAllWidthHeightMatches()
end)
end
end
end
---------------------------------------------------------------------------
-- Scale(x) -- snap to the nearest physical-pixel boundary (sizes, positions, offsets).
-- Divides x into whole-pixel chunks of `mult`, truncating toward zero (positive floors, negative ceils).
---------------------------------------------------------------------------
function PP.Scale(x)
if x == 0 then return 0 end
local m = PP.mult
if m == 1 then return x end
local pixels = x / m
-- Epsilon-guarded truncation: pixel-unit slider values sit exactly ON a grid boundary
-- (px*mult); float dust can land a hair below, dropping a whole pixel at floor. 0.001 px is below any legitimate distance.
pixels = x > 0 and math.floor(pixels + 0.001) or math.ceil(pixels - 0.001)
return pixels * m
end
--- Snap a value to the nearest physical pixel at UIParent scale.
--- Convenience wrapper for save paths that don't have a frame reference.
function PP.Snap(x)
if x == 0 then return 0 end
local m = PP.mult
local result = math.floor(x / m + 0.5) * m
-- Clean float dust: within 0.001 of an integer, round to it (prevents drift)
local rounded = math.floor(result + 0.5)
if math.abs(result - rounded) < 0.001 then result = rounded end
return result
end
--- Coord-space value -> physical pixel count (for display). Epsilon-guarded round (ties up):
--- a value a hair off a half-pixel boundary (uiScale CVar != 768/screenHeight) must round the same every call/reload or coords flip by 1.
function PP.ToPixels(coord)
if coord == 0 then return 0 end
return math.floor(coord / PP.mult + 0.5 + 0.001)
end
--- Convert a physical pixel count to a grid-aligned coord value (for storage).
function PP.FromPixels(px)
if px == 0 then return 0 end
return px * PP.mult
end
---------------------------------------------------------------------------
-- IsNum(n) -- true for a finite number. WoW Forever's Lua raises an error on a
-- division by zero or with a NaN or infinite operand (retail yields NaN or inf
-- quietly), so an engine width/height that is not a real number must never reach
-- the snaps' divisions. The NaN test is picked once: n == n where NaN compares
-- normally, else the number's text form (no string built per call otherwise).
---------------------------------------------------------------------------
do
local huge = math.huge
local okNaN, nan = pcall(function() return huge - huge end)
if okNaN and type(nan) == "number" and nan ~= nan then
function PP.IsNum(n)
return type(n) == "number" and n == n and n ~= huge and n ~= -huge
end
else
function PP.IsNum(n)
if type(n) ~= "number" then return false end
local s = tostring(n)
return not (s:find("nan", 1, true) or s:find("inf", 1, true))
end
end
end
---------------------------------------------------------------------------
-- SnapForES(x, effectiveScale) -- snap to a whole number of physical pixels at the
-- given effective scale (border-system approach): onePixel = perfect / es; result =
-- round(x / onePixel) * onePixel. Guarantees exactly N physical pixels, no sub-pixel drift between siblings.
-- A value or scale that is not a real number snaps to 0 / scale 1 instead of erroring.
---------------------------------------------------------------------------
function PP.SnapForES(x, es)
if x == 0 or not PP.IsNum(x) then return 0 end
if not (PP.IsNum(es) and es > 0) then es = 1 end
local onePixel = PP.perfect / es
-- Epsilon-guarded round (ties up): inputs a hair below a half-pixel boundary
-- (imperfect uiScale CVars) must snap the same way or frames shift 1px per reload.
local physPixels = math.floor(x / onePixel + 0.5 + 0.001)
local result = physPixels * onePixel
local rounded = math.floor(result + 0.5)
if math.abs(result - rounded) < 0.001 then result = rounded end
return result
end
---------------------------------------------------------------------------
-- SnapCenterForDim(value, dim, effectiveScale) -- snap a CENTER coord so both edges land
-- on physical pixels: EVEN dim -> whole-pixel center; ODD -> half-pixel center (integer +
-- 0.5) so center +/- dim/2 are both whole. THE snap for CENTER/CENTER-stored frames --
-- plain SnapForES loses the +0.5 for odd dims and drifts 1px on save/exit, profile change, spec swap.
---------------------------------------------------------------------------
function PP.SnapCenterForDim(value, dim, es)
if value == nil then return value end
if not PP.IsNum(value) then return 0 end
es = es or (UIParent and UIParent:GetEffectiveScale() or 1)
if not (PP.IsNum(es) and es > 0) then es = 1 end
-- A dim that is not a real number is unknown (the even/whole-pixel path).
if dim and not PP.IsNum(dim) then dim = nil end
local onePixel = PP.perfect / es
local valuePx = value / onePixel
-- Clean float dust BEFORE any floor: N +/- 1e-9 must not flip which half/whole pixel it
-- lands on between reloads (visible 1px jump); cleaned, an exact integer takes the +0.5 odd-branch side.
local vClean = math.floor(valuePx + 0.5)
if math.abs(valuePx - vClean) < 0.001 then valuePx = vClean end
local result
if dim and dim > 0 then
local dimPx = math.floor(dim / onePixel + 0.5 + 0.001)
if dimPx % 2 == 1 then
-- Odd dim: snap center to nearest half-pixel (integer + 0.5)
result = (math.floor(valuePx) + 0.5) * onePixel
-- Clean floating point dust (half-pixel values)
local rounded = math.floor(result) + 0.5
if math.abs(result - rounded) < 0.001 then result = rounded end
return result
end
end
-- Even dimension (or unknown): snap center to nearest whole pixel.
result = math.floor(valuePx + 0.5 + 0.001) * onePixel
local rounded = math.floor(result + 0.5)
if math.abs(result - rounded) < 0.001 then result = rounded end
return result
end
---------------------------------------------------------------------------
-- CenterToPixels(center, dim, effectiveScale) -- inverse of SnapCenterForDim for LIVE-
-- geometry readouts/deltas: live CENTER coord (UIParent units) -> stored-convention pixel
-- value. Odd-dim centers rest on a half pixel (stored N means N+0.5), so subtract it before
-- rounding, or ToPixels' tie-up maps N+0.5 to N+1 and the delta overshoots a whole pixel (even dims behave like ToPixels).
---------------------------------------------------------------------------
function PP.CenterToPixels(center, dim, es)
if center == nil or not PP.IsNum(center) then return nil end
local v = center / PP.mult
if dim and PP.IsNum(dim) and dim > 0 then
es = es or (UIParent and UIParent:GetEffectiveScale() or 1)
if not (PP.IsNum(es) and es > 0) then es = 1 end
local onePixel = PP.perfect / es
local dimPx = math.floor(dim / onePixel + 0.5 + 0.001)
if dimPx % 2 == 1 then v = v - 0.5 end
end
return math.floor(v + 0.5 + 0.001)
end
---------------------------------------------------------------------------
-- Convenience wrappers -- pixel-snapped frame geometry
---------------------------------------------------------------------------
function PP.Size(frame, w, h)
frame:SetSize(PP.Scale(w), h and PP.Scale(h) or PP.Scale(w))
end
function PP.Width(frame, w)
frame:SetWidth(PP.Scale(w))
end
function PP.Height(frame, h)
frame:SetHeight(PP.Scale(h))
end
function PP.Point(obj, anchor, p1, p2, p3, p4)
if not p1 then p1 = obj:GetParent() end
if type(p1) == "number" then p1 = PP.Scale(p1) end
if type(p2) == "number" then p2 = PP.Scale(p2) end
if type(p3) == "number" then p3 = PP.Scale(p3) end
if type(p4) == "number" then p4 = PP.Scale(p4) end
obj:SetPoint(anchor, p1, p2, p3, p4)
end
function PP.SetInside(obj, anchor, xOff, yOff)
anchor = anchor or obj:GetParent()
local inset = PP.Scale(xOff or 1)
local insetY = PP.Scale(yOff or 1)
obj:ClearAllPoints()
PP.DisablePixelSnap(obj)
obj:SetPoint("TOPLEFT", anchor, "TOPLEFT", inset, -insetY)
obj:SetPoint("BOTTOMRIGHT", anchor, "BOTTOMRIGHT", -inset, insetY)
end
function PP.SetOutside(obj, anchor, xOff, yOff)
anchor = anchor or obj:GetParent()
local outset = PP.Scale(xOff or 1)
local outsetY = PP.Scale(yOff or 1)
obj:ClearAllPoints()
PP.DisablePixelSnap(obj)
obj:SetPoint("TOPLEFT", anchor, "TOPLEFT", -outset, outsetY)
obj:SetPoint("BOTTOMRIGHT", anchor, "BOTTOMRIGHT", outset, -outsetY)
end
---------------------------------------------------------------------------
-- DisablePixelSnap -- stop WoW rounding texture coords to the nearest pixel (blurs sub-pixel-sized elements).
---------------------------------------------------------------------------
-- External weak-keyed set: NEVER write custom keys onto Blizzard's secure widget
-- tables (taints them -> "secret value" errors).
local _pixelSnapDisabled = setmetatable({}, { __mode = "k" })
function PP.DisablePixelSnap(obj)
if not obj then return end
if issecretvalue and issecretvalue(obj) then return end
if issecrettable and issecrettable(obj) then return end
if _pixelSnapDisabled[obj] then return end
if obj.IsForbidden and obj:IsForbidden() then return end
-- Textures and FontStrings expose SetSnapToPixelGrid directly
local target = obj
if not obj.SetSnapToPixelGrid and obj.GetStatusBarTexture then
-- StatusBars need their inner texture unsnapped instead
target = obj:GetStatusBarTexture()
if type(target) ~= "table" or not target.SetSnapToPixelGrid then
_pixelSnapDisabled[obj] = true
return
end
end
if target.SetSnapToPixelGrid then
target:SetSnapToPixelGrid(false)
target:SetTexelSnappingBias(0)
end
_pixelSnapDisabled[obj] = true
end
-- The options page pool (EllesmereUI_FramePool.lua) resets an object it hands out
-- again with SetToDefaults, which turns pixel snapping back on as on a new
-- object: drop the cache entry so the next image set disables it again.
function PP.ForgetPixelSnap(obj)
_pixelSnapDisabled[obj] = nil
end
---------------------------------------------------------------------------
-- Global Pixel Snap Prevention -- pixel-snap is a persistent property of each
-- texture OBJECT: defaults ON at creation, when SetStatusBarTexture mints a new
-- inner texture, or when foreign code calls SetSnapToPixelGrid(true). Image setters
-- (SetTexture/SetColorTexture/SetAtlas) do NOT reset snap; tint/UV changes never do.
-- So hook only: image setters (one-time first-touch disable), SetStatusBarTexture
-- (fill swaps), and SetSnapToPixelGrid via WatchPixelSnap (re-catch Blizzard
-- re-enabling). NOT hooked: SetVertexColor/SetTexCoord -- fire constantly (nameplate
-- recolor churn) yet never blur a texture, so skipping them is the CPU win.
-- PP.DisablePixelSnap caches into _pixelSnapDisabled (snap C-calls run once each).
-- INVARIANT: cache keys on the StatusBar OBJECT, so re-calling SetStatusBarTexture
-- on a cached bar does NOT re-disable snap on the new inner texture -- runtime
-- bar-fill swaps MUST call PP.DisablePixelSnap on the new GetStatusBarTexture() themselves.
---------------------------------------------------------------------------
local function WatchPixelSnap(frame, snap)
if issecrettable and issecrettable(frame) then return end
if (frame and not frame:IsForbidden()) and _pixelSnapDisabled[frame] and snap then
_pixelSnapDisabled[frame] = nil
end
end
local _hookedMetatables = {}
local function HookPixelSnap(object)
local mk = getmetatable(object)
if not mk then return end
mk = mk.__index
if not mk or _hookedMetatables[mk] then return end
if mk.SetSnapToPixelGrid or mk.SetStatusBarTexture or mk.SetColorTexture
or mk.SetAtlas or mk.SetTexture then
-- Rationale in the banner. CreateTexture is NOT hooked: hooksecurefunc
-- passes the parent frame, not the new texture, so it would be a no-op.
if mk.SetSnapToPixelGrid then hooksecurefunc(mk, "SetSnapToPixelGrid", WatchPixelSnap) end
if mk.SetStatusBarTexture then hooksecurefunc(mk, "SetStatusBarTexture", PP.DisablePixelSnap) end
if mk.SetColorTexture then hooksecurefunc(mk, "SetColorTexture", PP.DisablePixelSnap) end
if mk.SetAtlas then hooksecurefunc(mk, "SetAtlas", PP.DisablePixelSnap) end
if mk.SetTexture then hooksecurefunc(mk, "SetTexture", PP.DisablePixelSnap) end
_hookedMetatables[mk] = true
end
end
-- Hook all known widget types by creating one of each and hooking its metatable
local hookFrame = CreateFrame("Frame")
do
-- Raw ORIGINAL image setters captured BEFORE HookPixelSnap wraps the Texture
-- metatable. Pooled hot-path textures (nameplate aura slots) snap once at
-- creation then use these to skip wrapper + guard + cache lookup per swap.
-- Only legal when the texture had PP.DisablePixelSnap applied once and nothing
-- can re-enable snap on it afterwards (our own pooled textures qualify).
local mt = getmetatable(hookFrame:CreateTexture())
if mt and mt.__index then
PP.RawSetTexture = mt.__index.SetTexture
PP.RawSetColorTexture = mt.__index.SetColorTexture
end
end
HookPixelSnap(hookFrame)
HookPixelSnap(hookFrame:CreateTexture())
HookPixelSnap(hookFrame:CreateFontString())
HookPixelSnap(hookFrame:CreateMaskTexture())
-- No frame-tree enumeration here, deliberately. An EnumerateFrames() walk
-- used to run at this point "to catch any type we missed": 11,305 frames,
-- 248 ms of a 419 ms load, and its only new metatable was StatusBar, which
-- the explicit hook below already covers (measured 2026-09-07, identical in
-- open world and in a M+ key). It also never saw ItemButton,
-- ScrollingMessageFrame or AuraContainer, which this suite creates later, so
-- it was not the net it claimed to be. HookPixelSnap dedupes by metatable,
-- so every type sharing one hooked here is covered anyway.
-- Also hook ScrollFrame and StatusBar metatables
HookPixelSnap(CreateFrame("ScrollFrame"))
do
local sb = CreateFrame("StatusBar")
sb:SetStatusBarTexture("Interface\\Buttons\\WHITE8x8")
HookPixelSnap(sb)
local sbt = sb:GetStatusBarTexture()
if sbt then HookPixelSnap(sbt) end
end
---------------------------------------------------------------------------
-- UNIFIED BORDER SYSTEM -- the single border API for every EllesmereUI addon. 4 manual
-- texture strips, each exactly borderSize*mult UI units thick (= borderSize physical
-- pixels); no BackdropTemplate, no sub-pixel interp. Border data lives in the external
-- weak-keyed _ppBorderData, NEVER on the frame (taints Blizzard's secure frame tables);
-- entry = { container, borderSize, borderColor }, container holds _top/_bottom/_left/_right textures.
-- API:
-- PP.CreateBorder(frame, r, g, b, a, borderSize, drawLayer, subLevel)
-- PP.SetBorderSize(frame, borderSize) PP.SetBorderColor(frame, r, g, b, a)
-- PP.UpdateBorder(frame, borderSize, r, g, b, a)
-- PP.HideBorder(frame) PP.ShowBorder(frame)
local _ppBorderData = setmetatable({}, { __mode = "k" })
---------------------------------------------------------------------------
local function SnapBorderTextures(container, frame, borderSize)
if not container.GetEffectiveScale then return end
local ok, es = pcall(container.GetEffectiveScale, container)
if not ok or not es then return end
-- Degenerate PARENT-scale guard, OPT-IN via container._scaleGuard (nameplates, see
-- PP.CreateBorder): those containers are scale-DECOUPLED so their own es pins to 1
-- and onePixel cannot explode, but the plate they anchor to hits near-zero scale during
-- recycle/hide/PEW (SetScale(0.001)) -- snapping against that rect is churn, skip it (the skipped state is re-asserted via ArmPendingSnap below -- do NOT assume some later pass comes on its own, pooled plates get none); UIParent-based borders leave the flag unset.
if container._scaleGuard then
local pok, pes = pcall(frame.GetEffectiveScale, frame)
if pok and pes and pes < 0.1 then
-- Skipping the snap is right (the rect is collapsed, snapping against it is
-- churn), but the CALLER'S INTENT MUST NOT BE LOST. A state change that lands
-- in this window is otherwise dropped for good: the nameplate cast-bar wrap
-- clears its hidden-bottom seam when the cast ends, which for a dying/despawning
-- unit happens exactly while the plate is collapsed -- and since plates are
-- POOLED and the paths that would re-snap on re-use are appearance-generation
-- cached, the bottom strip then stays hidden for every unit that plate is
-- recycled onto. Drop the geometry key so the next pass cannot match it as
-- identical and skip, and arm a one-shot re-snap for when the container is
-- visible again.
container._snapEdge = nil
PP.ArmPendingSnap(container, frame)
return
end
end
local onePixel = es > 0 and (PP.perfect / es) or PP.mult
local bs = borderSize or 1
local edgeSize = bs > 0 and math.max(onePixel, math.floor(bs + 0.5) * onePixel) or 0
local t, b, l, r = container._top, container._bottom, container._left, container._right
if not t then return end
if edgeSize == 0 then
t:Hide(); b:Hide(); l:Hide(); r:Hide()
-- Clear the geometry key so the next non-zero pass cannot match a stale one and skip the Show().
container._snapEdge = nil
return
end
-- Edge suppression for joined bars: flags live on the container so they survive
-- re-snaps (a one-shot :Hide() is clobbered by the next snap). Hidden top/bottom
-- edges also drop the matching side-strip inset so stacked borders fuse with no
-- corner notch.
local topInset, botInset = -edgeSize, edgeSize
if container._hideTop then topInset = 0 end
if container._hideBottom then botInset = 0 end
-- Geometry is idempotent (~30 frame API calls per invocation, per-aura-per-refresh):
-- skip when the layout is identical. Compare POST-transform values (edgeSize + both
-- insets), NEVER borderSize input -- an input-vs-applied guard can't match a non-trivial
-- transform and re-fires forever. Scale flows through (es -> onePixel -> edgeSize), so an
-- equal-edgeSize skip is correct (every SetPoint here is anchor-relative, insets-only).
-- The colour block stays OUTSIDE this guard (textured path zeroes strip alpha; SetVertexColor
-- restores it); side-strip hidden flags need their own key slots since a left/right-only
-- flip at unchanged geometry moves no inset and would otherwise be skipped.
local hideL = container._hideLeft or false
local hideR = container._hideRight or false
local geomSame = container._snapEdge == edgeSize
and container._snapTop == topInset
and container._snapBot == botInset
and container._snapHideL == hideL
and container._snapHideR == hideR
and container._snapFrame == frame
if not geomSame then
container._snapEdge, container._snapTop = edgeSize, topInset
container._snapBot, container._snapFrame = botInset, frame
container._snapHideL, container._snapHideR = hideL, hideR
if container._hideTop then
t:Hide()
else
t:ClearAllPoints()
t:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
t:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)
t:SetHeight(edgeSize); t:Show()
end
if container._hideBottom then
b:Hide()
else
b:ClearAllPoints()
b:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)
b:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0)
b:SetHeight(edgeSize); b:Show()
end
if container._hideLeft then
l:Hide()
else
l:ClearAllPoints()
l:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, topInset)
l:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, botInset)
l:SetWidth(edgeSize); l:Show()
end
if container._hideRight then
r:Hide()
else
r:ClearAllPoints()
r:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, topInset)
r:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, botInset)
r:SetWidth(edgeSize); r:Show()
end
end
local bc = container._bdColor
if bc then
t:SetVertexColor(bc[1], bc[2], bc[3], bc[4])
b:SetVertexColor(bc[1], bc[2], bc[3], bc[4])
l:SetVertexColor(bc[1], bc[2], bc[3], bc[4])
r:SetVertexColor(bc[1], bc[2], bc[3], bc[4])
end
end
-- Deferred re-snap for a guarded container whose snap was swallowed at degenerate parent
-- scale (rationale at the guard in SnapBorderTextures). OnShow is the trigger because it is
-- exactly the moment the plate un-collapses, costs nothing while idle, and needs no ticker.
-- Nothing else scripts OnShow on a border container, so SetScript is safe here; the handler
-- is shared, NOT a per-container closure (these arm on pooled nameplate borders).
-- Two further nets catch a container whose OnShow never fires: the cleared _snapEdge means
-- no later snap can skip it as unchanged, and PP.ResnapAllBorders re-snaps it wholesale.
function PP._pendingSnapOnShow(container)
local frame = container._pendingSnap
if not frame then
container:SetScript("OnShow", nil)
return
end
local pok, pes = pcall(frame.GetEffectiveScale, frame)
-- Still collapsed (shown inside a hidden/zero-scale ancestor): stay armed for the next show.
if not pok or not pes or pes < 0.1 then return end
container._pendingSnap = nil
container:SetScript("OnShow", nil)
local bd = _ppBorderData[frame]
SnapBorderTextures(container, frame, bd and bd.borderSize or 1)
end
function PP.ArmPendingSnap(container, frame)
if container._pendingSnap then return end
container._pendingSnap = frame
container:SetScript("OnShow", PP._pendingSnapOnShow)
end
---------------------------------------------------------------------------
-- Border registry -- tracks all border containers for centralized re-snap
-- when UI scale or resolution changes. Avoids per-border OnUpdate overhead.
---------------------------------------------------------------------------
local allBorders = {}
local allBordersN = 0
local function RegisterBorder(container, frame)
allBordersN = allBordersN + 1
allBorders[allBordersN] = { container = container, frame = frame }
end
-- Snap one registry entry. Evicts it only on a real failure (dead frame): while
-- auras are secret, rect reads in engine aura subtrees are denied but transient.
local function SnapEntry(entry)
local f = entry.frame
local bd = _ppBorderData[f]
local ok = pcall(SnapBorderTextures, entry.container, f, bd and bd.borderSize or 1)
if not ok then
local AKR = EllesmereUI and EllesmereUI.AuraKit
if not (AKR and AKR.AurasRestricted and AKR.AurasRestricted()) then
entry.container = nil
entry.frame = nil
end
end
end
--- Re-snap every registered border. Called on scale/resolution changes.
function PP.ResnapAllBorders()
for i = 1, allBordersN do
local entry = allBorders[i]
if entry.container and entry.frame then SnapEntry(entry) end
end
end
--- Re-snap only borders whose parent frame descends from `root`. Tab switch uses
--- this instead of resnapping 600+ borders when only ~30 on the page need it.
function PP.ResnapBordersUnder(root)
if not root then return PP.ResnapAllBorders() end
-- Shared method ref for the climb: engine aura buttons deny addon access while
-- auras are secret. Under pcall a denied step is a chain dead-end = not under
-- root, which is correct (aura subtrees are never inside the options panel).
local GetParentFn = root.GetParent
for i = 1, allBordersN do
local entry = allBorders[i]
local f = entry.frame
if f and entry.container then
-- Walk up the parent chain (max 10 levels to avoid infinite loops)
local parent = f
for _ = 1, 10 do
local ok, p = pcall(GetParentFn, parent)
if not ok or not p then break end
parent = p
if parent == root then SnapEntry(entry); break end
end
end
end
end
--- Re-snap the borders under any of several roots in ONE registry pass. roots maps
--- a frame to true (every border under it), "shown" (only while the border's
--- frame is visible) or "skip" (none of them); the nearest root up the parent
--- chain decides. The options panel uses it after a panel scale change: the
--- whole panel body and the popups at once, while hidden cached pages ("skip")
--- wait for their tab switch.
function PP.ResnapBordersUnderRoots(roots)
local GetParentFn = UIParent.GetParent
for i = 1, allBordersN do
local entry = allBorders[i]
local f = entry.frame
if f and entry.container then
local parent, mode = f, nil
for _ = 1, 10 do
local ok, p = pcall(GetParentFn, parent)
if not ok or not p then break end
parent = p
mode = roots[parent]
if mode then break end
end
if mode and mode ~= "skip" and (mode ~= "shown" or f:IsVisible()) then SnapEntry(entry) end
end
end
end
-- scaleGuard (opt-in, nameplate borders): marks a border whose PARENT has a DYNAMIC
-- effective scale (Scale Target/Casting Nameplate ease plate:SetScale live, recycled
-- plates snap back to 1, Blizzard rescales base plates). Two effects, flagged borders only:
-- 1. Container is scale-DECOUPLED (SetIgnoreParentScale(true) + SetScale 1): strips render
-- in fixed scale-1 space while anchors track the plate's live rect, so thickness stays
-- EXACTLY round(borderSize) physical pixels however the plate scales AFTER the snap.
-- Otherwise thickness bakes in at snap-time scale and a later DOWN-scale (target lost,
-- cast ended, mid-ease, recycled from an enlarged unit) leaves sub-1px strips that cover
-- no pixel center at many sub-pixel positions -- border sides VANISH as the plate slides
-- with the camera (DisablePixelSnap cannot fix this: it removes snap-to-0, not exact-geometry raster).
-- 2. SnapBorderTextures skips work while the PARENT's effective scale is degenerate (< 0.1,
-- the SetScale(0.001) hide path). Every other caller leaves scaleGuard nil, unaffected.
local function DecoupleBorderScale(container)
if container._scaleDecoupled then return end
if not container.SetIgnoreParentScale then return end
container._scaleDecoupled = true
container:SetIgnoreParentScale(true)
container:SetScale(1)
end
-- A border on a frame the options page pool handed out again (its dress
-- generation changed since the border was made): the kept container and
-- strips take this call's look, exactly as a new border would.
local function RedressBorder(frame, bd, gen, r, g, b, a, borderSize, drawLayer, subLevel, scaleGuard)
r = r or 0; g = g or 0; b = b or 0; a = a or 1
borderSize = borderSize or 1
drawLayer = drawLayer or "OVERLAY"
subLevel = subLevel or 0
local c = bd.container
bd.gen = gen
bd.borderSize = borderSize
bd.colDirty = nil
local col = bd.borderColor
if col then
col[1], col[2], col[3], col[4] = r, g, b, a
else
col = { r, g, b, a }
bd.borderColor = col
end
c._bdColor = col
c._hideTop, c._hideBottom, c._hideLeft, c._hideRight = nil, nil, nil, nil
c._snapEdge = nil
if c._pendingSnap then
c._pendingSnap = nil
c:SetScript("OnShow", nil)
end
if scaleGuard then
if not c._scaleGuard then
c._scaleGuard = true
DecoupleBorderScale(c)
end
elseif c._scaleGuard or c._scaleDecoupled then
c._scaleGuard = nil
if c._scaleDecoupled then
c._scaleDecoupled = nil
c:SetIgnoreParentScale(false)
c:SetScale(1)
end
end
c._top:SetDrawLayer(drawLayer, subLevel)
c._bottom:SetDrawLayer(drawLayer, subLevel)
c._left:SetDrawLayer(drawLayer, subLevel)
c._right:SetDrawLayer(drawLayer, subLevel)
c:SetFrameLevel(frame:GetFrameLevel() + 1)
c:Show()
SnapBorderTextures(c, frame, borderSize)
local ticks = 0
c:SetScript("OnUpdate", function(self)
ticks = ticks + 1
SnapBorderTextures(self, frame, bd.borderSize or 1)
if ticks >= 2 then pcall(self.SetScript, self, "OnUpdate", nil) end
end)
return c
end
function PP.CreateBorder(frame, r, g, b, a, borderSize, drawLayer, subLevel, scaleGuard)
local bd = _ppBorderData[frame]
if bd then
-- A border made on a frame of the options page pool (bd.gen) is dressed
-- again once its frame's dress generation moved on; a frame the pool
-- did not make never becomes one, so it pays one field read.
if bd.gen ~= nil then
local gen = Pool.Gen(frame)
if gen ~= bd.gen then
return RedressBorder(frame, bd, gen, r, g, b, a, borderSize, drawLayer, subLevel, scaleGuard)
end
end
-- A later call can opt an existing border into the guard. Decoupling changes
-- the container's coord space, so re-snap now (stored sizes used the old one).
if scaleGuard and not bd.container._scaleGuard then
bd.container._scaleGuard = true
DecoupleBorderScale(bd.container)
SnapBorderTextures(bd.container, frame, bd.borderSize or 1)
end
return bd.container
end
r = r or 0; g = g or 0; b = b or 0; a = a or 1
borderSize = borderSize or 1
drawLayer = drawLayer or "OVERLAY"
subLevel = subLevel or 0
-- 4 strips instead of BackdropTemplate: NineSlice corner sub-frames render as black boxes on nameplates.
local container = CreateFrame("Frame", nil, frame)
container:SetAllPoints(frame)
container:SetFrameLevel(frame:GetFrameLevel() + 1)
local WHITE = "Interface\\Buttons\\WHITE8X8"
local function MakeTex()
local tx = container:CreateTexture(nil, drawLayer, nil, subLevel)
tx:SetTexture(WHITE)
-- No pixel-grid snapping: a 1px edge strip must never round to 0 and
-- VANISH per-side at fractional scales/positions. Our textures, taint-safe.
PP.DisablePixelSnap(tx)
return tx
end
container._top = MakeTex()
container._bottom = MakeTex()
container._left = MakeTex()
container._right = MakeTex()
container._bdColor = { r, g, b, a }
container._scaleGuard = scaleGuard or nil
if scaleGuard then DecoupleBorderScale(container) end
bd = { container = container, borderSize = borderSize, borderColor = { r, g, b, a } }
_ppBorderData[frame] = bd
-- A frame of the options page pool keeps its border through every reuse:
-- the container rides on it (hidden at its reset) and the next call on
-- its new dress takes the RedressBorder path above.
local gen = Pool.Gen and Pool.Gen(frame)
if gen and Pool.Attach(frame, container) then bd.gen = gen end
SnapBorderTextures(container, frame, borderSize)
-- Re-snap for 2 frames to catch final effective scale after layout.
-- The stop is pcall'd: a container under a tooltip that a nameplate owns
-- inherits its forbidden layout aspect inside these two frames (Snap probes
-- and returns; a bare SetScript raises). Refused = keep ticking; the stop
-- lands once the restriction lifts, and a hidden container never ticks.
local ticks = 0
container:SetScript("OnUpdate", function(self)
ticks = ticks + 1
SnapBorderTextures(self, frame, bd.borderSize or 1)
if ticks >= 2 then pcall(self.SetScript, self, "OnUpdate", nil) end
end)
RegisterBorder(container, frame)
return container
end
-- On a frame the options page pool handed out again (bd.gen set, its dress
-- generation moved on) a border belongs to the previous dress: the frame
-- reads as a new one, without a border, until its next PP.CreateBorder
-- dresses the kept strips again (RedressBorder). The check sits inline in
-- each call below (SetBorderColor runs per aura per refresh); any other
-- frame pays one field read.
function PP.GetBorders(frame)
local bd = _ppBorderData[frame]
if bd and bd.gen ~= nil and Pool.Gen(frame) ~= bd.gen then bd = nil end
return bd and bd.container
end
function PP.SetBorderSize(frame, borderSize)
local bd = _ppBorderData[frame]
if bd and bd.gen ~= nil and Pool.Gen(frame) ~= bd.gen then bd = nil end
if not bd then return end
borderSize = borderSize or 1
SnapBorderTextures(bd.container, frame, borderSize)
bd.borderSize = borderSize
end
function PP.SetBorderColor(frame, r, g, b, a)
local bd = _ppBorderData[frame]
if bd and bd.gen ~= nil and Pool.Gen(frame) ~= bd.gen then bd = nil end
if not bd then return end
a = a or 1
-- Mutated in place, never replaced: called per-aura-per-refresh, and a fresh {r,g,b,a}
-- table per call was the addon's largest garbage source (1.2 MB/min). container._bdColor
-- aliases this table, so writes keep both views in sync. SECRET color components
-- (RaidFrames dispel colors in combat) can be WRITTEN to textures but never compared or
-- cached (the compare errors on a secret, and caching one breaks the NEXT clean call's
-- compare too); secrets skip the guard, leave the cache on the last CLEAN color, and mark dirty so the next clean color repaints.
local col = bd.borderColor
local secret = issecretvalue
and (issecretvalue(r) or issecretvalue(g) or issecretvalue(b) or issecretvalue(a))
if secret then
bd.colDirty = true
elseif col then
if not bd.colDirty
and col[1] == r and col[2] == g and col[3] == b and col[4] == a then
return -- unchanged; the four texture writes below are redundant
end
bd.colDirty = nil
col[1], col[2], col[3], col[4] = r, g, b, a
else
col = { r, g, b, a }
bd.borderColor = col
end
if col then bd.container._bdColor = col end
local c = bd.container
if c._top then c._top:SetVertexColor(r, g, b, a) end
if c._bottom then c._bottom:SetVertexColor(r, g, b, a) end
if c._left then c._left:SetVertexColor(r, g, b, a) end
if c._right then c._right:SetVertexColor(r, g, b, a) end
end
function PP.UpdateBorder(frame, borderSize, r, g, b, a)
if r then PP.SetBorderColor(frame, r, g, b, a) end
PP.SetBorderSize(frame, borderSize)
end
function PP.HideBorder(frame)
local bd = _ppBorderData[frame]
if bd and bd.gen ~= nil and Pool.Gen(frame) ~= bd.gen then bd = nil end
if bd then bd.container:Hide() end
end
function PP.ShowBorder(frame)
local bd = _ppBorderData[frame]
if bd and bd.gen ~= nil and Pool.Gen(frame) ~= bd.gen then bd = nil end
if bd then bd.container:Show() end
end
-- Variable-thickness border ring drawn from a single pre-shaped texture (natively
-- 7px thick), scaled by (7 - rawBorderSize) and clipped by `mask`. Called with `:`
-- so self.Point resolves to whichever PP module owns host's frame hierarchy
-- (world PP vs PanelPP) -- calling with `.` shifts every argument left by one.
function PP:ApplyMaskedShapeBorder(host, mask, texPath, rawBorderSize, r, g, b, a)
if not host then return end
rawBorderSize = rawBorderSize or 0
if rawBorderSize <= 0 or not texPath then
if host._shapeBorderShown then
host._shapeBorderTex:Hide()
host._shapeBorderShown = nil
end
return
end
r, g, b, a = r or 0, g or 0, b or 0, a or 1
-- Every field guarded: callers invoke this on every restyle pass regardless
-- of whether the border changed, and Remove/AddMaskTexture aren't cheap.
if host._shapeBorderShown and host._sbTex == texPath and host._sbSize == rawBorderSize
and host._sbMask == mask and host._sbR == r and host._sbG == g
and host._sbB == b and host._sbA == a then
return
end
if not host._shapeBorderTex then
host._shapeBorderTex = host:CreateTexture(nil, "OVERLAY")
local t = host._shapeBorderTex
if t.SetSnapToPixelGrid then t:SetSnapToPixelGrid(false); t:SetTexelSnappingBias(0) end
end
local tex = host._shapeBorderTex
local bExp = 7 - math.min(rawBorderSize, 7)
tex:ClearAllPoints()
self.Point(tex, "TOPLEFT", host, "TOPLEFT", -bExp, bExp)
self.Point(tex, "BOTTOMRIGHT", host, "BOTTOMRIGHT", bExp, -bExp)
if mask then
pcall(tex.RemoveMaskTexture, tex, mask)
pcall(tex.AddMaskTexture, tex, mask)
end
tex:SetTexture(texPath)
tex:SetVertexColor(r, g, b, a)
tex:Show()
host._sbTex, host._sbSize, host._sbMask, host._sbR, host._sbG, host._sbB, host._sbA =
texPath, rawBorderSize, mask, r, g, b, a
host._shapeBorderShown = true
end
-- Callers switching away from a shaped border (to the plain-line border, or off
-- entirely) must hide _shapeBorderTex through this, not a raw :Hide() -- the guard
-- above trusts _shapeBorderShown, so an external hide it doesn't know about leaves
-- a later ApplyMaskedShapeBorder call with the exact same args wrongly skipping
-- its own Show().
function PP:HideMaskedShapeBorder(host)
if host and host._shapeBorderShown then
host._shapeBorderTex:Hide()
host._shapeBorderShown = nil
end
end
---------------------------------------------------------------------------
-- Scale change watcher
---------------------------------------------------------------------------
local scaleWatcher = CreateFrame("Frame")
scaleWatcher:RegisterEvent("UI_SCALE_CHANGED")
scaleWatcher:RegisterEvent("DISPLAY_SIZE_CHANGED")
scaleWatcher:RegisterEvent("PLAYER_ENTERING_WORLD")
scaleWatcher:SetScript("OnEvent", function(_, event)
if event == "DISPLAY_SIZE_CHANGED" then
-- Resolution changed -- recalculate perfect and re-apply scale
PP.RefreshPhysical()
-- Only auto-update if user explicitly opted into auto scale
if EllesmereUIDB and EllesmereUIDB.ppUIScaleAuto == true then
PP.SetUIScale(PP.PixelBestSize())
else
PP.UpdateMult()
end
else
PP.UpdateMult()
end
PP.ResnapAllBorders()
if EllesmereUI.ReapplyPxBorders then EllesmereUI.ReapplyPxBorders() end
-- Re-sync panel scale after loading screens / resolution / UI scale changes
local mf = EllesmereUI._mainFrame
if mf and mf:IsShown() then
local physW = (GetPhysicalScreenSize())
local sw = GetScreenWidth()
if physW and physW > 0 and sw and sw > 0 then
local baseScale = sw / physW
local userScale = (EllesmereUIDB and EllesmereUIDB.panelScale) or 1.0
mf:SetScale(baseScale * userScale)
if EllesmereUI.PanelPP then EllesmereUI.PanelPP.UpdateMult() end
end
end
end)
end
-- File-level PP reference for code outside the do block
PP = EllesmereUI.PP
-------------------------------------------------------------------------------
-- Panel Pixel Perfect (PanelPP) -- the options panel runs at effective scale = baseScale *
-- userScale. At userScale 1.0, 1 unit = 1 physical pixel (integer rounding suffices); otherwise PanelPP computes its own mult (1 physical pixel in panel units) and snaps to that grid.
-------------------------------------------------------------------------------
do
local PanelPP = {}
EllesmereUI.PanelPP = PanelPP
local floor, type = math.floor, type
-- mult = 1 physical pixel in panel units (1.0 at userScale 1.0, ~0.9901 at
-- 1.01); recalculated by UpdateMult() when the panel scale changes.
PanelPP.mult = 1
function PanelPP.UpdateMult()
local userScale = (EllesmereUIDB and EllesmereUIDB.panelScale) or 1.0
if userScale == 0 then userScale = 1 end
-- 1 physical pixel = 1/userScale panel units
PanelPP.mult = 1 / userScale
end
-- Snap a value to the nearest physical pixel boundary in panel coords
function PanelPP.Scale(x)
if x == 0 then return 0 end
local m = PanelPP.mult
if m == 1 then return floor(x + 0.5) end
-- Same snapping algorithm as PP.Scale
local y = m > 1 and m or -m