Repository navigation
Expand file tree
/
Copy pathSession.lua
More file actions
1036 lines (978 loc) · 44.7 KB
/
Copy pathSession.lua
File metadata and controls
1036 lines (978 loc) · 44.7 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
-- ============================================================
-- SESSION
-- The unlock lifecycle. Snapshots on enter, pending edits on the live records,
-- Save/Discard on exit, suspend/resume around combat, undo stack, keyboard.
-- ============================================================
local Sess = { active = false, suspended = false, snapshots = {}, dirty = false, selected = nil }
NS.Session = Sess
local Registry, Solver, Proxy, Grid, L = NS.Registry, NS.Solver, NS.Proxy, NS.Grid, NS.L
local Undo = LibStub("DandersUndo-1.0")
local Lib = NS.Lib
local pairs, ipairs, format, wipe, sqrt = pairs, ipairs, string.format, wipe, math.sqrt
local InCombatLockdown, UIParent, IsShiftKeyDown, IsControlKeyDown = InCombatLockdown, UIParent, IsShiftKeyDown, IsControlKeyDown
local C_Timer, debugprofilestop = C_Timer, debugprofilestop
local SCREEN_SNAP = 8
-- Only reached when the SV predate the setting; NS.DEFAULTS.snapDistance is the value.
local SNAP_DISTANCE = 25
local function panel(method) if NS.Panel then NS.Panel[method](NS.Panel) end end
-- PERF instrumentation (debug-gated, /mover debug). debugprofilestop() is
-- wall-clock ms since some epoch; deltas of it are the per-block cost. Kept
-- permanently: it is a handful of subtractions behind the debug flag, and the
-- unlock/lock hitch investigation needs real in-game numbers to stay honest.
local function perfStart()
return NS.db and NS.db.debug and debugprofilestop and debugprofilestop() or nil
end
local function perfLog(t0, label)
if t0 then NS:Debug(format("PERF %s %.1fms", label, debugprofilestop() - t0)) end
end
function Sess:IsActive() return self.active end
function Sess:IsSuspended() return self.suspended end
-- ============================================================
-- APPLY / COMMIT
-- ============================================================
-- Push the element's record to its consumer and everything anchored to it.
local function apply(el, reason)
NS:Notify(el, reason)
NS:ReapplyDescendants(el.id, "parent")
Proxy:RefreshAll()
panel("Refresh")
end
local function restore(el, snap, reason)
NS.CopyPos(snap, Registry:GetPos(el))
-- An anchored snapshot carries the x/y solved at commit time; re-solve
-- against the target's current rect (holds if the target is missing).
NS:ResolveElement(el)
apply(el, reason)
end
local function commit(el, before, label)
local after = NS.CopyPos(Registry:GetPos(el))
Sess.dirty = true
Sess.undo:Push({
label = format(label, el.title),
undo = function() restore(el, before, "undo") end,
redo = function() restore(el, after, "redo") end,
})
end
-- ============================================================
-- LIFECYCLE
-- ============================================================
function Sess:Unlock(filter)
if self.active then return end
if InCombatLockdown() then NS:Print(L["Movers cannot be unlocked in combat."]) return end
filter = Registry:NormalizeFilter(filter)
self.filter = filter
-- Keys named in the filter take part even when their isRelevant() says no, and
-- are forced relevant for the whole session (snap + be snapped to). Set BEFORE the
-- snapshot walk and before Unlocked fires, so both see the same answer.
wipe(NS.forcedRelevant)
if filter and filter.keySet then
for key in pairs(filter.keySet) do NS.forcedRelevant[Registry.Id(filter.addon, key)] = true end
end
-- Snapshot everything IN the session, proxied or not: other addons' elements can
-- gain a proxy mid-session (showOtherAddons) and Discard must restore them too.
wipe(self.snapshots)
for _, el in ipairs(Registry:SortedElements()) do
if Registry:IsInSession(filter, el) then
self.snapshots[el.id] = NS.CopyPos(Registry:GetPos(el))
end
end
self.undo = Undo:New({ limit = 100 })
-- The panels re-read the element they are bound to; the legend's Undo/Redo
-- buttons re-read whether there is anything left to undo.
self.undo.RegisterCallback(self, "Changed", function()
panel("Refresh")
Proxy:RefreshLegendVerbs()
end)
self.dirty = false
self.selected = nil
self.active = true
self.suspended = false
-- Fire before Build: consumers commonly show preview/test frames from this
-- callback, and the proxies must be measured against what the consumer ends
-- up showing, not what was on screen a moment earlier. Snapshots are taken
-- above so the record is captured before any consumer-side mutation.
local tTotal = perfStart()
Lib.callbacks:Fire("Unlocked")
perfLog(tTotal, "Unlocked callbacks")
local tBuild = perfStart()
Proxy:Build(filter, true) -- animate: the overlay fades in, slabs with a stagger
perfLog(tBuild, "Proxy:Build")
Grid:Refresh()
self:EnableKeyboard(true)
perfLog(tTotal, "Unlock total (callbacks+build+grid)")
-- Anything the consumer only shows on the next frame (secure headers, test
-- containers) has no rect yet at Build time; re-measure once it does.
C_Timer.After(0, function()
if Sess.active and not Sess.suspended then
Proxy:RefreshAll()
Grid:Refresh()
if NS.Panel then NS.Panel:Refresh() end
end
end)
end
-- onDone (optional): told whether the session actually ended. With unsaved changes
-- the prompt makes that asynchronous, and a caller that has to act only once the
-- session is gone (a host switching what it shows) cannot assume Lock() finished it.
function Sess:Lock(onDone)
if not self.active then
if onDone then onDone(true) end
return
end
-- A second Lock while the prompt is up must not drop the first caller: both hear the
-- one answer.
local prev = self.lockDone
if prev and onDone then
self.lockDone = function(ended) prev(ended); onDone(ended) end
else
self.lockDone = onDone or prev
end
if not self.dirty then self:Finish("save") return end
-- The popup frame is shared with the host addon, so remember that this prompt is
-- ours: Finish and Escape close only it, never a dialog someone else opened.
self.promptOpen = true
NS.UI:ShowPopupAlert({
title = L["Unsaved changes"],
message = L["You have unsaved mover changes."],
buttons = {
{ label = L["Save"], onClick = function() Sess.promptOpen = nil; Sess:Finish("save") end },
{ label = L["Cancel"], onClick = function() Sess.promptOpen = nil; Sess:CancelLock() end },
{ label = L["Discard"], onClick = function() Sess.promptOpen = nil; Sess:Finish("discard") end },
},
})
end
-- The prompt was backed out of: the session carries on, and whoever asked for the
-- lock hears that it did not happen.
function Sess:CancelLock()
local cb = self.lockDone
self.lockDone = nil
if cb then cb(false) end
end
function Sess:Finish(mode)
if not self.active then return end
-- Saved or discarded from the strip or panel while the prompt is up: close it, or
-- its buttons would act on whatever session is running when they are clicked.
if self.promptOpen then
self.promptOpen = nil
NS.UI:DismissPopup()
end
local tTotal = perfStart()
if mode == "discard" then
for id, snap in pairs(self.snapshots) do
local el = Registry:Get(id)
if el then NS.CopyPos(snap, Registry:GetPos(el)) end
end
for id in pairs(self.snapshots) do
local el = Registry:Get(id)
if el then NS:Notify(el, "discard") end
end
end
-- A link gesture in flight owns an OnUpdate on the unlock frame; the
-- session must never end with that still running.
if self.linking then
self.linking = nil
Proxy:EndLinkVisual()
end
self:EnableKeyboard(false)
Proxy:DismissAll() -- fade out, then destroy (combat suspend stays instant)
Grid:Hide()
panel("Hide")
wipe(self.snapshots)
wipe(NS.forcedRelevant)
self.filter = nil
self.active, self.suspended, self.dirty, self.selected = false, false, false, nil
-- Clear after the state reset: Clear fires "Changed" -> panel Refresh,
-- which must see the session as inactive so it cannot re-show the panel.
if self.undo then self.undo:Clear() end
local tLocked = perfStart()
Lib.callbacks:Fire("Locked")
perfLog(tLocked, "Locked callbacks")
Lib.callbacks:Fire(mode == "discard" and "Discarded" or "Saved")
perfLog(tTotal, "Finish total")
local cb = self.lockDone
self.lockDone = nil
if cb then cb(true) end
end
function Sess:Toggle() if self.active then self:Lock() else self:Unlock() end end
-- Tear the proxies down and build them again against the current filter: a toggle
-- changed what should be on screen (an addon/element switch, hidden movers, other
-- addons' movers). Settings and the legend checkbox both come through here.
function Sess:RebuildProxies()
if not self.active or self.suspended then return end
-- A rebuild can follow an element being unregistered mid-session. Proxy:Build
-- will not make one for an id that no longer exists, but the selection would
-- keep pointing at it -- and the panel docks to the selected proxy.
if self.selected and not Registry:Get(self.selected) then self.selected = nil end
-- Rebuild, not DestroyAll + Build: the overlay (and an entrance still
-- playing on it) stays; only the slabs are remade. Also re-draws the legend.
Proxy:Rebuild(self.filter)
Grid:Refresh()
panel("Refresh") -- hides itself when the selection is gone
end
-- Elements can come and go WHILE a session is open: DandersFrames re-registers
-- its whole pinned list every time a set is added or removed, so without this a
-- proxy sits over a container that is not there any more (and a new set gets no
-- proxy until the next unlock). Debounced to the end of the frame because that
-- re-registration is a burst of unregister/register calls, not one change.
local rebuildPending = false
Lib.RegisterCallback(Sess, "RegistryChanged", function()
if rebuildPending or not Sess.active or Sess.suspended then return end
rebuildPending = true
C_Timer.After(0, function()
rebuildPending = false
Sess:RebuildProxies()
end)
end)
function Sess:Suspend()
if not self.active or self.suspended then return end
-- A link gesture cannot survive combat: the unlock frame (and its
-- OnUpdate) hides, and a stale `linking` flag would keep the ClickSelect
-- guard and the glyph dead after Resume.
if self.linking then self:CancelLink() end
if Proxy.EndActiveDrag then Proxy:EndActiveDrag() end
self.suspended = true
Proxy:GetUnlockFrame():Hide()
Grid:Hide()
panel("Hide")
NS:Print(L["Movers suspended for combat."])
end
function Sess:Resume()
if not self.active or not self.suspended then return end
self.suspended = false
Proxy:GetUnlockFrame():Show()
Proxy:RefreshAll()
Grid:Refresh()
panel("Refresh")
end
function Sess:Select(id)
self.selected = id
Proxy:Highlight(id)
panel("Refresh")
end
-- ============================================================
-- MUTATIONS (each = one undo step)
-- ============================================================
local function sizeOf(el)
local w, h = Registry:GetSize(el)
return w or 24, h or 24
end
-- Where the element actually is on screen. Falls back to the record's implied
-- centre when the frame has no rect yet (hidden, or a getFrame that is nil).
local function visualCenter(el, pos)
local rect = Registry:GetRect(el)
if rect then return rect.x, rect.y end
local w, h = sizeOf(el)
return Solver.PointToCenter(pos.point or "CENTER", pos.x or 0, pos.y or 0, w, h)
end
-- ============================================================
-- MIRROR
-- The session half of anchor mirroring (the maths and the record shape are in
-- Solver.lua, MIRROR). A mirrored pair is one child and the parent it is anchored
-- to; on every mirrored axis, moving either one moves the other the opposite way
-- and the centre line between them stays put. Only the Mirror Position setting
-- moves the line.
-- ============================================================
local function screenHalves() return UIParent:GetWidth() / 2, UIParent:GetHeight() / 2 end
-- The element's PRIMARY anchor block when that block is the one driving it and it
-- mirrors at least one axis; nil otherwise. A backup block never mirrors, so a child
-- standing on its backup behaves exactly like an unmirrored one.
local function activeMirror(el)
local a = Registry:GetPos(el).anchor
if not a or not (a.mirrorX or a.mirrorY) then return nil end
if Registry:ActiveAnchor(el) ~= a then return nil end
return a
end
-- Seen from the PARENT: every child mirrored onto it right now, as the numbers the
-- pair clamp needs. nil when there are none.
local function mirroredKids(el)
local out
for _, kid in ipairs(Registry:Children(el.id)) do
local a = activeMirror(kid)
if a and Registry:CanonicalId(a.target) == el.id then
local w, h = Registry:GetSize(kid)
if w then
out = out or {}
out[#out + 1] = { x = a.mirrorX, y = a.mirrorY, lineX = a.mirrorPosX or 0,
lineY = a.mirrorPosY or 0, hw = w / 2, hh = h / 2 }
end
end
end
return out
end
-- A parent centre pulled into the range where it AND every mirrored child's
-- reflection of it are fully on screen (Solver.MirrorClampAxis). Clamping the pair,
-- not just the element being moved, is what keeps a clamp from quietly breaking the
-- symmetry: the child's own solve only rescues a rect with nothing left on screen.
local function clampForKids(kids, cx, cy, w, h)
if not kids then return cx, cy end
local sx, sy = screenHalves()
for _, k in ipairs(kids) do
if k.x then cx = Solver.MirrorClampAxis(cx, w / 2, k.hw, k.lineX, sx) end
if k.y then cy = Solver.MirrorClampAxis(cy, h / 2, k.hh, k.lineY, sy) end
end
return cx, cy
end
-- Seen from the CHILD: the parent a drag or nudge of the child moves the opposite
-- way, or nil when there is nothing to move. freeX / freeY are the mirrored axes the
-- parent can actually follow on. The parent must be a registered element in this
-- session (so Discard restores it) and FREE: an anchored parent's place belongs to
-- its own anchor, and moving it would fight that. On a mirrored axis the parent
-- cannot follow, the child is HELD, exactly as it is on an unmirrored axis.
--
-- The parent is moved by its TARGET rect (startTx/Ty, the rect the child's solve
-- reflects), as a delta on its record, so a record that is not centred on its
-- visible rect keeps that offset.
function Sess:MirrorPartner(el)
local a = activeMirror(el)
if not a then return nil end
local target = Registry:GetTarget(a.target)
local rect = target and Registry:GetRect(target)
if not rect then return nil end
local partner = Registry:Get(Registry:CanonicalId(a.target))
if not partner or partner == el then return nil end
local ppos = Registry:GetPos(partner)
if ppos.anchor or not Registry:IsInSession(self.filter, partner) then return nil end
return {
partner = partner, freeX = a.mirrorX and true or false, freeY = a.mirrorY and true or false,
lineX = a.mirrorPosX or 0, lineY = a.mirrorPosY or 0,
before = NS.CopyPos(ppos), startTx = rect.x, startTy = rect.y, tw = rect.w, th = rect.h,
}
end
-- The child's centre (cx, cy) with the pair clamp applied on the free axes, and the
-- target centre (tx, ty) the parent must move to for the reflection to hold.
local function mirrorPlace(m, cx, cy, w, h)
local sx, sy = screenHalves()
local tx, ty = m.startTx, m.startTy
if m.freeX then
cx = Solver.MirrorClampAxis(cx, w / 2, m.tw / 2, m.lineX, sx)
tx = Solver.Reflect(cx, m.lineX)
end
if m.freeY then
cy = Solver.MirrorClampAxis(cy, h / 2, m.th / 2, m.lineY, sy)
ty = Solver.Reflect(cy, m.lineY)
end
return cx, cy, tx, ty
end
-- Move the parent so its target rect's centre sits at (tx, ty). Quiet when that is
-- where it already is: a drag calls this every frame.
local function placePartner(m, tx, ty, reason)
local ppos = Registry:GetPos(m.partner)
local nx, ny = (m.before.x or 0) + (tx - m.startTx), (m.before.y or 0) + (ty - m.startTy)
if ppos.x == nx and ppos.y == ny then return end
ppos.x, ppos.y = nx, ny
NS:Notify(m.partner, reason)
NS:ReapplyDescendants(m.partner.id, "parent")
end
local function partnerMoved(m)
local ppos = Registry:GetPos(m.partner)
return ppos.x ~= m.before.x or ppos.y ~= m.before.y
end
-- Free elements: after a record change, pull the visible rect back on screen.
-- The consumer must have applied the record first (Notify), so the rect is current.
-- A parent with mirrored children is clamped as a pair (clampForKids).
local function clampFree(el, pos)
local rect = Registry:GetRect(el)
if not rect then return end
local cx, cy = Solver.ClampToScreen(rect.x, rect.y, rect.w, rect.h, UIParent:GetWidth(), UIParent:GetHeight())
cx, cy = clampForKids(mirroredKids(el), cx, cy, rect.w, rect.h)
pos.x, pos.y = (pos.x or 0) + (cx - rect.x), (pos.y or 0) + (cy - rect.y)
end
-- One undo step for a change that moved a mirrored child AND its parent. The parent
-- is restored first so the child re-solves against where it belongs.
local function commitPair(el, before, m, label)
local after = NS.CopyPos(Registry:GetPos(el))
local partner, pBefore = m.partner, m.before
local pAfter = NS.CopyPos(Registry:GetPos(partner))
Sess.dirty = true
Sess.undo:Push({
label = format(label, el.title),
undo = function() restore(partner, pBefore, "undo"); restore(el, before, "undo") end,
redo = function() restore(partner, pAfter, "redo"); restore(el, after, "redo") end,
})
end
-- Nudge a mirrored child: an unmirrored axis takes the step on its offset as always;
-- a mirrored axis moves the child by the step and the parent by minus the step
-- (clamped as a pair), or does nothing when the parent cannot follow.
local function nudgeMirrored(el, pos, before, dx, dy)
local a = pos.anchor
local changed = false
if not a.mirrorX and dx ~= 0 then a.offsetX = (a.offsetX or 0) + dx; changed = true end
if not a.mirrorY and dy ~= 0 then a.offsetY = (a.offsetY or 0) + dy; changed = true end
local m = Sess:MirrorPartner(el)
if m and ((m.freeX and dx ~= 0) or (m.freeY and dy ~= 0)) then
local w, h = sizeOf(el)
local cx = Solver.Reflect(m.startTx, m.lineX) + (m.freeX and dx or 0)
local cy = Solver.Reflect(m.startTy, m.lineY) + (m.freeY and dy or 0)
local _, _, tx, ty = mirrorPlace(m, cx, cy, w, h)
placePartner(m, tx, ty, "nudge")
end
local pair = m and partnerMoved(m)
if not (changed or pair) then return end
NS:ResolveElement(el)
if pair then apply(m.partner, "nudge") end
apply(el, "nudge")
if pair then commitPair(el, before, m, L["Nudge %s"]) else commit(el, before, L["Nudge %s"]) end
end
function Sess:Nudge(el, dx, dy)
-- The panel is not parked here any more: the nudge BUTTONS auto-pin, which
-- is a better answer to "the panel chases my nudges" than a timed hold.
local pos = Registry:GetPos(el)
local before = NS.CopyPos(pos)
if activeMirror(el) then return nudgeMirrored(el, pos, before, dx, dy) end
if pos.anchor then
pos.anchor.offsetX = (pos.anchor.offsetX or 0) + dx
pos.anchor.offsetY = (pos.anchor.offsetY or 0) + dy
NS:ResolveElement(el)
else
pos.x, pos.y = (pos.x or 0) + dx, (pos.y or 0) + dy
NS:Notify(el, "nudge"); clampFree(el, pos)
end
apply(el, "nudge")
commit(el, before, L["Nudge %s"])
end
-- Panel edit boxes: free element -> absolute x/y; anchored -> offsets, except that
-- a MIRRORED axis's box is its Mirror Position. Both boxes commit together, so a
-- mirrored axis whose box still shows its rounded readout is left alone: rewriting
-- the line with the rounded number would move the child up to a pixel.
function Sess:SetXY(el, x, y)
local pos = Registry:GetPos(el)
local before = NS.CopyPos(pos)
if pos.anchor then
local a = pos.anchor
local rx, ry = Solver.Readout(pos)
if a.mirrorX then
if x ~= rx then a.mirrorPosX = x end
else
a.offsetX = x
end
if a.mirrorY then
if y ~= ry then a.mirrorPosY = y end
else
a.offsetY = y
end
NS:ResolveElement(el)
else
pos.x, pos.y = x, y
NS:Notify(el, "nudge"); clampFree(el, pos)
end
apply(el, "nudge")
commit(el, before, L["Move %s"])
end
function Sess:SetAnchorPoint(el, point)
if el.pointLocked then return end
local pos = Registry:GetPos(el)
if pos.anchor or pos.point == point then return end
local before = NS.CopyPos(pos)
-- An element that says where its visible rect sits relative to its record
-- (def.visibleOffset) converts through that: the visible centre stays put and
-- the record is re-expressed from the new point. The size-based conversion
-- below assumes the record's point is a point OF the visible rect, which is
-- false for a container larger than what it shows (DF's raid frames).
local ox, oy
if NS.VisibleOffset then ox, oy = NS.VisibleOffset(el, pos) end
local nx, ny
if ox then
local vx, vy = (pos.x or 0) + ox, (pos.y or 0) + oy
pos.point = point
nx, ny = NS.VisibleOffset(el, pos)
if nx then pos.x, pos.y = vx - nx, vy - ny end
end
if not nx then
pos.point = before.point
local w, h = sizeOf(el)
local cx, cy = Solver.PointToCenter(pos.point or "CENTER", pos.x or 0, pos.y or 0, w, h)
pos.point = point
pos.x, pos.y = Solver.CenterToPoint(point, cx, cy, w, h)
end
apply(el, "nudge")
commit(el, before, L["Anchor point %s"])
end
-- Re-anchoring to a new primary keeps whatever backup was already set: the
-- element's fall-back plan is a separate choice from where it normally sits.
-- Dropped only when the new primary IS the backup, which would make the two
-- the same link.
local function carryFallback(pos, before, targetId)
local fb = before.anchor and before.anchor.fallback
if not fb or fb.target == targetId then return end
pos.anchor.fallback = { target = fb.target, mode = fb.mode, edge = fb.edge, align = fb.align,
point = fb.point, relPoint = fb.relPoint,
offsetX = fb.offsetX, offsetY = fb.offsetY }
end
function Sess:Anchor(el, targetId, edge, align)
if Registry:WouldCreateCycle(el.id, targetId) then
NS:Print(L["Anchoring would create a loop."])
return false
end
local pos = Registry:GetPos(el)
local before = NS.CopyPos(pos)
pos.anchor = { target = targetId, edge = edge, align = align, offsetX = 0, offsetY = 0 }
carryFallback(pos, before, targetId)
NS.RecenterRecord(el, pos)
NS:ResolveElement(el)
apply(el, "anchor")
commit(el, before, L["Anchor %s"])
return true
end
-- Anchor to a target WITHOUT moving: the picker's verb. Solver.AnchorInPlace
-- derives the spec that reproduces where the element already sits, so choosing
-- a parent from the panel is a change of relationship, not of position.
function Sess:AnchorInPlace(el, targetId)
if Registry:WouldCreateCycle(el.id, targetId) then
NS:Print(L["Anchoring would create a loop."])
return
end
local pos = Registry:GetPos(el)
local before = NS.CopyPos(pos)
local childRect = Registry:GetRect(el)
if not childRect then
local cx, cy = visualCenter(el, pos)
local w, h = sizeOf(el)
childRect = { x = cx, y = cy, w = w, h = h }
end
local target = Registry:GetTarget(targetId)
local targetRect = target and Registry:GetRect(target) or nil
if not targetRect then
-- Anchoring in place is impossible with no rect: there is no geometry to
-- measure a seat against, so nothing can be reproduced. The element HOLDS
-- where it is (ResolveElement finds no available anchor) and takes the
-- centre-on-centre spec -- the one case where picking a target moves the
-- element later, once that target finally appears.
pos.anchor = { target = targetId, mode = "point", point = "CENTER", relPoint = "CENTER",
offsetX = 0, offsetY = 0 }
else
local spec = Solver.AnchorInPlace(childRect, targetRect, Solver.SPACING)
pos.anchor = { target = targetId, mode = spec.mode, edge = spec.edge, align = spec.align,
point = spec.point, relPoint = spec.relPoint,
offsetX = spec.offsetX, offsetY = spec.offsetY }
end
carryFallback(pos, before, targetId)
NS.RecenterRecord(el, pos)
NS:ResolveElement(el)
apply(el, "anchor")
commit(el, before, L["Anchor %s"])
end
-- Edit the live spec of an existing anchor: the panel's second anchor row,
-- whichever pair of fields the mode actually has -- edge/align in outside mode,
-- point/relPoint in point mode. A change the mode has no field for is dropped
-- rather than written, so an outside anchor can never grow a stray `point`.
local SPEC_FIELDS = {
outside = { edge = true, align = true },
point = { point = true, relPoint = true },
}
function Sess:SetAnchorSpec(el, changes)
local pos = Registry:GetPos(el)
if not pos.anchor then return end
local allowed = SPEC_FIELDS[pos.anchor.mode == "point" and "point" or "outside"]
local any = false
for k in pairs(changes) do
if allowed[k] then any = true else changes[k] = nil end
end
if not any then return end
local before = NS.CopyPos(pos)
for k, v in pairs(changes) do pos.anchor[k] = v end
NS:ResolveElement(el)
apply(el, "anchor")
commit(el, before, L["Anchor %s"])
end
-- Turn mirroring on or off for one axis ("X" or "Y") of the element's PRIMARY anchor.
--
-- On: the centre line becomes the midpoint of the child's and the parent's centres,
-- so the child stays exactly where it is -- reflecting centres means there is always
-- such a line. Off: the axis's offset becomes the plain one that reproduces where the
-- child is now, so it stays put again. Both need the primary to be the live block and
-- its target to have a rect; without that (the child is holding, or standing on its
-- backup) "on" takes the screen centre as the line and "off" keeps the offset the last
-- mirrored solve left, which is where the child last sat on the primary.
function Sess:SetMirror(el, axis, on)
local pos = Registry:GetPos(el)
local a = pos.anchor
if not a or (axis ~= "X" and axis ~= "Y") then return end
local flag, lineKey, offKey = "mirror" .. axis, "mirrorPos" .. axis, "offset" .. axis
on = on and true or nil
if (a[flag] and true or nil) == on then return end
local before = NS.CopyPos(pos)
local target = Registry:GetTarget(a.target)
local rect = Registry:ActiveAnchor(el) == a and target and Registry:GetRect(target) or nil
local cx, cy = visualCenter(el, pos)
if on then
local line = 0
if rect then
line = axis == "X" and Solver.MirrorLine(cx, rect.x) or Solver.MirrorLine(cy, rect.y)
end
a[flag], a[lineKey] = true, line
else
if rect then
local w, h = sizeOf(el)
local ox, oy = Solver.OffsetsFor(a, w, h, rect, Solver.SPACING, cx, cy)
a[offKey] = axis == "X" and ox or oy
end
a[flag], a[lineKey] = nil, nil
end
NS:ResolveElement(el)
apply(el, "anchor")
commit(el, before, L["Mirror %s"])
end
-- ============================================================
-- LINK DRAG
-- Press one of the panel's link handles and drag a line onto another element:
-- the release ties the two together, and nothing moves.
-- The gesture owns no record state of its own -- Sess.linking is just the id
-- of the element being linked FROM plus which of the two links is being aimed,
-- and the commit is the same one the matching dropdown makes (one undo entry).
-- Esc and right-click cancel; the visual half lives in Proxy.
--
-- mode: "primary" (default) re-anchors in place; "fallback" sets the BACKUP
-- anchor, which takes the primary's whole spec with only the target swapped --
-- so a backup drop never moves the element either.
-- ============================================================
function Sess:BeginLink(el, mode)
if not self.active or self.suspended or self.linking then return end
-- A backup is only meaningful once there IS a primary to fall back FROM.
-- SetFallback would refuse the drop anyway; refusing the gesture keeps the
-- handle honest rather than letting a line be dragged to nowhere.
if mode == "fallback" and not Registry:GetPos(el).anchor then return end
self.linking = { id = el.id, mode = mode or "primary" }
Proxy:BeginLinkVisual(el, self.linking.mode)
end
function Sess:EndLink(targetId)
if not self.linking then return end
-- Re-read the element: a link can outlive the record it started from
-- (RegistryChanged mid-gesture), and the visual must come down either way.
local el = Registry:Get(self.linking.id)
local mode = self.linking.mode
self.linking = nil
Proxy:EndLinkVisual()
if not (el and targetId) then return end
if mode == "fallback" then self:SetFallback(el, targetId)
else self:AnchorInPlace(el, targetId) end
end
function Sess:CancelLink()
if not self.linking then return end
self.linking = nil
Proxy:EndLinkVisual()
end
-- Set a whole anchor spec at once (Lib:SetAnchor). `anchor` is a fresh copy the
-- caller owns; the cycle check has already passed. One undo step.
function Sess:SetAnchor(el, anchor)
local pos = Registry:GetPos(el)
local before = NS.CopyPos(pos)
pos.anchor = anchor
NS.RecenterRecord(el, pos)
NS:ResolveElement(el)
apply(el, "anchor")
commit(el, before, L["Anchor %s"])
return true
end
function Sess:Detach(el)
local pos = Registry:GetPos(el)
if not pos.anchor then return end
local before = NS.CopyPos(pos)
pos.anchor = nil
apply(el, "detach")
commit(el, before, L["Detach %s"])
end
-- The backup anchor: where the element goes when its primary target is not on
-- screen. It takes the primary's whole spec with only the target swapped --
-- the same seat on a different parent -- so the element keeps its shape of
-- attachment either way.
function Sess:SetFallback(el, targetId)
local pos = Registry:GetPos(el)
if not pos.anchor then return end
-- Same as the primary is a no-op, not a mistake worth a message.
if targetId == pos.anchor.target then return end
if Registry:WouldCreateCycle(el.id, targetId) then
NS:Print(L["Anchoring would create a loop."])
return
end
local before = NS.CopyPos(pos)
local a = pos.anchor
a.fallback = { target = targetId, mode = a.mode, edge = a.edge, align = a.align,
point = a.point, relPoint = a.relPoint,
offsetX = a.offsetX, offsetY = a.offsetY }
-- Only actually moves the element if the primary is already unavailable,
-- which is exactly when the backup is meant to take over.
NS:ResolveElement(el)
apply(el, "fallback")
commit(el, before, L["Backup anchor %s"])
end
function Sess:ClearFallback(el)
local pos = Registry:GetPos(el)
if not pos.anchor or not pos.anchor.fallback then return end
local before = NS.CopyPos(pos)
pos.anchor.fallback = nil
apply(el, "fallback")
commit(el, before, L["Clear backup %s"])
end
function Sess:Center(el)
local pos = Registry:GetPos(el)
local before = NS.CopyPos(pos)
-- Move the record by whatever it takes to put the visible rect on 0,0.
local cx, cy = visualCenter(el, pos)
pos.anchor = nil
pos.x, pos.y = (pos.x or 0) - cx, (pos.y or 0) - cy
apply(el, "center")
commit(el, before, L["Center %s"])
end
function Sess:Reset(el)
if not el.default then return self:Center(el) end
local pos = Registry:GetPos(el)
local before = NS.CopyPos(pos)
NS.CopyPos(el.default, pos)
if pos.anchor then NS:ResolveElement(el) end
apply(el, "reset")
commit(el, before, L["Reset %s"])
end
-- Copy this element's record onto its declared twin (def.twin = "addon:key"):
-- party container onto raid container, pinned N onto the other mode's N. The
-- twin takes the whole record -- anchor included, unless carrying it over
-- would create a cycle, in which case the twin lands free at the same
-- coordinates. One undo entry, named for the twin.
function Sess:CopyToTwin(el)
local twin = el.twin and Registry:Get(el.twin)
if not twin then return end
local dst = Registry:GetPos(twin)
local before = NS.CopyPos(dst)
NS.CopyPos(Registry:GetPos(el), dst)
if dst.anchor and Registry:WouldCreateCycle(twin.id, dst.anchor.target) then dst.anchor = nil end
-- The backup is dropped on its own if only IT would loop; the primary stays.
if dst.anchor and dst.anchor.fallback and Registry:WouldCreateCycle(twin.id, dst.anchor.fallback.target) then
dst.anchor.fallback = nil
end
if dst.anchor then NS:ResolveElement(twin) end
apply(twin, "copy")
commit(twin, before, L["Copy to %s"])
end
-- The toast needs the entry's label, and Undo/Redo POP the entry -- so Peek
-- before popping, and only toast when something was actually undone/redone.
function Sess:Undo()
if not self.undo then return end
local label = self.undo:Peek()
if self.undo:Undo() and label then
Proxy:ShowToast(format(L["Undid: %s"], label))
end
end
function Sess:Redo()
if not self.undo then return end
local label = self.undo:PeekRedo()
if self.undo:Redo() and label then
Proxy:ShowToast(format(L["Redid: %s"], label))
end
end
-- ============================================================
-- DRAG
-- ============================================================
function Sess:BeginDrag(el)
local pos = Registry:GetPos(el)
self.dragBefore = NS.CopyPos(pos)
self.dragStartPos = NS.CopyPos(pos)
self.dragStartCx, self.dragStartCy = visualCenter(el, pos)
-- Anchored: arm the tether. Pull is measured from the RESOLVED anchor
-- position, which is exactly where the element sits at drag start (the
-- record was solved). Proxy reads this table to draw the strain.
--
-- A mirrored child whose parent can follow (Sess:MirrorPartner) drags that parent
-- the opposite way on each free axis, and only the HELD axes count towards the
-- tether: movement along a free axis is the mirror working, not a pull.
self.mirror = pos.anchor and self:MirrorPartner(el) or nil
local m = self.mirror
if pos.anchor then
local a = pos.anchor
self.tether = { target = a.target,
spec = { mode = a.mode, edge = a.edge, align = a.align,
point = a.point, relPoint = a.relPoint },
homeX = self.dragStartCx, homeY = self.dragStartCy,
freeX = m and m.freeX or false, freeY = m and m.freeY or false,
state = "held", strain = 0, snapped = false }
else
self.tether = nil
end
-- Dragging a parent: its mirrored children re-solve as it moves (the usual
-- descendant pass); this is only what the pair clamp needs, read once.
self.mirrorKids = mirroredKids(el)
end
function Sess:DragTo(el, cx, cy)
local db = NS.db
local w, h = sizeOf(el)
-- Frame zones are tested on the RAW cursor position and win outright; grid
-- and screen snapping only apply when no zone is hit. Quantising first would
-- make zones on off-grid targets unreachable (a 20px grid vs a zone at y=150).
local zone
if db.snapToFrames and #Proxy.dragZones > 0 then
zone = Solver.NearestZone(cx, cy, w, h, Proxy.dragZones, db.snapDistance or SNAP_DISTANCE)
end
if zone then
cx, cy = zone.x, zone.y
Grid:HidePreview()
else
-- The preview lines sit on the grid / screen line each axis actually
-- snapped to (an edge or the centre), not on the element's centre.
-- Screen snap runs last, so its line wins on an axis it moved.
local lineX, lineY
if db.snapToGrid then cx, cy, lineX, lineY = Solver.SnapRectToGrid(cx, cy, w, h, db.gridSize) end
if db.snapToScreen then
local sx, sy, lx, ly
cx, cy, sx, sy, lx, ly = Solver.SnapToScreen({ x = cx, y = cy, w = w, h = h }, UIParent:GetWidth(), UIParent:GetHeight(), SCREEN_SNAP)
if sx then lineX = lx end
if sy then lineY = ly end
end
if lineX or lineY then Grid:ShowPreview(lineX, lineY) else Grid:HidePreview() end
end
-- ☠ A ZONE PREVIEWS WHERE THE DROP WILL SEAT IT. The drop solves the anchor, and an
-- anchored solve only pulls back an element with nothing on screen (NS.KeepOnScreen):
-- a seat that overhangs an edge keeps its overhang. Clamping the preview fully on screen
-- would show the frames in one place and release them in another; tall elements (a
-- Column of party frames above scale 1) hit it first.
if zone then
cx, cy = NS.KeepOnScreen(cx, cy, w, h)
else
cx, cy = Solver.ClampToScreen(cx, cy, w, h, UIParent:GetWidth(), UIParent:GetHeight())
-- Mirrored pairs clamp together, whichever half is being dragged.
cx, cy = clampForKids(self.mirrorKids, cx, cy, w, h)
local m = self.mirror
if m and not (self.tether and self.tether.snapped) then
cx, cy = mirrorPlace(m, cx, cy, w, h)
end
end
Grid:ShowMeasure(cx, cy, w, h)
-- Tether strain-and-snap: pulling an anchored element past the thresholds
-- (Solver.TETHER_HOLD/SNAP x snapDistance, measured from the resolved
-- anchor position) first strains the tether, then snaps it -- the element
-- is free from that moment and EndDrag's spring-back no longer applies.
local t = self.tether
if t and not t.snapped then
local ddx = t.freeX and 0 or (cx - t.homeX)
local ddy = t.freeY and 0 or (cy - t.homeY)
local dist = sqrt(ddx ^ 2 + ddy ^ 2)
local snapR = db.snapDistance or SNAP_DISTANCE
t.state = Solver.TetherState(dist, snapR)
t.strain = Solver.TetherStrain(dist, snapR)
if t.state == "snapped" then
t.snapped = true
Proxy:SnapTether(el)
end
end
local pos = Registry:GetPos(el)
pos.anchor = nil
pos.x, pos.y = Solver.DragDelta(self.dragStartPos, self.dragStartCx, self.dragStartCy, cx, cy)
-- The mirrored parent follows on its free axes -- unless the drop is going to
-- re-anchor the child (a zone) or free it (a snapped tether), in which case the
-- mirror is over and the parent goes back to where it started, so the preview
-- shows exactly what the drop will do.
local m = self.mirror
if m then
if zone or (t and t.snapped) then
placePartner(m, m.startTx, m.startTy, "drag")
else
placePartner(m, m.freeX and Solver.Reflect(cx, m.lineX) or m.startTx,
m.freeY and Solver.Reflect(cy, m.lineY) or m.startTy, "drag")
end
end
NS:Notify(el, "drag")
NS:ReapplyDescendants(el.id, "parent")
Proxy:RefreshAll()
return cx, cy, zone
end
function Sess:EndDrag(el, cx, cy, zone)
local before = self.dragBefore or NS.CopyPos(Registry:GetPos(el))
local tether = self.tether
local m = self.mirror
self.dragBefore, self.dragStartPos, self.dragStartCx, self.dragStartCy = nil, nil, nil, nil
self.tether, self.mirror, self.mirrorKids = nil, nil, nil
local pos = Registry:GetPos(el)
if zone and Registry:WouldCreateCycle(el.id, zone.target) then zone = nil end
-- ⚠ THE ZONES ARE A SNAPSHOT. Proxy:ShowZones collects them once at drag
-- start and nothing re-collects during the drag, so a target that went off
-- screen (or out of the registry) mid-drag still has its zone under the
-- cursor. Its rect is whatever it was; a solve against it lands the element
-- somewhere the preview never showed. Refused here: the drop goes through
-- as a plain move to the spot the preview DID show.
if zone and not Registry:IsTargetAvailable(Registry:GetTarget(zone.target)) then zone = nil end
-- The mirrored parent keeps where the drag took it only when the child's anchor
-- (and so the mirror) survives the drop; any other drop puts it back.
local keepPartner = m and not zone and before.anchor and not (tether and tether.snapped)
if m and not keepPartner and partnerMoved(m) then
NS.CopyPos(m.before, Registry:GetPos(m.partner))
apply(m.partner, "reapply")
end
if zone then
pos.anchor = { target = zone.target, edge = zone.edge, align = zone.align, offsetX = 0, offsetY = 0 }
carryFallback(pos, before, zone.target)
pos.point = "CENTER"
NS:ResolveElement(el)
elseif before.anchor and not (tether and tether.snapped) then
-- The anchor SURVIVED the drag (never pulled past the snap threshold):
-- dropped outside every zone, it springs back and re-solves. Nothing
-- changed, so no undo entry. A snapped tether skips this branch -- the
-- element detached mid-drag and the drop commits it as free.
NS.CopyPos(before, pos)
NS:ResolveElement(el)
-- A mirrored drag DID change something: the parent moved, and the child
-- re-solves onto its reflection. One undo step for the two of them.
if keepPartner and partnerMoved(m) then
apply(m.partner, "drag")
apply(el, "drag")
commitPair(el, before, m, L["Move %s"])
self:Select(el.id)
return
end
apply(el, "reapply")
self:Select(el.id)
return
end
apply(el, zone and "anchor" or "drag")
local label = zone and L["Anchor %s"] or (before.anchor and L["Detach %s"] or L["Move %s"])
-- Pulled free of a record that also named a backup: the backup went with the
-- primary, and that is worth saying out loud -- it is a link the user set up
-- deliberately and would otherwise notice only much later.
if not zone and before.anchor and before.anchor.fallback then
Proxy:ShowToast(L["Detached — backup anchor cleared"])
end
commit(el, before, label)
self:Select(el.id)
end
-- ============================================================
-- KEYBOARD
-- Esc backs out one layer at a time: a live link gesture, then the selection, then the
-- session. Arrows = nudge (Shift x10, Ctrl x100), Ctrl+Z / Ctrl+Y = undo/redo.
-- Only ever enabled while the unlock frame is shown, which never happens in combat.
--
-- Esc IS the deselect: the unlock overlay takes no clicks (it covers the whole screen,
-- so owning the mouse would make the world unclickable for the session), so a click on