Repository navigation
Expand file tree
/
Copy pathProxy.lua
More file actions
1759 lines (1661 loc) · 81.1 KB
/
Copy pathProxy.lua
File metadata and controls
1759 lines (1661 loc) · 81.1 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 = ...
-- A copy that lost the LibStub race (a renamed duplicate install) must go
-- fully inert: Core.lua only sets NS.Lib on the winning copy.
if not NS.Lib then return end
-- ============================================================
-- PROXIES
-- One plain Button per movable element, parented to the unlock frame.
-- The proxy is what the user drags; the real frame only ever moves through
-- the consumer's onChanged. Also owns the snap-zone visual pool.
-- ============================================================
local P = { proxies = {}, zones = {}, zoneCount = 0, dragZones = {},
tethers = {}, tetherGlows = {}, tetherCount = 0 }
NS.Proxy = P
local Registry, Solver, UI, L = NS.Registry, NS.Solver, NS.UI, NS.L
local CreateFrame, UIParent, GetCursorPosition, C_Timer, GetTime = CreateFrame, UIParent, GetCursorPosition, C_Timer, GetTime
local IsShiftKeyDown, IsControlKeyDown, IsAltKeyDown = IsShiftKeyDown, IsControlKeyDown, IsAltKeyDown
local pairs, ipairs, format, sqrt, max, abs, tsort = pairs, ipairs, string.format, math.sqrt, math.max, math.abs, table.sort
local MEDIA = "Interface\\AddOns\\DandersMover\\Media\\"
local DEFAULT_ICON = MEDIA .. "DF_Icon"
-- ---- the three small glyphs --------------------------------------
-- 64px supersampled TGAs (Tools-generated, flat white so SetVertexColor
-- carries the role colour). SVG was tried first -- 12.1's loader took the
-- paths but rendered green error squares, so its supported subset is
-- narrower than "any .svg"; high-res raster art gets the same crispness.
local LINK_ICON = MEDIA .. "link"
local DOT_ICON = MEDIA .. "dot"
local RING_ICON = MEDIA .. "ring"
-- Role colours, all off the shared palette. FREE (no anchor, nothing anchored
-- to it) = the party accent; CHILD (anchored to something) = the anchored
-- purple; ROOT (free, with children) = the anchorRoot green. A root that is
-- itself a child keeps the anchored colour and wears the root ring on its dot.
-- Free elements use the HOST accent (the mover sets a blue), not the shared
-- lavender: at 9px the lavender is indistinguishable from the anchored purple.
local C_FREE = UI:GetAccent() or UI.Colors.accent
local C_ANCHORED = UI.Colors.anchored
local C_ROOT = UI.Colors.anchorRoot
local C_MUTED = UI.Colors.textDim
local C_BODY = UI.Colors.panel -- the slab itself
local C_OUTLINE = UI.Colors.border -- neutral hairline; the ROLE never colours it
local PAD, GAP, TIGHT = UI.Space.section, UI.RowGap, UI.RowGapTight
local DOT, DOT_RING = 9, 11 -- role dot, and the root ring behind it
-- Only reached when the SV predate the setting; NS.DEFAULTS.snapDistance is the value.
local PROXIMITY = 100
local MIN_PROXY = 24
-- Slab metrics. The theme's row rhythm (UI.Space / UI.RowGap) is page-scale, and
-- a proxy is only ever as big as the frame it stands in for -- which can be 24px
-- square -- so the inline art carries its own small scale.
local EDGE_W = 3 -- role-coloured left edge
local ICON_SZ, LINK_SZ = 16, 12
local INSET, ITEM = 4, 4 -- slab padding, gap between inline items
-- The strip's and the tab's fill. Chrome, not a mover: it does not follow the
-- Mover Opacity setting below.
local BODY_ALPHA = 0.95
-- ---- mover opacity ---------------------------------------------------
-- A slab's FILL is the only part the Mover Opacity setting (Settings > Editor,
-- DandersMoverDB.moverOpacity) thins out. Everything that SAYS something stays
-- at full strength over it: the role edge and dot, the icon, the title and
-- coords, and the outline that carries selection (white), hover (softer white)
-- and the pin marker -- so a selected slab reads as selected at any setting.
-- The old fixed 0.95 fill hid the frames being arranged (tester report,
-- alpha.12); NS.DEFAULTS.moverOpacity is 0.5. Hover lifts the fill a step so
-- the slab under the cursor still stands out when the rest are faint.
local OPACITY_MIN, OPACITY_MAX, OPACITY_FALLBACK = 0.1, 1, 0.5
local HOVER_LIFT = 0.15
local function slabFillAlpha(hovered)
local a = NS.db and NS.db.moverOpacity
if type(a) ~= "number" then a = OPACITY_FALLBACK end
if a < OPACITY_MIN then a = OPACITY_MIN elseif a > OPACITY_MAX then a = OPACITY_MAX end
if hovered then a = a + HOVER_LIFT; if a > 1 then a = 1 end end
return a
end
local WEIGHT, SEL_WEIGHT = 1, 1 -- outline thickness; selection is colour, not weight
-- The "a panel is pinned open on this one" marker: the SAME white outline the
-- selection and hover wear, at a third alpha. 0.4 because it has to sit clearly
-- under hover (0.6) and selection (1) -- a resting marker that outshone the
-- thing under the cursor would make hover unreadable -- while staying clearly
-- above the neutral hairline (a 0.25 grey), or it would say nothing at all.
local PIN_ALPHA = 0.4
-- Below these the slab cannot hold everything, so parts drop out in this order:
-- the coords first, then the role DOT -- the addon icon wins the space (the
-- left edge already carries the role colour) -- then everything but the
-- floating title. The icon itself never drops.
local NO_COORDS_H, NO_COORDS_W, NO_DOT_W, TITLE_ONLY_W = 28, 120, 80, 60
-- Snap zones. Same accent as the free-role dot, because a zone IS where a free
-- drop would land; occupied ones go red. Hover is the only place a zone borrows
-- the selection white, and the kit has no white token, so that one is a literal.
local C_ZONE = UI.Colors.accent
local C_ZONE_OCCUPIED = UI.Colors.danger
local C_ZONE_HOVER = { r = 1, g = 1, b = 1 }
local ZONE_WEIGHT, ZONE_HOVER_WEIGHT = 1, 2
local TAG_PAD = 3 -- padding of the floating title pill
-- Session-open entrance: each slab fades in over FADE_IN with STAGGER between
-- slabs in build order. Lock/save/discard fades the whole overlay out over
-- FADE_OUT and only then tears it down (DismissAll). Combat suspend stays
-- instant -- Session:Suspend hides the unlock frame directly.
local FADE_IN, FADE_OUT, STAGGER = 0.45, 0.4, 0.035
-- ☠ THE OVERLAY'S FRAME LEVEL, NOT JUST ITS STRATA. Strata orders frames first
-- and level second, and a frame created under UIParent starts at level 1 -- so
-- the overlay at HIGH sat one level above UIParent, and anything ELSE at HIGH
-- with a few frames of nesting drew over the slabs. DandersFrames' personal
-- targeted-spells block is HIGH with its icons four frames deep (+5 on the
-- highlight), which is why its mover handle rendered underneath its own preview
-- icons. Slabs, zones and the rest are children and level from here.
local OVERLAY_LEVEL = 100
local ZONE_DASH_W = 3 -- dashed-edge thickness
local DASH_H, DASH_V = MEDIA .. "dash_h", MEDIA .. "dash_v"
-- ============================================================
-- UNLOCK FRAME + CURSOR
-- ============================================================
function P:GetUnlockFrame()
if self.unlockFrame then return self.unlockFrame end
local f = CreateFrame("Frame", "DandersMoverUnlockFrame", UIParent)
f:SetAllPoints(UIParent)
f:SetFrameStrata("HIGH")
f:SetFrameLevel(OVERLAY_LEVEL)
-- ☠ The overlay must NEVER take the mouse. It covers the whole screen, so
-- EnableMouse(true) would capture every click while a session is open -- the camera
-- could not be dragged and nothing behind the movers could be clicked. Esc deselects
-- (Session's OnKeyDown); only the CHILDREN (slabs, strip, panels) take clicks.
--
-- Alt-peek. The event is only registered while a session is up (Build / DestroyAll), so
-- this cannot fire outside one.
--
-- ☠ THE EVENTS DECIDE, NOT IsAltKeyDown. Each event's payload says which way that key
-- went (1 = pressed), and the two Alts are tracked apart, so a release only keeps the
-- peek up while the OTHER Alt's own press stands. IsAltKeyDown() reads the client's
-- lagging state: after an alt-tab, or a burst of fast taps, it answers "down" for a
-- second or two after a real release.
f:SetScript("OnEvent", function(_, event, key, down)
-- A click ends a peek whatever the Alt state says: reaching for a faded control
-- must bring it back, even when the release was lost to an alt-tab and the
-- client's own answer still says the key is down (P:WatchPeek).
if event == "GLOBAL_MOUSE_DOWN" then
P.altDown = {}
P:SetPeek(false)
return
end
if key ~= "LALT" and key ~= "RALT" then return end
P.altDown[key] = (down == 1 or down == true) or nil
P:SetPeek(next(P.altDown) ~= nil)
end)
f:Hide()
UI:AddFontRoot(f) -- the strip and panels follow a Settings Font change
self.unlockFrame = f
return f
end
-- Hold Alt to peek at the UI underneath: everything the session put on screen
-- (slabs, strip and panel are all children of the unlock frame) drops to
-- PEEK_ALPHA while Alt is held; release restores. Ignored while a drag is in
-- flight -- the drag is the one thing that must stay fully visible.
local PEEK_ALPHA = 0.1
P.altDown = {} -- "LALT"/"RALT" -> true while that key's press stands
function P:SetPeek(on)
on = on and true or false
if on then
for _, b in pairs(self.proxies) do if b.dragging then return end end
end
if self.peeking == on then return end
self.peeking = on
NS.Fx.FadeTo(self:GetUnlockFrame(), on and PEEK_ALPHA or 1, 0.1)
self:WatchPeek(on)
end
-- ☠ THE RELEASE CAN GO MISSING. MODIFIER_STATE_CHANGED does not reach an
-- unfocused client, so an alt-tab out on a held Alt left peeking = true with
-- nothing coming to clear it -- the overlay sat at PEEK_ALPHA until the next
-- press AND release, because the press alone was swallowed by the equality
-- guard above. A burst of fast presses can drop a release the same way. So
-- while peeking, ask the client's own answer a few times a second and stand
-- down the moment it says Alt is up; nothing runs while not peeking, because a
-- press is an event and events are reliable in that direction. Guarded for a
-- client or stub without a ticker, which just keeps the event-only behaviour.
local PEEK_POLL = 0.2
function P:WatchPeek(on)
if self.peekTicker then
self.peekTicker:Cancel()
self.peekTicker = nil
end
if not on or not (C_Timer and C_Timer.NewTicker) then return end
self.peekTicker = C_Timer.NewTicker(PEEK_POLL, function()
if not IsAltKeyDown() then
P.altDown = {}
P:SetPeek(false)
end
end)
end
function P:CursorPos()
local x, y = GetCursorPosition()
local s = UIParent:GetEffectiveScale()
local ux, uy = UIParent:GetCenter()
return x / s - ux, y / s - uy
end
-- ============================================================
-- PROXY LIFECYCLE
-- ============================================================
local function onDragStart(self)
local el = self.element
local cx, cy = P:CursorPos()
local pcx, pcy = self:GetCenter()
local ux, uy = UIParent:GetCenter()
self.grabX, self.grabY = cx - (pcx - ux), cy - (pcy - uy)
self.startX, self.startY = pcx - ux, pcy - uy
-- Seed the drop values so a drag that ends before its first OnUpdate
-- commits the start position rather than nil or a previous drag's values.
self.lastX, self.lastY, self.lastZone = self.startX, self.startY, nil
self.dragging = true
UI:HideTooltip()
NS.Session.selected = el.id -- select without docking the panel; EndDrag re-docks it
P:Highlight(el.id)
-- Only the FOLLOWING panel: it hangs off the slab that is about to move, so
-- it would be dragged around with it. Pinned panels are anchored to the
-- screen and stay put -- hiding them would make every drag flash the whole
-- pinned set off and on. Their content catches up on the drop (apply ->
-- Refresh), which is soon enough for a panel that is not moving.
if NS.Panel then NS.Panel:HideFollowing() end
NS.Session:BeginDrag(el)
P:ShowZones(el)
self:SetScript("OnUpdate", function(s)
local mx, my = P:CursorPos()
local nx, ny = mx - s.grabX, my - s.grabY
-- Axis locks: Shift = horizontal only, Ctrl = vertical only. Both held
-- cancel out to a free drag. Read every frame, so the lock can be taken
-- and released mid-drag.
local shift, ctrl = IsShiftKeyDown(), IsControlKeyDown()
if shift and not ctrl then ny = s.startY elseif ctrl and not shift then nx = s.startX end
-- Brighten the centre line of the axis the drag is locked TO.
NS.Grid:SetAxisLock(shift and not ctrl, ctrl and not shift)
local fx, fy, zone = NS.Session:DragTo(el, nx, ny)
s:ClearAllPoints(); s:SetPoint("CENTER", UIParent, "CENTER", fx, fy)
-- The record DragTo just wrote, read like the panel reads it. No new
-- allocation over the old line: one format string per frame, as before.
local rx, ry = Solver.Readout(Registry:GetPos(el))
s.coords:SetText(format("%d, %d", rx, ry))
P:UpdateZones(fx, fy, zone)
P:UpdateLegendDodge(fx, fy, s:GetWidth() or 0, s:GetHeight() or 0)
-- After the SetPoint above, so the tether's slab endpoint has no
-- one-frame lag behind the cursor.
P:UpdateTethers()
s.lastX, s.lastY, s.lastZone = fx, fy, zone
end)
end
local function onDragStop(self)
if not self.dragging then return end
self.dragging = false
self:SetScript("OnUpdate", nil)
P:HideZones()
P:ClearLegendDodge()
NS.Grid:HidePreview()
NS.Grid:HideMeasure()
NS.Grid:SetAxisLock(false, false)
NS.Session:EndDrag(self.element, self.lastX, self.lastY, self.lastZone)
end
-- A proxy's click routes through ClickSelect rather than selecting itself, so a
-- click on a stack of overlapping proxies can cycle through them even though the
-- top proxy is the one that took the click.
--
-- No link-gesture guard is needed here: the gesture starts on the panel's link
-- HANDLE, which keeps mouse capture between press and release, so the release
-- lands back on the handle and a proxy under the cursor never sees a click.
local function onClick(self)
P:ClickSelect()
end
-- ============================================================
-- OVERLAP CYCLING
-- The first click on a point selects the topmost proxy under it; clicking
-- again at (about) the same point cycles to the next one down, wrapping
-- around. The cycle resets when the click lands more than CYCLE_MOVE from the
-- last one or after CYCLE_TIMEOUT seconds. A click over nothing does nothing --
-- deselecting is Esc's job, and the overlay does not take clicks any more, so
-- the only way in here is a proxy's own click (which always has a hit).
-- ============================================================
local CYCLE_MOVE, CYCLE_TIMEOUT = 10, 2
local cycle = { x = nil, y = nil, at = 0, index = 0 }
-- Every shown proxy whose rect covers (x, y), topmost first: higher frame
-- level wins, ties go to the later-created button (which renders on top).
local function hitsAt(x, y)
local out = {}
local ux, uy = UIParent:GetCenter()
for _, b in pairs(P.proxies) do
if b:IsShown() then
local cx, cy = b:GetCenter()
if cx then
cx, cy = cx - ux, cy - uy
local hw, hh = (b:GetWidth() or 0) / 2, (b:GetHeight() or 0) / 2
if x >= cx - hw and x <= cx + hw and y >= cy - hh and y <= cy + hh then
out[#out + 1] = b
end
end
end
end
tsort(out, function(a, b)
local la, lb = a:GetFrameLevel() or 0, b:GetFrameLevel() or 0
if la ~= lb then return la > lb end
return (a.createIndex or 0) > (b.createIndex or 0)
end)
return out
end
function P:ClickSelect()
local x, y = self:CursorPos()
local hits = hitsAt(x, y)
if #hits == 0 then return end
local now = GetTime and GetTime() or 0
local same = cycle.x ~= nil
and abs(x - cycle.x) <= CYCLE_MOVE and abs(y - cycle.y) <= CYCLE_MOVE
and (now - cycle.at) <= CYCLE_TIMEOUT
if same then cycle.index = cycle.index % #hits + 1 else cycle.index = 1 end
cycle.x, cycle.y, cycle.at = x, y, now
NS.Session:Select(hits[cycle.index].element.id)
end
-- Lay the slab's contents out for its CURRENT size. A proxy is exactly as big as
-- the frame it stands in for, so one 24px icon mover and one full raid container
-- come through here: parts drop out as the room runs out rather than overlapping.
local function layout(b, anchored)
local w = b:GetWidth() or 0
local h = b:GetHeight() or 0
local titleOnly = w < TITLE_ONLY_W
local showDot = not titleOnly and w >= NO_DOT_W
local showCoords = not titleOnly and w >= NO_COORDS_W and h >= NO_COORDS_H
local showLink = anchored and not titleOnly
-- The thresholds are only the fast path. A long title can fail to fit a slab
-- wide enough to keep the normal layout, and its LEFT->RIGHT anchors would
-- ellipsise it -- so MEASURE the text and fall back in the same order the
-- thresholds do: coords out, then the dot, then the centred overflow title.
-- The title itself never truncates, and the icon never drops.
if b.layoutTitle ~= b.element.title then b.title:SetText(b.element.title) end
if not titleOnly then
-- Unbounded = the full text's width even while the FontString is
-- clipped; guarded for stubs that only have GetStringWidth.
local titleW
if b.title.GetUnboundedStringWidth then titleW = b.title:GetUnboundedStringWidth() end
titleW = titleW or b.title:GetStringWidth() or 0
local function avail()
local left = EDGE_W + INSET + ICON_SZ + ITEM
if showDot then left = left + DOT + ITEM end
local right = INSET
if showCoords then right = right + (b.coords:GetStringWidth() or 0) + ITEM end
if showLink then right = right + LINK_SZ + ITEM end
return w - left - right
end
if titleW > avail() then showCoords = false end
if titleW > avail() then showDot = false end
if titleW > avail() then titleOnly, showDot, showCoords, showLink = true, false, false, false end
end
-- Dragging re-runs this for EVERY proxy on every frame (RefreshAll ->
-- Refresh -> Highlight), so the anchors and the SetText only get touched
-- when the answer actually changed. Which parts are visible, plus the
-- title, is the whole of the layout's input.
local key = (titleOnly and 1 or 0) + (showDot and 2 or 0)
+ (showCoords and 4 or 0) + (showLink and 8 or 0)
b.showDot = showDot -- applyLook's ring placement reads this
if b.layoutKey == key and b.layoutTitle == b.element.title then return end
b.layoutKey, b.layoutTitle = key, b.element.title
b.icon:Show() -- explicit for pooled reuse and stubs
b.dot:SetShown(showDot)
b.coords:SetShown(showCoords)
b.link:SetShown(showLink)
-- Anchored whether or not they are shown: a region with no points is a
-- region nothing else can anchor OFF, and the title does exactly that.
-- With the dot dropped the icon takes its place flush left, and the root
-- ring re-homes onto whichever of the two is the leftmost marker.
b.icon:ClearAllPoints()
if showDot then b.icon:SetPoint("LEFT", b.dot, "RIGHT", ITEM, 0)
else b.icon:SetPoint("LEFT", b, "LEFT", EDGE_W + INSET, 0) end
b.root:ClearAllPoints()
if showDot then
b.root:SetPoint("CENTER", b.dot, "CENTER")
b.root:SetSize(DOT_RING, DOT_RING)
else
b.root:SetPoint("CENTER", b.icon, "CENTER")
b.root:SetSize(ICON_SZ + 4, ICON_SZ + 4)
end
b.link:ClearAllPoints()
if showCoords then b.link:SetPoint("RIGHT", b.coords, "LEFT", -ITEM, 0)
else b.link:SetPoint("RIGHT", b, "RIGHT", -INSET, 0) end
b.title:ClearAllPoints()
b.title:SetPoint("LEFT", b.icon, "RIGHT", ITEM, 0)
if showLink then b.title:SetPoint("RIGHT", b.link, "LEFT", -ITEM, 0)
elseif showCoords then b.title:SetPoint("RIGHT", b.coords, "LEFT", -ITEM, 0)
else b.title:SetPoint("RIGHT", b, "RIGHT", -INSET, 0) end
-- Small slabs: the full title, centred and allowed to overflow the slab --
-- never truncated (an outlined font stays readable over the world).
if titleOnly then
-- Too narrow for an in-slab title: float it in a pill BELOW the slab so
-- the dot, edge and logo stay visible and the text sits on its own dark
-- background instead of over the world or the frame's contents.
b.title:ClearAllPoints()
b.title:SetPoint("TOP", b, "BOTTOM", 0, -(TAG_PAD + 2))
b.tagBg:ClearAllPoints()
b.tagBg:SetPoint("TOPLEFT", b.title, "TOPLEFT", -TAG_PAD, TAG_PAD)
b.tagBg:SetPoint("BOTTOMRIGHT", b.title, "BOTTOMRIGHT", TAG_PAD, -TAG_PAD)
b.titleFloating = true
else
b.titleFloating = false
b.tagShown = nil
-- A pill fade may still be running from the floating state; cancel it
-- so its "hide when done" cannot swallow the in-slab title.
NS.Fx.Cancel(b.title)
NS.Fx.Cancel(b.tagBg)
b.tagBg:Hide()
b.title:Show()
end
end
-- Everything about how one slab READS: role colour, markers, selection and hover
-- chrome, and the layout that follows from its size. One function, so no state
-- change can repaint half a slab and leave the other half saying something else.
local function applyLook(b, selected, hovered)
local pos = Registry:GetPos(b.element)
-- Children() is alias-aware, so a target that resolves to this element's
-- frame counts too.
local isRoot = Registry:HasChildren(b.element.id)
local c = pos.anchor and C_ANCHORED or (isRoot and C_ROOT or C_FREE)
b.edge:SetColorTexture(c.r, c.g, c.b, 1)
b.dot:SetVertexColor(c.r, c.g, c.b)
layout(b, pos.anchor ~= nil)
-- With the dot on show, the ring marks the one case the dot cannot: a root
-- that is ITSELF anchored, whose dot is already wearing the anchored
-- purple. On a slab too narrow for the dot the ICON carries the ring for
-- EVERY root, or the root state would vanish with the dot (layout homes
-- the ring on whichever marker is showing).
local ringOn
if b.showDot then ringOn = isRoot and pos.anchor ~= nil
else ringOn = isRoot end
b.root:SetShown(ringOn and true or false)
-- A floating title is on-demand chrome: shown only while this slab is the
-- one being looked at or moved, so stacked anchors do not pile pills on
-- top of each other. The tooltip still names an idle slab on hover.
if b.titleFloating then
local showTag = (selected or hovered or b.dragging) and true or false
-- Fade rather than pop, but only on the transition: applyLook runs
-- every frame of a drag and must not restart the animation.
if b.tagShown ~= showTag then
b.tagShown = showTag
if showTag then
NS.Fx.FadeIn(b.title, 0.1)
NS.Fx.FadeIn(b.tagBg, 0.1)
else
NS.Fx.FadeOut(b.title, 0.1, function() b.title:Hide() end)
NS.Fx.FadeOut(b.tagBg, 0.1, function() b.tagBg:Hide() end)
end
end
end
b:SetBackdropColor(C_BODY.r, C_BODY.g, C_BODY.b, slabFillAlpha(hovered))
-- Selection is the OUTLINE, never the fill or the role colour: white and
-- twice as thick. Hover is a softer white at the same weight, and it stands
-- down for the selected proxy so hovering cannot make it look less selected.
-- Below both, the pin marker: a mover with a panel pinned open on it keeps a
-- dim white outline at rest, so several pinned panels can be told apart from
-- the movers they belong to at a glance. NS.Panel is guarded because Proxy
-- loads before it.
local weight = selected and SEL_WEIGHT or WEIGHT
local r, g, bl, a = C_OUTLINE.r, C_OUTLINE.g, C_OUTLINE.b, 1
if selected then r, g, bl, a = 1, 1, 1, 1
elseif hovered then r, g, bl, a = 1, 1, 1, 0.6
elseif NS.Panel and NS.Panel:IsElementPinned(b.element.id) then r, g, bl, a = 1, 1, 1, PIN_ALPHA end
-- Weight is baked into the border textures, so it only re-lays out when the
-- thickness actually changes; a recolour goes through the cheap shim.
if b.outlineWeight ~= weight then
UI:ApplyPixelBorder(b, { r, g, bl, a }, weight)
b.outlineWeight = weight
else
b:SetBackdropBorderColor(r, g, bl, a)
end
end
-- ------------------------------------------------------------
-- SLAB TEXT SCALE
-- The title and coords follow the chrome scale (Settings > Scale); the slab
-- itself, its icon and markers do not -- it is as big as its frame. Through
-- SetTextScale rather than a sized font: the title keeps its font OBJECT, and
-- with it the multi-alphabet family an element name in another script needs.
-- Tester report (alpha.12): "Party Frames" and the coords ignored the setting.
-- Only touched when the value changes; clearing layoutKey makes the next
-- layout re-measure what fits at the new size.
-- ------------------------------------------------------------
local function applySlabTextScale(b)
local s = NS:ChromeScale()
if b.textScale == s then return end
b.textScale = s
if b.title.SetTextScale then b.title:SetTextScale(s) end
if b.coords.SetTextScale then b.coords:SetTextScale(s) end
b.layoutKey = nil
end
local function create(el)
local b = CreateFrame("Button", nil, P:GetUnlockFrame(), "BackdropTemplate")
-- A solid dark slab with a neutral hairline. The role is carried by the dot
-- and the left edge ONLY, so the outline is free to mean "selected" and the
-- body is free to stay readable behind whatever the proxy is sitting on.
UI:CreateElementBackdrop(b, {
bgColor = { C_BODY.r, C_BODY.g, C_BODY.b, slabFillAlpha(false) },
borderColor = { C_OUTLINE.r, C_OUTLINE.g, C_OUTLINE.b, 1 },
})
b.outlineWeight = WEIGHT
b:RegisterForClicks("LeftButtonUp")
b:RegisterForDrag("LeftButton")
b:SetMovable(false)
-- ☠ NO SetClampedToScreen HERE. The client clamps FULLY, while the element the slab
-- stands for is only clamped loosely (an anchored solve keeps any seat that leaves part
-- of it on screen -- Core's KeepOnScreen), so a client-clamped slab would sit on screen
-- off its frames, quote coords for a place nothing is, and make the first drag move the
-- element. syncGeometry places the slab through Solver.KeepOnScreen, the exact rule the
-- element gets, so slab and element agree whenever any of the element is visible. The
-- one case they part is an element with NOTHING on screen: its slab is pulled to the
-- nearest visible spot as the rescue handle, and dragging it re-places the element.
-- Role edge: full height, flush left, and UNDER the pixel border (which draws
-- at ARTWORK sublevel 7) so the selection outline always reads over it.
b.edge = b:CreateTexture(nil, "ARTWORK", nil, 6)
b.edge:SetWidth(EDGE_W)
b.edge:SetPoint("TOPLEFT"); b.edge:SetPoint("BOTTOMLEFT")
-- Role dot, with the root ring behind it: a hollow circle one pixel larger
-- all round, so what shows is a 1px rim.
b.root = b:CreateTexture(nil, "OVERLAY", nil, 1)
b.root:SetTexture(RING_ICON); b.root:SetSize(DOT_RING, DOT_RING)
b.root:SetVertexColor(C_ROOT.r, C_ROOT.g, C_ROOT.b); b.root:Hide()
b.dot = b:CreateTexture(nil, "OVERLAY", nil, 2)
b.dot:SetTexture(DOT_ICON); b.dot:SetSize(DOT, DOT)
b.dot:SetPoint("LEFT", b, "LEFT", EDGE_W + INSET, 0)
b.root:SetPoint("CENTER", b.dot, "CENTER")
-- Owning addon's icon; falls back to the DF icon bundled with the lib.
-- Sublevel 2 (like the dot) so the root ring at sublevel 1 sits BEHIND it
-- when a narrow slab homes the ring on the icon instead of the dot.
b.icon = b:CreateTexture(nil, "OVERLAY", nil, 2)
b.icon:SetSize(ICON_SZ, ICON_SZ)
local addon = Registry:GetAddon(el.addon)
if b.icon:SetTexture(addon and addon.icon or DEFAULT_ICON) == false then b.icon:SetTexture(DEFAULT_ICON) end
b.title = UI:CreateLabel(b, { text = el.title, font = "DFFontNormal", color = UI.Colors.text })
-- Pill behind the title when it floats below a too-narrow slab.
b.tagBg = b:CreateTexture(nil, "BACKGROUND")
b.tagBg:SetColorTexture(C_BODY.r, C_BODY.g, C_BODY.b, 0.92)
b.tagBg:Hide()
b.title:SetWordWrap(false)
b.coords = UI:CreateLabel(b, { size = 9, color = C_MUTED, justify = "RIGHT" })
b.coords:SetPoint("RIGHT", b, "RIGHT", -INSET, 0)
-- Link glyph while anchored; sits just before the coords.
b.link = b:CreateTexture(nil, "OVERLAY")
b.link:SetTexture(LINK_ICON); b.link:SetSize(LINK_SZ, LINK_SZ)
b.link:SetVertexColor(C_ANCHORED.r, C_ANCHORED.g, C_ANCHORED.b); b.link:Hide()
-- Centre crosshair, quiet enough that it marks the centre without competing
-- with the content beside it.
b.crossH = b:CreateTexture(nil, "OVERLAY"); b.crossH:SetColorTexture(1, 1, 1, 0.25); b.crossH:SetSize(16, 1); b.crossH:SetPoint("CENTER")
b.crossV = b:CreateTexture(nil, "OVERLAY"); b.crossV:SetColorTexture(1, 1, 1, 0.25); b.crossV:SetSize(1, 16); b.crossV:SetPoint("CENTER")
b.textScale = nil
applySlabTextScale(b)
b:SetScript("OnDragStart", onDragStart)
b:SetScript("OnDragStop", onDragStop)
b:SetScript("OnClick", onClick)
b:SetScript("OnEnter", function(s)
s.hovered = true
applyLook(s, NS.Session and NS.Session.selected == s.element.id, true)
P:ShowTooltip(s)
end)
b:SetScript("OnLeave", function(s)
s.hovered = false
P:Highlight(NS.Session and NS.Session.selected)
UI:HideTooltip()
end)
b.element = el
-- Creation order breaks z-ties in the overlap cycle: at equal frame level
-- the later-created button renders on top.
P.createCounter = (P.createCounter or 0) + 1
b.createIndex = P.createCounter
return b
end
-- ☠ SLABS ARE POOLED, NEVER DROPPED. WoW does not free a frame, so a slab that
-- Remove let go of was leaked for good -- and every lock, every mid-session
-- Rebuild (DandersFrames re-registers its anchor targets on unlock and on every
-- sort) let go of ALL of them, so each unlock added a full set of Buttons plus
-- their regions that never came back. Remove parks the slab here; acquire hands
-- it out again and re-points it at its new element. createIndex is kept: it is
-- the frame's own creation order, which is what breaks the z-tie it stands for.
local spareSlabs = {}
local function acquire(el)
local n = #spareSlabs
if n == 0 then return create(el) end
local b = spareSlabs[n]
spareSlabs[n] = nil
b.element = el
b.dragging, b.hovered, b.tagShown = false, false, nil
-- nil forces layout() to re-anchor and re-title for the new element.
b.layoutKey, b.layoutTitle = nil, nil
-- The scale may have moved while it sat parked.
applySlabTextScale(b)
local addon = Registry:GetAddon(el.addon)
if b.icon:SetTexture(addon and addon.icon or DEFAULT_ICON) == false then b.icon:SetTexture(DEFAULT_ICON) end
return b
end
-- filter is the NORMALISED session filter (Session.filter): nil, or
-- { addon = <string|nil>, keySet = <set|nil> }. The initiator's keys outside keySet
-- get NO proxy at all, not a dimmed one (a party unlock must not put raid proxies on
-- screen); other addons' elements get one only with showOtherAddons (Registry:WantsProxy).
-- animate: session open only -- the whole overlay fades in (the mirror of
-- DismissAll's fade out, so strip, slabs and panel all arrive together) and the
-- slabs add a small stagger in build order on top. Rebuilds mid-session come
-- through Rebuild, without it: new slabs are instant, but the overlay -- and
-- an entrance still playing on it -- is left exactly as it is.
function P:Build(filter, animate)
local f = self:GetUnlockFrame()
-- The OVERLAY half runs once per session (Rebuild leaves it up): a lock's
-- dismiss fade may still be running (lock -> unlock inside FADE_OUT), so
-- invalidate its deferred teardown and restore the frame it was fading.
if not self.overlayUp then
self.overlayUp = true
self.dismissToken = (self.dismissToken or 0) + 1
NS.Fx.Cancel(f)
self.peeking = false -- Cancel above restored alpha 1
self.altDown = {}
self:WatchPeek(false)
if f.RegisterEvent then
f:RegisterEvent("MODIFIER_STATE_CHANGED")
f:RegisterEvent("GLOBAL_MOUSE_DOWN")
end
if animate then NS.Fx.FadeIn(f, FADE_IN) else f:Show() end
end
local n = 0
for _, el in ipairs(Registry:SortedElements()) do
if Registry:WantsProxy(filter, el) then
local frame = Registry:GetFrame(el)
if NS.db.showHiddenMovers or (frame and frame:IsShown()) then
local b = self.proxies[el.id] or acquire(el)
b.element = el
self.proxies[el.id] = b
self:Refresh(el.id)
b:Show()
if animate and C_Timer then
n = n + 1
b:SetAlpha(0)
local id = el.id
C_Timer.After((n - 1) * STAGGER, function()
-- The pool can be torn down and rebuilt while this
-- timer is pending; only fade the exact button that is
-- still the live proxy for the id.
if self.proxies[id] == b and b:IsShown() then
NS.Fx.FadeIn(b, FADE_IN)
end
end)
else
-- A rebuild can reuse a slab whose entrance was interrupted
-- mid-fade; make sure it rests at full alpha.
NS.Fx.Cancel(b)
end
end
end
end
self:ShowLegend()
end
-- Mid-session rebuild: the slabs are torn down and built again against the
-- current filter; the overlay is not touched. NOT DestroyAll + Build --
-- DestroyAll resets the overlay (alpha back to 1, any entrance cancelled), and
-- a consumer that re-registers its targets early in a session would then cut
-- the entrance fade short.
function P:Rebuild(filter)
for id in pairs(self.proxies) do self:Remove(id) end
self:HideZones()
self:HideTethers()
self:Build(filter)
end
-- The GEOMETRY half of a refresh: the slab's size and centre re-measured from
-- the element's CURRENT rect, plus the coords readout. Returns whether it
-- actually ran.
--
-- Split out of Refresh so a sweep can re-measure a whole subtree and repaint
-- ONCE at the end: Highlight walks every slab, so calling it per element costs
-- n passes for the same answer one pass gives.
--
-- ☠ A SLAB BEING DRAGGED IS LEFT ALONE. The drag owns its position for as long
-- as the button is down (its OnUpdate SetPoints it to the cursor every frame),
-- and it re-reads the record on drop -- EndDrag -> apply -> RefreshAll. Writing
-- geometry here mid-drag would fight the cursor.
local function syncGeometry(b)
if not b or b.dragging then return false end
local el = b.element
local rect = Registry:GetRect(el)
local pos = Registry:GetPos(el)
local w, h = Registry:GetSize(el)
w, h = w or MIN_PROXY, h or MIN_PROXY
local cx, cy
if rect then cx, cy = rect.x, rect.y; w, h = rect.w, rect.h
else cx, cy = Solver.PointToCenter(pos.point or "CENTER", pos.x or 0, pos.y or 0, w, h) end
w, h = math.max(w, MIN_PROXY), math.max(h, MIN_PROXY)
-- The element's own clamp rule, not a harder one: see create().
local sw, sh = UIParent:GetWidth(), UIParent:GetHeight()
if sw and sh then cx, cy = Solver.KeepOnScreen(cx, cy, w, h, sw, sh) end
b:SetSize(w, h)
b:ClearAllPoints(); b:SetPoint("CENTER", UIParent, "CENTER", cx, cy)
-- A mover whose element is not on screen has no meaningful position to
-- report, so the coords slot says so instead of quoting a stale number.
-- Availability, not the raw frame's IsShown: a consumer getRect owns the
-- "is it visible" question (Registry:IsTargetAvailable), and DF's raid
-- container is hidden behind its test-mode preview during every mover
-- session -- the visible thing is the test container its getRect measures.
-- Elements without getRect still fall back to the frame's shown state.
local shown = Registry:IsTargetAvailable(el)
-- Hidden frames keep a full-strength slab; the muted "hidden" word carries
-- the state on its own.
-- ☠ THE RECORD, NOT THE SLAB'S CENTRE: the same pair the panel's X/Y boxes
-- show (Solver.Readout). The centre is a different number whenever the
-- record's point is not CENTER or the visible rect is offset from the record.
if shown then
local rx, ry = Solver.Readout(pos)
b.coords:SetText(format("%d, %d", rx, ry))
else
b.coords:SetText(L["hidden"])
end
return true
end
-- One slab re-measured, no repaint. For callers that are about to repaint
-- anyway (SyncMany) or that only care about geometry.
function P:SyncElement(id) return syncGeometry(self.proxies[id]) end
-- Re-measure a batch of slabs, then repaint ONCE. `ids` may name things that
-- have no proxy at all -- a pure anchor target, an element outside the session
-- filter, an id from a consumer's key list that was never movable -- and those
-- are skipped rather than being an error.
function P:SyncMany(ids)
local any = false
for i = 1, #ids do
if syncGeometry(self.proxies[ids[i]]) then any = true end
end
if any then self:Highlight(NS.Session and NS.Session.selected) end
return any
end
function P:Refresh(id)
if not syncGeometry(self.proxies[id]) then return end
self:Highlight(NS.Session and NS.Session.selected)
end
-- Every slab re-measured, then ONE repaint -- the SyncMany shape. Each Refresh repaints
-- EVERY slab, so refreshing per slab would paint n*n slabs a frame during a drag (DragTo
-- runs this every frame).
function P:RefreshAll()
local any = false
for _, b in pairs(self.proxies) do
if syncGeometry(b) then any = true end
end
if any then self:Highlight(NS.Session and NS.Session.selected) end
end
-- Is any slab under the cursor right now? Callers that would otherwise put
-- session chrome back on screen (the panel a drag deliberately hid) ask first.
function P:IsDragging()
for _, b in pairs(self.proxies) do if b.dragging then return true end end
return false
end
-- Finish a drag in flight where it stands, as a release there would. A combat suspend
-- hides the slab (it is a child of the unlock frame), and a hidden frame never gets its
-- OnDragStop -- the drag would be left half-done: no undo entry, the tether armed, and
-- IsDragging stuck true after Resume.
function P:EndActiveDrag()
for _, b in pairs(self.proxies) do
if b.dragging then onDragStop(b) end
end
end
function P:Highlight(selectedId)
for id, b in pairs(self.proxies) do
applyLook(b, id == selectedId, b.hovered)
end
self:UpdateTethers()
end
-- The Mover Opacity setting moved: repaint every slab (applyLook reads it).
function P:ApplyOpacity()
self:Highlight(NS.Session and NS.Session.selected)
end
function P:Remove(id)
local b = self.proxies[id]
if b then
b:Hide(); b:SetScript("OnUpdate", nil); self.proxies[id] = nil
NS.Fx.Cancel(b) -- an entrance still playing must not finish on a parked slab
spareSlabs[#spareSlabs + 1] = b
end
end
function P:RemoveAddon(addon)
for id, b in pairs(self.proxies) do if b.element.addon == addon then self:Remove(id) end end
end
function P:DestroyAll()
for id in pairs(self.proxies) do self:Remove(id) end
self.overlayUp = false
self:WatchPeek(false)
if self.unlockFrame then
if self.unlockFrame.UnregisterEvent then
self.unlockFrame:UnregisterEvent("MODIFIER_STATE_CHANGED")
self.unlockFrame:UnregisterEvent("GLOBAL_MOUSE_DOWN")
end
self.peeking = false
self.altDown = {}
self.unlockFrame:SetAlpha(1)
self.unlockFrame:Hide()
end
self:HideZones()
self:HideTethers()
self:HideLegend()
if self.toast then NS.Fx.Cancel(self.toast); self.toast:Hide() end
end
-- Lock/save/discard: fade the whole overlay (slabs, legend, panel) out, then
-- destroy. The token guards the deferred teardown -- a new session can open
-- before the fade lands, and its freshly built proxies must not be destroyed
-- by the previous session's callback (Build bumps the token and cancels the
-- fade). The overlay never held the mouse, so the screen is usable throughout.
function P:DismissAll()
local f = self.unlockFrame
if not f or not f:IsShown() then self:DestroyAll() return end
local token = (self.dismissToken or 0) + 1
self.dismissToken = token
self.peeking = false -- the fade below plays from full alpha
self:WatchPeek(false)
self.overlayUp = false -- on its way out: the next Build re-opens it
NS.Fx.FadeOut(f, FADE_OUT, function()
if self.dismissToken == token and not (NS.Session and NS.Session:IsActive()) then
self:DestroyAll()
end
end)
end
-- ============================================================
-- ANCHOR TETHER
-- A thin line from an anchored slab's centre to the nearest point on its
-- anchor target's rect (the centre when the slab sits inside it). Shown while
-- the slab is selected, hovered or being dragged; hovering also lights the
-- whole chain -- the hovered slab's parent link AND every child's link
-- (alias-aware via Registry:Children). Subtle lavender at rest; the dragged
-- slab's tether lerps to the danger red and thins as it strains
-- (Session.tether), and flashes once when it snaps.
--
-- Drawn with Line objects (frame:CreateLine), not a rotated texture:
-- TextureBase:SetRotation rotates the texture inside its axis-aligned quad,
-- so a long thin quad cannot draw a diagonal.
-- ============================================================
local C_TETHER = UI.Colors.accent
local C_TETHER_STRAIN = UI.Colors.danger
local TETHER_W, TETHER_STRAIN_W = 3, 1
local TETHER_ALPHA, TETHER_STRAIN_ALPHA = 0.55, 0.8
-- A Line takes a flat colour (SetColorTexture) and the kit has no soft-edged
-- line art to hand it instead, so the soft edge is DRAWN: a wider, nearly
-- transparent line on a lower sublevel behind the tether. It reads as a glow
-- at a glance and keeps the tether itself from being a hard 3px rule over the
-- UI. Its own pool, index-parallel with P.tethers.
local TETHER_GLOW_W, TETHER_GLOW_ALPHA = 5, 0.15
-- Pooled line on the unlock frame (under the slabs, which are child frames).
-- nil in a headless stub without CreateLine support.
local function tetherLine(pool, i, subLevel)
local t = pool[i]
if t == nil then
local f = P:GetUnlockFrame()
t = f.CreateLine and f:CreateLine(nil, "ARTWORK", nil, subLevel) or false
pool[i] = t
end
return t or nil
end
-- Proxy centre in UIParent-centre units.
local function proxyCenter(b)
local cx, cy = b:GetCenter()
if not cx then return nil end
local ux, uy = UIParent:GetCenter()
return cx - ux, cy - uy
end
-- Nearest point on a centre-based rect from (cx, cy); the rect's centre when
-- the point lies inside it.
local function nearestOnRect(rect, cx, cy)
local l, r = rect.x - rect.w / 2, rect.x + rect.w / 2
local b, t = rect.y - rect.h / 2, rect.y + rect.h / 2
local px = cx < l and l or (cx > r and r or cx)
local py = cy < b and b or (cy > t and t or cy)
if px == cx and py == cy then return rect.x, rect.y end
return px, py
end
local function lerp(a, b, f) return a + (b - a) * f end
-- Lay one tether for element el / slab b. Returns the new pool watermark.
-- drag is Session.tether: only the slab actually being dragged reads it (the
-- working record's anchor is nil'd during a drag, so the target comes from
-- there), everyone else reads their own record.
local function drawTether(n, el, b, drag)
local targetId, strain
-- The active block for a non-dragging slab, which may be the backup anchor:
-- the line has to show the link that is actually holding the element.
local active, onFallback
if b.dragging then
if not drag or drag.snapped then return n end -- snapped: tether gone
targetId, strain = drag.target, drag.strain or 0
else
local a = Registry:ActiveAnchor(el)
if not a then return n end
active = a
onFallback = a == Registry:GetPos(el).anchor.fallback
targetId, strain = a.target, 0
end
local target = Registry:GetTarget(targetId)
local rect = target and Registry:GetRect(target)
if not rect then return n end
local cx, cy = proxyCenter(b)
if not cx then return n end
-- The tether's far end is where the anchor CONNECTS on the parent -- the
-- edge/align (or relPoint) spot on the target itself. Fixed: it neither
-- slides along the parent's surface nor floats at the child's seat.
local tx, ty
local spec = (b.dragging and drag) and drag.spec or active
if spec then tx, ty = Solver.AnchorPointOnTarget(spec, rect) end
if not tx then tx, ty = nearestOnRect(rect, cx, cy) end
local line = tetherLine(P.tethers, n + 1, 0)
if not line then return n end
local cr = lerp(C_TETHER.r, C_TETHER_STRAIN.r, strain)
local cg = lerp(C_TETHER.g, C_TETHER_STRAIN.g, strain)
local cb = lerp(C_TETHER.b, C_TETHER_STRAIN.b, strain)
-- Standing on the backup: a visibly lesser link -- thin and muted, not the
-- full accent line the primary gets.
if onFallback then cr, cg, cb = C_MUTED.r, C_MUTED.g, C_MUTED.b end
local glow = tetherLine(P.tetherGlows, n + 1, -1)
if glow then
glow:SetStartPoint("CENTER", UIParent, cx, cy)
glow:SetEndPoint("CENTER", UIParent, tx, ty)
glow:SetThickness(TETHER_GLOW_W)
glow:SetColorTexture(cr, cg, cb, TETHER_GLOW_ALPHA)
glow:Show()
end
line:SetStartPoint("CENTER", UIParent, cx, cy)
line:SetEndPoint("CENTER", UIParent, tx, ty)
line:SetThickness((onFallback or strain > 0) and TETHER_STRAIN_W or TETHER_W)
line:SetColorTexture(cr, cg, cb, lerp(TETHER_ALPHA, TETHER_STRAIN_ALPHA, strain))
line:Show()
return n + 1
end
-- Which slabs show their parent tether: the selected one, any hovered one
-- (plus all of the hovered one's children -- the chain highlight), and the
-- one being dragged. Called from Highlight, so every selection/hover change
-- and every drag frame comes through here.
function P:UpdateTethers()
local sess = NS.Session
local drag = sess and sess.tether
local want = {}
local selected = sess and sess.selected
if selected and self.proxies[selected] then want[selected] = true end
for id, b in pairs(self.proxies) do
if b:IsShown() then
if b.hovered then
want[id] = true
for _, child in ipairs(Registry:Children(id)) do
if self.proxies[child.id] then want[child.id] = true end
end
end
if b.dragging then want[id] = true end
end
end
local n = 0
for id in pairs(want) do
local el = Registry:Get(id)
local b = self.proxies[id]
if el and b and b:IsShown() then n = drawTether(n, el, b, drag) end
end
for i = n + 1, #self.tethers do
local t = self.tethers[i]
if t then t:Hide() end
end