Repository navigation
Expand file tree
/
Copy pathPanel.lua
More file actions
1313 lines (1235 loc) · 63.1 KB
/
Copy pathPanel.lua
File metadata and controls
1313 lines (1235 loc) · 63.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
-- ============================================================
-- ELEMENT PANEL
-- One DandersUI popout per element being edited. The shell (host:CreatePopout)
-- owns the frame, the title bar, the docking, the pin and the tether beam; this
-- file owns what goes INSIDE it and what closing it means.
--
-- ONE PANEL AT A TIME. Every element panel is opened in the family
-- "mover.panel", and a family is an exclusivity CLAIM in the shell: opening one
-- member closes every other, PINNED ONES INCLUDED. So there is never more than
-- one panel on screen -- selecting a mover leaves exactly the pooled popout,
-- following that mover's proxy.
--
-- PIN therefore means "stop following", not "keep a second one". A pinned panel
-- stays where it is and keeps editing ITS mover while the selection is cleared
-- or comes back to it; selecting a DIFFERENT mover closes it (reason "family")
-- and opens a fresh FOLLOWING panel over there. The shell still supports
-- several pinned members for consumers that want them -- the mover does not,
-- because a screen full of panels was never what the editor is for.
--
-- ⚠ Nothing here may read Session.selected inside a control. `build` runs ONCE
-- per instance and every closure it creates belongs to THAT instance, so a
-- control acts on `popout.el` -- the element its own panel is bound to -- which
-- for a pinned panel is not the selected one and never becomes it again.
--
-- Deliberately NOT a settings window. The bulk of the panel is element content
-- -- what this mover is anchored to, where it sits, where it goes.
--
-- Under it sits a SECOND access point for the session verbs (undo, redo, save,
-- discard, settings). The legend strip still carries them, spelled out in
-- words, and is still where they live when nothing is selected; this row is the
-- one you reach for without crossing the screen while you are working on an
-- element. It is a compact ICON row precisely because it is the second copy:
-- five captions would out-shout the content above them, and the strip already
-- says the words.
--
-- ⚠ They are SESSION verbs, so unlike everything else on the panel they do not
-- auto-pin -- pressing Undo is not editing THIS element, and a panel that
-- pinned itself on it would stop following the selection for a keystroke that
-- had nothing to do with the mover it is docked to.
-- ============================================================
local Pn = { live = {} }
NS.Panel = Pn
local Registry, Sess, Proxy, Solver, UI, L = NS.Registry, NS.Session, NS.Proxy, NS.Solver, NS.UI, NS.L
local CreateFrame, UIParent, IsShiftKeyDown, IsControlKeyDown = CreateFrame, UIParent, IsShiftKeyDown, IsControlKeyDown
local xpcall, geterrorhandler = xpcall, geterrorhandler
local format, tonumber, ipairs, pairs, floor, max, min = string.format, tonumber, ipairs, pairs, math.floor, math.max, math.min
local tremove, wipe = table.remove, wipe
-- The mover's own art, not the kit's (UI.MEDIA): the link glyph ships with this
-- addon because it is this addon's verb. 64px supersampled TGA so it stays
-- crisp at the 14px the drag handles draw it (SVG rendered as error squares
-- in-game -- see the note on Proxy.lua's copy of these constants).
local MEDIA = "Interface\\AddOns\\DandersMover\\Media\\"
local LINK_ICON = MEDIA .. "link"
-- The grip dots beside the chain: a 2x3 dot grid on its own 32px canvas, drawn
-- at 9px. Its ink is only the middle ~half of that canvas, so the drawn square
-- is wider than the dots look -- the margins are what keep the dots round.
local GRIP_ICON = MEDIA .. "grip"
-- The session verbs' glyphs. save / discard / settings are the DandersFrames
-- Material set's own save, delete and settings, COPIED here byte for byte --
-- the mover may not REFERENCE that folder (it has to load with DandersFrames
-- disabled) but duplicating the asset is free, and a hand-drawn floppy beside
-- the addon's established icons would be the one thing the eye picks out.
-- undo and redo have no equivalent in that set (refresh and sync are a reload
-- loop and a two-arrow cycle, neither of which reads as history), so those two
-- are drawn to the same metrics: 32px master, 2px stroke, ink in a 26px box.
--
-- Mover-local rather than in DandersUI: the kit itself never draws an undo, a
-- floppy or a bin, and art the toolkit does not reference has no business
-- living in the toolkit.
local UNDO_ICON = MEDIA .. "undo"
local REDO_ICON = MEDIA .. "redo"
local SAVE_ICON = MEDIA .. "save"
local DISCARD_ICON = MEDIA .. "discard"
local SETTINGS_ICON = MEDIA .. "settings"
local DEFAULT_ICON = MEDIA .. "DF_Icon"
-- The pool key, and the family name -- deliberately the same string, because
-- for the mover they say the same thing. Per host+key the shell keeps ONE
-- unpinned instance (the panel that follows the selection); the FAMILY is what
-- extends that to pinned ones, so opening a panel for another element closes
-- whatever was up.
local POPOUT_KEY = "mover.panel"
-- Spacing comes from the theme so this panel keeps the rhythm of every other
-- DandersUI surface: PAD is the outer padding, GAP the gap between rows of
-- different kinds, TIGHT the gap inside a run of like things (buttons in a
-- row, the two X/Y pairs).
-- 290, not the old 260 (itself up from 236): the anchor block carries a label
-- column and a drag handle on the same row as its picker, so what is left for
-- the picker is the panel width minus two fixed columns. At 260 the seat
-- dropdowns were clipping their own VALUES -- "CE..." where CENTER should be --
-- and a setting you cannot read the value of is not saving any space worth
-- having. The shell insets the content by its own PAD, so what is handed to
-- CreatePopout is the CONTENT width and 290 is what the frame measures.
local W = 290
local PAD, GAP, TIGHT = UI.Space.section, UI.RowGap, UI.RowGapTight
local CW = W - PAD * 2 -- content width
local DOCK_GAP = 12 -- panel <-> proxy distance
local ADDON_H = 12 -- the dim addon-name line under the title bar
local HEADER_BTN_H = 18 -- the Configure row under it
local BOX_W = 62 -- X / Y edit boxes
local NUDGE_CELL, NUDGE_ICON = 22, 14
local DOT, DOT_GAP = 16, 4 -- 9-point picker cells
local BTN_H = 20
-- The session-verb footer. A glyph CELL, not a labelled button: five captions
-- ("Save & Exit" among them) do not fit across 270px of content, and this row
-- is the second way to reach verbs the legend strip already spells out in
-- words -- so the names live in the tooltips. Cell height is BTN_H so the row
-- sits square under the Center/Reset/Detach row above it.
--
-- ☠ 16, not 14. The glyphs are 32px masters, so 16 is an exact 2:1 downsample
-- and every 2px stroke in them lands on one whole pixel. At 14 the same
-- strokes fall between pixels and the set goes soft -- the gear worst of all,
-- which at 14 is a smudge with a hole in it. Three pixels wider costs the row
-- nothing; illegible icons cost it everything.
local VERB_CELL, VERB_ICON = 22, 16
-- The gap that splits the pair from the trio. Undo and Redo step through the
-- history; Save, Discard and Settings leave the session or configure it. Run
-- all five together and they read as one undifferentiated strip of icons.
local VERB_SEP = GAP
-- ---- the anchor block --------------------------------------------
-- Its own group box, so "Anchor" reads as one subject rather than as four
-- dropdowns that happen to be stacked. UI.Space.section is a PAGE inset and a
-- panel this narrow cannot spare 10px a side on top of the shell's own, so the
-- box takes a tighter one.
local ANCHOR_PAD = 6
-- One height for all FOUR rows -- Target, the seat pair and Backup are the same
-- shape (label column, dropdown, handle gutter) and a row that is 2px shorter
-- than the ones around it reads as a mistake rather than as a distinction.
local ROW_H = 20
-- Target, Edge, Align, Backup, Mirror.
local ANCHOR_ROWS = 5
-- The link-drag handle at a picker row's right end. Wider than it is tall
-- because it is a GRIP, not an icon: grip dots on the left, the chain glyph on
-- the right, both inside a backdrop so the thing reads as a control you take
-- hold of rather than a decoration printed on the row. 24 is what those two
-- glyphs plus their insets measure -- the width buys an affordance, not a label,
-- and the picker beside it gives up 8px for it.
local HANDLE_W, HANDLE_H = 24, 16
local GRIP, HANDLE_GLYPH = 9, 12 -- the two glyph sizes inside that box
-- Hover: a small lift, quick enough to read as a response rather than an
-- animation. 1.15 on a 24x16 button is ~3px of growth.
local HOVER_SCALE, HOVER_DUR = 1.15, 0.08
-- The label column shared by all four rows of the block, measured from the
-- labels themselves (layoutAnchorBlock) and held between these: below the
-- minimum the rows stop lining up, above the maximum a long translation would
-- eat the picker instead of wrapping.
local LABEL_MIN, LABEL_MAX = 34, 96
-- The Mirror row's two toggles: the kit's checkbox at a size that sits inside
-- ROW_H, each cell wide enough for its box, the gap and a one-letter caption.
local MIRROR_BOX, MIRROR_CELL = 14, 44
local ACW = CW - ANCHOR_PAD * 2 -- the group box's inner content width
-- The kit's own grey-when-disabled dim (UI:CreateDropdown SetEnabled), matched
-- so a disabled label and the disabled dropdown beside it fade to the same depth.
local DISABLED_ALPHA = 0.4
local POINTS = { "TOPLEFT", "TOP", "TOPRIGHT", "LEFT", "CENTER", "RIGHT", "BOTTOMLEFT", "BOTTOM", "BOTTOMRIGHT" }
-- Which of the anchor block's two fields a dropdown edits, per mode. The pair
-- is positional -- left dropdown, right dropdown -- and the mode decides what
-- each one means.
local SPEC_KEYS = { outside = { "edge", "align" }, point = { "point", "relPoint" } }
local SPEC_FALLBACK = { edge = "right", align = "center", point = "CENTER", relPoint = "CENTER" }
local function step() return Solver.NudgeStep(IsShiftKeyDown(), IsControlKeyDown()) end
-- The Target row's caption while the element is free. "None" on its own names
-- the state and stops there, which on the ONE row that has a gesture behind it
-- is a wasted line: the empty state teaches the gesture instead, with the chain
-- glyph inline so the sentence points at the handle sitting beside it.
--
-- The texture escape is assembled here and never inside the locale key -- a
-- translator gets the sentence with a gap, not a file path to preserve.
local function unanchoredCaption()
return format(L["None — drag %s to link"], "|T" .. LINK_ICON .. ".tga:12:12|t")
end
-- The anchor mode of whatever element a panel is bound to. Every control that
-- edits the seat pair asks this rather than a cached flag, because two panels
-- open at once can be in different modes.
local function specModeOf(el)
if not el then return "outside" end
local a = Registry:GetPos(el).anchor
return (a and a.mode == "point") and "point" or "outside"
end
-- ============================================================
-- GRAB CURSOR
-- The move cursor while the pointer is over a drag handle: the one affordance
-- that says "this is draggable" before you press anything.
--
-- ☠ SetCursor is SILENT about a path it cannot resolve -- it draws a BLACK
-- SQUARE where the pointer should be rather than erroring -- so a pcall is not
-- a guard against a wrong path, only against a missing API. The path is
-- therefore PROBED first through a throwaway texture: SetTexture answers false
-- for a file that is not in the client, the same idiom the header uses to fall
-- back on the addon icon. A probe that fails, or is inconclusive on a stub,
-- drops the whole affordance rather than risking the square.
--
-- Looked up through _G at call time, not cached at file scope: the headless
-- tests inject their own to see that both guards hold.
-- ============================================================
local GRAB_CURSOR = "Interface\\CURSOR\\UI-Cursor-Move"
local cursorProbe -- nil = not probed yet, then true/false
local function setGrabCursor(on)
local set, reset = _G.SetCursor, _G.ResetCursor
if type(set) ~= "function" or type(reset) ~= "function" then return end
if cursorProbe == nil then
cursorProbe = false
local ok, probe = pcall(function() return UIParent:CreateTexture(nil, "BACKGROUND") end)
if ok and probe then
cursorProbe = probe:SetTexture(GRAB_CURSOR) ~= false
probe:Hide()
end
end
if not cursorProbe then return end
if on then pcall(set, GRAB_CURSOR) else pcall(reset) end
end
-- ============================================================
-- SESSION VERBS
-- The footer row's contents, in order, with the gap that splits the pair from
-- the trio marked on the first member of the second group.
--
-- Every onClick goes straight to the SAME entry point the legend strip's own
-- button uses -- Sess:Undo/Redo, Sess:Finish, Settings:Toggle. Nothing here
-- decides anything; two access points to one verb must not be two
-- implementations of it.
--
-- NS.Settings is read at call time, not captured: Settings.lua loads after this
-- file, exactly as the legend does it.
-- ============================================================
local VERBS = {
{ key = "btnUndo", icon = UNDO_ICON, tip = "Undo",
onClick = function() Sess:Undo() end },
{ key = "btnRedo", icon = REDO_ICON, tip = "Redo",
onClick = function() Sess:Redo() end },
{ key = "btnSave", icon = SAVE_ICON, tip = "Save & Exit", split = true,
onClick = function() Sess:Finish("save") end },
{ key = "btnDiscard", icon = DISCARD_ICON, tip = "Discard",
onClick = function() Sess:Finish("discard") end },
{ key = "btnSettings", icon = SETTINGS_ICON, tip = "Settings",
desc = "Snapping, grid and per-addon mover toggles.",
onClick = function() if NS.Settings then NS.Settings:Toggle() end end },
}
-- Grey a verb the session has nothing for. The kit's glyph button carries no
-- disabled look of its own (it is a bare icon, not a chromed control), so the
-- dim is ours -- matched to the same DISABLED_ALPHA the anchor block's rows
-- use, and the hover brighten is suppressed with it so a dead button does not
-- light up under the mouse. The tooltip is deliberately left alive: "Redo" with
-- nothing to redo is still the answer to "what is this icon".
local function setVerbEnabled(btn, enabled)
btn:SetEnabled(enabled)
btn:SetAlpha(enabled and 1 or DISABLED_ALPHA)
btn:SetGlyphHover(enabled)
end
-- Undo and Redo are the only two on the row whose state is not a constant: it
-- follows the session's undo stack. Driven from Proxy:RefreshLegendVerbs, so
-- the strip and every open panel can never disagree about whether there is
-- anything left to undo.
local function refreshVerbs(po)
local ui = po.ui
if not ui or not ui.btnUndo then return end
local undo = Sess.undo
setVerbEnabled(ui.btnUndo, (undo and undo:CanUndo()) or false)
setVerbEnabled(ui.btnRedo, (undo and undo:CanRedo()) or false)
end
-- A row of equal-width buttons spanning the content width.
local function buttonRow(parent, y, height, defs)
local n = #defs
local bw = floor((CW - TIGHT * (n - 1)) / n)
local out = {}
for i, d in ipairs(defs) do
local b = UI:CreateButton(parent, {
text = d.text, width = bw, height = height, onClick = d.onClick,
style = d.style, tone = d.tone, fitText = false,
})
b:SetPoint("TOPLEFT", (i - 1) * (bw + TIGHT), y)
out[i] = b
end
return out
end
-- ============================================================
-- ANCHOR BLOCK LAYOUT
-- Every row is always there -- an unanchored element greys the ones it has no
-- answer for rather than dropping them, so the block never changes height and
-- the panel never jumps as you anchor and detach. What IS measured here is the
-- label column: a FontString reads 0 wide until its font resolves, and the seat
-- labels also swap with the anchor mode, so the column is re-measured on every
-- layout rather than fixed at build.
--
-- Returns the height the block consumes, gap to the rows below included.
-- ============================================================
-- Grey a whole picker row: the kit dims the dropdown itself, the label and the
-- handle are ours to match. A disabled Button already refuses OnMouseDown, and
-- Sess:BeginLink refuses a fallback link with no primary anyway -- the alpha is
-- there to say so before the user tries.
local function setRowEnabled(row, enabled)
row.picker:SetEnabled(enabled)
row.label:SetAlpha(enabled and 1 or DISABLED_ALPHA)
-- The seat rows have no handle: link-drag names a TARGET, which is what the
-- Target and Backup rows hold and what the seat rows say nothing about.
--
-- Asked of an explicit flag rather than of `row.handle` itself. A field
-- nobody set is not reliably nil on every Frame stand-in this code runs
-- against (the headless tests' fake frame answers a no-op function for any
-- key), so "has a handle" is recorded rather than inferred.
if row.hasHandle then
row.handle:SetEnabled(enabled)
row.handle:SetAlpha(enabled and 1 or DISABLED_ALPHA)
end
end
-- Every label in the set takes the widest of them, so all four pickers start on
-- the same column and the block reads as one table rather than as rows that
-- happen to be stacked.
local function labelColumn(labels, floorW, ceilW)
local w = 0
for _, fs in ipairs(labels) do w = max(w, fs:GetStringWidth() or 0) end
w = min(max(w, floorW), max(floorW, ceilW))
for _, fs in ipairs(labels) do fs:SetWidth(w) end
return w
end
local function layoutAnchorBlock(ui)
-- ONE column across all four rows, not one per pair: the seat rows are the
-- same shape as Target and Backup now, and two columns 6px apart would read
-- as a misalignment rather than as a grouping. Re-measured on every layout
-- because the seat labels swap with the mode ("Rel point" is twice "Align").
labelColumn({ ui.targetRow.label, ui.edgeRow.label, ui.alignRow.label, ui.backupRow.label,
ui.mirrorRow.label },
LABEL_MIN, LABEL_MAX)
local contentH = ROW_H * ANCHOR_ROWS + TIGHT * (ANCHOR_ROWS - 1)
ui.anchorBox:SetContentHeight(contentH)
local h = (ui.anchorBox:GetHeight() or contentH) + GAP
ui.rest:ClearAllPoints()
ui.rest:SetPoint("TOPLEFT", 0, -h)
return h
end
-- Size the X/Y group to its current labels so it stays centred. Both labels
-- take the wider of the two, so the pairs mirror each other; the boxes give up
-- width when the labels are long ("Offset X") so the group never overflows.
local function layoutXY(ui)
local lw = max(ui.xLabel:GetStringWidth() or 0, ui.yLabel:GetStringWidth() or 0)
ui.xLabel:SetWidth(lw); ui.yLabel:SetWidth(lw)
local pair = lw + TIGHT - 2
local bw = max(40, floor(min(BOX_W, (CW - GAP) / 2 - pair)))
ui.xBox:SetWidth(bw); ui.yBox:SetWidth(bw)
ui.xyRow:SetWidth((pair + bw) * 2 + GAP)
end
-- The addon line is always there; Configure only exists for elements whose def
-- offers openSettings. Returns the height consumed above the body.
local function layoutContent(ui, canConfigure)
ui.btnConfigure:SetShown(canConfigure)
local top = ADDON_H + (canConfigure and (TIGHT + HEADER_BTN_H) or 0) + GAP
ui.body:ClearAllPoints()
ui.body:SetPoint("TOPLEFT", 0, -top)
return top
end
-- ============================================================
-- DRAG HANDLE LOOK
-- The glyph button gives us the chain, the tooltip and the click plumbing; this
-- is the GRIP half -- the part that says the control is draggable before you
-- have touched it. A backdrop box so it reads as a thing rather than as art
-- printed on the row, grip dots beside the chain, and a hover state that lifts,
-- brightens and takes the move cursor.
--
-- Both handles come through here: primary and backup are the same gesture, so
-- they must not look like different kinds of control.
-- ============================================================
local function styleHandle(btn)
UI:CreateElementBackdrop(btn)
-- The kit centres its glyph; here it shares the box with the dots, so each
-- is pinned to its own end.
btn.Icon:ClearAllPoints()
btn.Icon:SetPoint("RIGHT", -1, 0)
btn.Grip = btn:CreateTexture(nil, "OVERLAY")
btn.Grip:SetTexture(GRIP_ICON)
btn.Grip:SetSize(GRIP, GRIP)
btn.Grip:SetPoint("LEFT", 1, 0)
local dim, border = UI.Colors.textDim, UI.Colors.border
btn.Grip:SetVertexColor(dim.r, dim.g, dim.b)
-- Chained, not replaced: the kit's own OnEnter owns the chain tint and the
-- tooltip, and both handles keep theirs.
local prevEnter, prevLeave = btn:GetScript("OnEnter"), btn:GetScript("OnLeave")
-- Whether the cursor currently on screen is OURS. An upvalue rather than a
-- field on the button: the pair of scripts is per handle anyway, and a flag
-- that lives in the closure cannot be read back wrong.
local grabbed = false
btn:SetScript("OnEnter", function(self, ...)
if prevEnter then prevEnter(self, ...) end
-- A greyed backup handle refuses the gesture, so it must not advertise
-- it either -- no lift, no cursor.
if not self:IsEnabled() then return end
local accent = UI:GetAccent()
NS.Fx.ScaleTo(self, HOVER_SCALE, HOVER_DUR)
self.Grip:SetVertexColor(1, 1, 1)
self:SetBackdropBorderColor(accent.r, accent.g, accent.b, 1)
-- Mid-gesture the cursor belongs to the GESTURE, which may have set its
-- own. The button keeps mouse capture between press and release, so the
-- pointer comes back over it while a link is live and grabbing here
-- would fight the drag.
if not Sess.linking then
grabbed = true
setGrabCursor(true)
end
end)
btn:SetScript("OnLeave", function(self, ...)
if prevLeave then prevLeave(self, ...) end
NS.Fx.ScaleTo(self, 1, HOVER_DUR)
self.Grip:SetVertexColor(dim.r, dim.g, dim.b)
self:SetBackdropBorderColor(border.r, border.g, border.b, 0.5)
-- Only ours to put back: an enter that skipped the cursor (disabled, or
-- mid-gesture) must not reset one the gesture set.
if grabbed then
grabbed = false
setGrabCursor(false)
end
end)
end
-- ============================================================
-- BUILD
-- Runs ONCE per popout instance (the shell re-runs it only for a genuinely new
-- frame), so everything it makes is stored on `po.ui` and every closure it
-- creates closes over `po` -- never over the selection.
-- ============================================================
local function buildPanel(po, content)
local ui = {}
po.ui = ui
-- ---- the addon this element belongs to --------------------------
-- The shell's title bar carries the icon and the element's own title; the
-- addon name is context under it, muted, one line.
ui.addon = UI:CreateLabel(content, { size = 10, color = UI.Colors.textDim })
ui.addon:SetWidth(CW)
ui.addon:SetWordWrap(false)
ui.addon:SetPoint("TOPLEFT", 0, 0)
-- ---- Configure --------------------------------------------------
-- The CONSUMER entry point (this element's own options page). Full width,
-- and only for defs that offer openSettings.
--
-- ⚠ Not to be confused with the cog in the verb footer: that one opens the
-- MOVER's settings (snapping, grid, which addons get proxies), this one
-- opens the settings of the thing being moved. Which is why one is a
-- labelled button at the top and the other an icon at the bottom.
ui.btnConfigure = UI:CreateButton(content, {
text = L["Configure"], width = CW, height = HEADER_BTN_H, style = "ghost", fitText = false,
tooltip = { title = L["Configure"], lines = { L["Open this element's own settings."] } },
onClick = function()
local el = po.el
if el and el.openSettings then xpcall(el.openSettings, geterrorhandler()) end
end,
})
ui.btnConfigure:SetPoint("TOPLEFT", 0, -(ADDON_H + TIGHT))
ui.btnConfigure:Hide()
-- ---- body -------------------------------------------------------
-- Its own frame so the two header lines above can come and go: these rows
-- keep their build-time offsets and only the body's single anchor moves.
local body = CreateFrame("Frame", nil, content)
body:SetWidth(CW)
body:SetPoint("TOPLEFT", 0, 0) -- layoutContent re-anchors it
ui.body = body
-- ---- anchor block ------------------------------------------------
-- One titled sub-section, four labelled rows: what this element is anchored
-- TO, the two halves of how it sits on it, and where it goes when that
-- target is off screen. Every row is labelled, because four unlabelled
-- dropdowns in a column tell you nothing about which is which.
ui.anchorBox = UI:CreateGroupBox(body, { title = L["Anchor"], width = CW, padding = ANCHOR_PAD })
ui.anchorBox:SetPoint("TOPLEFT", 0, 0)
local ac = ui.anchorBox.content
-- ---- the four rows -----------------------------------------------
-- All of them are built by the same helper so they line up without any one
-- knowing about the others. Each is a muted label, a picker taking the rest
-- of the row, and -- on the two that name a TARGET -- a link-drag handle at
-- the right end.
--
-- The handle is the aimed form of what the picker chooses: hold it, drag
-- the line onto a target and let go. Press and release are deliberately
-- OnMouseDown/OnMouseUp rather than an onClick -- the gesture has to START
-- on the press and FINISH wherever the cursor ended up, which a click (down
-- and up on the same button) cannot express. The button keeps mouse capture
-- between the two, so the release comes back here even though the cursor is
-- over a proxy by then.
local function pickerRow(parent, o)
local row = CreateFrame("Frame", nil, parent)
row:SetSize(ACW, ROW_H)
row.label = UI:CreateLabel(row, { text = o.label, size = 10, color = UI.Colors.textDim })
-- The column is capped (labelColumn), so a long translation has to clip
-- rather than wrap: a second line would fall through the row below it.
row.label:SetWordWrap(false)
row.label:SetPoint("LEFT", 0, 0)
-- Only the rows that name a target get one: link-drag ties this element
-- TO another, and the seat rows say how it sits on the one already
-- named, not which. Their gutter is still reserved below, so the four
-- pickers share a right edge as well as a left one.
row.hasHandle = o.mode ~= nil
if row.hasHandle then
row.handle = UI:CreateGlyphButton(row, {
texture = LINK_ICON, width = HANDLE_W, height = HANDLE_H,
iconSize = HANDLE_GLYPH, tooltip = o.handleTooltip,
})
row.handle:SetPoint("RIGHT", 0, 0)
styleHandle(row.handle)
row.handle:RegisterForClicks("AnyUp", "AnyDown")
row.handle:SetScript("OnMouseDown", function(_, button)
local el = po.el
if not el then return end
if button == "LeftButton" then
-- Taking hold of the chain is editing: the panel earns its keep.
po:AutoPin()
Sess:BeginLink(el, o.mode)
elseif button == "RightButton" and Sess.linking then Sess:CancelLink() end
end)
row.handle:SetScript("OnMouseUp", function(_, button)
if button == "LeftButton" and Sess.linking then
Sess:EndLink(Proxy.LinkHover and Proxy:LinkHover() or nil)
end
end)
end
row.picker = UI:CreateDropdown(row, o.dropdown)
-- An inline dropdown's factory label is hidden, so the kit's usual
-- label-hover tooltip has nothing to sit on; openerTooltip puts it on
-- the opener itself. Worth having: "Target" alone does not say that
-- picking one MOVES NOTHING, which is the whole point of the row.
row.picker.openerTooltip = o.tooltip
-- The opener's caption CLIPS, it does not wrap. Its FontString is
-- anchored to both edges with word wrap left on by the kit, so a caption
-- wider than the opener takes a second line and spills straight out of a
-- 20px row. Short captions never showed it; the Target row's empty state
-- ("None -- drag [chain] to link") is close to the width in English and a
-- translation will pass it.
row.picker.opener.Text:SetWordWrap(false)
row.picker:SetHeight(ROW_H)
-- Two-point anchored rather than sized, so the picker absorbs whatever
-- the measured label column leaves it.
--
-- ☠ The right edge is measured off the ROW, not off the handle sitting
-- there. Anchor offsets resolve in screen space, so a picker pinned to
-- the handle's LEFT would slide every time the handle's hover lift
-- scaled it -- the whole dropdown jittering because you passed the
-- cursor over the grip. It is also why a handle-less row still gives
-- the gutter up: the offset is the same number either way, and four
-- pickers on one right edge is the whole point of the block.
row.picker:SetPoint("LEFT", row.label, "RIGHT", TIGHT, 0)
row.picker:SetPoint("RIGHT", row, "RIGHT", -(HANDLE_W + TIGHT), 0)
return row
end
-- Where row n sits inside the block, 0-based, reading down: Target, the two
-- seat rows, Backup. Four rows of one height with one tight gap between
-- each, so the offsets are a multiple rather than four sums to keep honest.
local function rowY(n) return -(ROW_H + TIGHT) * n end
-- Anchoring without dragging: pick a target and the element stays exactly
-- where it is (Sess:AnchorInPlace derives the spec that reproduces its
-- current seat). The list is rebuilt on every open (optionsFunc) because
-- what is legal changes with the graph and with what is on screen -- and
-- that same open is one of the auto-pin triggers.
ui.targetRow = pickerRow(ac, {
label = L["Target"], mode = "primary",
tooltip = { title = L["Target"],
lines = { L["What this element is anchored to. Picking one does not move it."] } },
handleTooltip = { title = L["Anchor"], lines = { L["Drag onto another mover to attach"] } },
dropdown = {
inline = true, searchable = true,
optionsFunc = function()
po:AutoPin()
local el = po.el
return el and NS.Picker:Options(el) or { _order = {} }
end,
get = function()
local el = po.el; if not el then return nil end
local a = Registry:GetPos(el).anchor
return a and a.target or nil
end,
set = function(targetId)
local el = po.el
if el then Sess:AnchorInPlace(el, targetId) end
end,
},
})
ui.targetRow:SetPoint("TOPLEFT", 0, rowY(0))
-- refreshInstance owns this caption from here on; set once so the opener
-- never renders the raw value between build and the refresh that follows it.
ui.targetRow.picker:SetDisplayOverride(unanchoredCaption())
-- Where the element goes when the primary target is not on screen. Only
-- meaningful once there IS a primary, so both the picker and its handle go
-- grey while the element is free.
ui.backupRow = pickerRow(ac, {
label = L["Backup"], mode = "fallback",
tooltip = { title = L["Backup"],
lines = { L["Where this element goes while the target above is off screen."] } },
handleTooltip = { title = L["Backup"], lines = { L["Drag onto another mover to set the backup anchor"] } },
dropdown = {
inline = true, searchable = true,
optionsFunc = function()
po:AutoPin()
local el = po.el
return el and NS.Picker:Options(el, "fallback") or { _order = {} }
end,
get = function()
local el = po.el; if not el then return NS.Picker.NONE end
local a = Registry:GetPos(el).anchor
return a and a.fallback and a.fallback.target or NS.Picker.NONE
end,
set = function(targetId)
local el = po.el; if not el then return end
if targetId == NS.Picker.NONE then Sess:ClearFallback(el) else Sess:SetFallback(el, targetId) end
end,
},
})
ui.backupRow:SetPoint("TOPLEFT", 0, rowY(3))
ui.backupRow.picker:SetDisplayOverride(L["None"])
-- ---- the seat: edge/align, or point/relPoint ---------------------
-- A ROW EACH, in the same shape as Target and Backup. The two shared one row
-- in half-width halves until the halves were too narrow for what they hold:
-- point mode is the tight case ("Rel point" beside "BOTTOMLEFT") and the
-- opener was clipping the VALUE, which is the thing being set. Space saved
-- by hiding the answer is not saved.
--
-- Their SUBJECT follows the anchor's mode, because the two modes describe a
-- seat with different fields and only one of them is ever live. The refresh
-- swaps both the labels and the option sets.
--
-- The 9 anchor points are API identifiers, not prose -- they read the same
-- in every locale and the panel already spells them out raw elsewhere, so
-- the same table serves both point-mode slots.
local POINT_OPTS = { _order = POINTS }
for _, p in ipairs(POINTS) do POINT_OPTS[p] = p end
ui.specOptions = {
outside = {
{ _order = { "right", "left", "top", "bottom" },
right = L["Right"], left = L["Left"], top = L["Top"], bottom = L["Bottom"] },
{ _order = { "start", "center", "end" },
start = L["Start"], center = L["Center"], ["end"] = L["End"] },
},
point = { POINT_OPTS, POINT_OPTS },
}
-- The dropdown half of a seat row; pickerRow builds the rest of it.
local function specDropdown(slot)
return {
inline = true, options = ui.specOptions.outside[slot],
-- Opening the menu is editing, and the set it opens on follows the
-- element THIS panel is bound to -- two panels can be in two modes.
optionsFunc = function()
po:AutoPin()
return ui.specOptions[specModeOf(po.el)][slot]
end,
get = function()
local el = po.el
local key = SPEC_KEYS[specModeOf(el)][slot]
if not el then return SPEC_FALLBACK[key] end
local a = Registry:GetPos(el).anchor
return (a and a[key]) or SPEC_FALLBACK[key]
end,
set = function(v)
local el = po.el; if not el then return end
Sess:SetAnchorSpec(el, { [SPEC_KEYS[specModeOf(el)][slot]] = v })
end,
}
end
-- No `mode`, so no link handle: see pickerRow.
ui.edgeRow = pickerRow(ac, { label = L["Edge"], dropdown = specDropdown(1) })
ui.edgeRow:SetPoint("TOPLEFT", 0, rowY(1))
ui.alignRow = pickerRow(ac, { label = L["Align"], dropdown = specDropdown(2) })
ui.alignRow:SetPoint("TOPLEFT", 0, rowY(2))
ui.specMode = "outside"
-- ---- mirror ------------------------------------------------------
-- Two independent toggles on the PRIMARY anchor: on a mirrored axis the element
-- moves the opposite way to its target, reflected across a centre line, and the
-- X/Y box for that axis below edits the line (Mirror Position) instead of an
-- offset. Last in the block so the rows above keep their places; same label
-- column, so it reads as the fifth row of one table.
ui.mirrorRow = CreateFrame("Frame", nil, ac)
ui.mirrorRow:SetSize(ACW, ROW_H)
ui.mirrorRow:SetPoint("TOPLEFT", 0, rowY(4))
ui.mirrorRow.label = UI:CreateLabel(ui.mirrorRow, { text = L["Mirror"], size = 10, color = UI.Colors.textDim })
ui.mirrorRow.label:SetWordWrap(false)
ui.mirrorRow.label:SetPoint("LEFT", 0, 0)
local function mirrorToggle(axis, title, line)
local cb = UI:CreateCheckbox(ui.mirrorRow, {
label = axis == "X" and L["X"] or L["Y"], size = MIRROR_BOX,
tooltip = { title = title, lines = { line, L["0 on Mirror Position is the centre of the screen."] } },
get = function()
local el = po.el
local a = el and Registry:GetPos(el).anchor
return a and a["mirror" .. axis] and true or false
end,
set = function(v)
local el = po.el; if not el then return end
po:AutoPin()
Sess:SetMirror(el, axis, v)
end,
})
cb:SetSize(MIRROR_CELL, ROW_H)
return cb
end
ui.mirrorX = mirrorToggle("X", L["Mirror X"],
L["Moves the opposite way to its target left and right, mirrored across a vertical centre line."])
ui.mirrorX:SetPoint("LEFT", ui.mirrorRow.label, "RIGHT", TIGHT, 0)
ui.mirrorY = mirrorToggle("Y", L["Mirror Y"],
L["Moves the opposite way to its target up and down, mirrored across a horizontal centre line."])
ui.mirrorY:SetPoint("LEFT", ui.mirrorX, "RIGHT", TIGHT, 0)
-- ---- everything below the anchor block ---------------------------
-- Its own frame so the block above can sit at whatever height the group box
-- measures: these rows keep their build-time offsets and only ui.rest's
-- single anchor moves.
local rest = CreateFrame("Frame", nil, body)
rest:SetWidth(CW)
rest:SetPoint("TOPLEFT", 0, 0) -- layoutAnchorBlock re-anchors it
ui.rest = rest
local y = 0
-- Reserve a row of height h and return its top; gap is the space to leave below it.
local function row(h, gap) local cur = y; y = y - h - (gap or GAP); return cur end
-- ---- X / Y: two label+box pairs centred as one group ------------
-- The pairs live in a container whose width is recomputed on every refresh
-- (the labels change between "X" and "Offset X"), and the container is
-- centred in the panel, so the group stays centred whatever the labels say.
local ry = row(20)
ui.xyRow = CreateFrame("Frame", nil, rest)
ui.xyRow:SetSize(CW, 20)
ui.xyRow:SetPoint("TOP", rest, "TOP", 0, ry)
ui.xLabel = UI:CreateLabel(ui.xyRow, { text = L["X"], size = 11, justify = "RIGHT" })
ui.xLabel:SetPoint("LEFT", 0, 0)
ui.xBox = UI:CreateEditBox(ui.xyRow, {
width = BOX_W, numeric = true,
get = function()
local el = po.el; if not el then return 0 end
-- Solver.Readout: the same pair the slab's coords readout quotes.
local x = Solver.Readout(Registry:GetPos(el))
return x
end,
onCommit = function(v)
local el = po.el
if el then Sess:SetXY(el, v, tonumber(ui.yBox:GetText()) or 0) end
end,
})
ui.xBox:SetPoint("LEFT", ui.xLabel, "RIGHT", TIGHT - 2, 0)
ui.yLabel = UI:CreateLabel(ui.xyRow, { text = L["Y"], size = 11, justify = "RIGHT" })
ui.yLabel:SetPoint("LEFT", ui.xBox, "RIGHT", GAP, 0)
ui.yBox = UI:CreateEditBox(ui.xyRow, {
width = BOX_W, numeric = true,
get = function()
local el = po.el; if not el then return 0 end
local _, y = Solver.Readout(Registry:GetPos(el))
return y
end,
onCommit = function(v)
local el = po.el
if el then Sess:SetXY(el, tonumber(ui.xBox:GetText()) or 0, v) end
end,
})
ui.yBox:SetPoint("LEFT", ui.yLabel, "RIGHT", TIGHT - 2, 0)
-- Taking focus in a coordinate box is the clearest "I am editing THIS one"
-- there is, and it is also the moment a panel that keeps re-docking under
-- the cursor is most in the way.
for _, box in ipairs({ ui.xBox, ui.yBox }) do
box:HookScript("OnEditFocusGained", function() po:AutoPin() end)
end
-- On a mirrored axis the box is the Mirror Position, which is not a number anyone
-- can guess the meaning of: say what 0 is. Other states keep the bare box.
local function mirrorTip(box, axis)
box:HookScript("OnEnter", function(self)
local el = po.el
local a = el and Registry:GetPos(el).anchor
if not (a and a["mirror" .. axis]) then return end
UI:ShowTooltip(self, { title = L["Mirror Position"], lines = {
L["Where the centre line sits that this element and its target mirror around."],
L["0 on Mirror Position is the centre of the screen."] } })
end)
box:HookScript("OnLeave", function() UI:HideTooltip() end)
end
mirrorTip(ui.xBox, "X")
mirrorTip(ui.yBox, "Y")
-- ---- nudge cluster | 9-point picker: two equal halves ------------
-- Each cluster sits in its own container, centred in its half of the
-- content width and on the same vertical centre.
local clusterH = max(NUDGE_CELL * 3, DOT * 3 + DOT_GAP * 2)
local ny = row(clusterH)
local half = CW / 2
ui.nudge = CreateFrame("Frame", nil, rest)
ui.nudge:SetSize(NUDGE_CELL * 3, NUDGE_CELL * 3)
ui.nudge:SetPoint("CENTER", rest, "TOPLEFT", half / 2, ny - clusterH / 2)
local ARROWS = { -- icon, dx, dy, column, row (0-based in the 3x3 cluster)
{ "expand_less", 0, 1, 1, 0 },
{ "chevron_left", -1, 0, 0, 1 },
{ "chevron_right", 1, 0, 2, 1 },
{ "expand_more", 0, -1, 1, 2 },
}
ui.nudgeButtons = {}
for i, a in ipairs(ARROWS) do
local icon, dx, dy, col, rowi = a[1], a[2], a[3], a[4], a[5]
local b = UI:CreateGlyphButton(ui.nudge, {
texture = UI.MEDIA .. "Icons\\" .. icon .. ".png", size = NUDGE_CELL, iconSize = NUDGE_ICON,
onClick = function()
local el = po.el
if not el then return end
-- Pin BEFORE the nudge: a following panel would otherwise
-- re-dock under the cursor on the very keypress that asked for
-- a run of them.
po:AutoPin()
Sess:Nudge(el, dx * step(), dy * step())
end,
tooltip = { title = L["Nudge"], lines = { L["Hold Shift for 10 units, Ctrl for 100."] } },
})
b:SetPoint("TOPLEFT", col * NUDGE_CELL, -rowi * NUDGE_CELL)
ui.nudgeButtons[i] = b
end
ui.picker = CreateFrame("Frame", nil, rest)
ui.picker:SetSize(DOT * 3 + DOT_GAP * 2, DOT * 3 + DOT_GAP * 2)
ui.picker:SetPoint("CENTER", rest, "TOPLEFT", half + half / 2, ny - clusterH / 2)
ui.points = {}
for i, point in ipairs(POINTS) do
local col, rowi = (i - 1) % 3, floor((i - 1) / 3)
local b = UI:CreateButton(ui.picker, {
width = DOT, height = DOT,
onClick = function()
local el = po.el
if not el then return end
po:AutoPin()
Sess:SetAnchorPoint(el, point)
end,
tooltip = point,
})
b:SetPoint("TOPLEFT", col * (DOT + DOT_GAP), -rowi * (DOT + DOT_GAP))
b.point = point
ui.points[i] = b
end
-- ---- actions -------------------------------------------------
local acts = buttonRow(rest, row(BTN_H), BTN_H, {
{ text = L["Center"], onClick = function() local el = po.el; if el then Sess:Center(el) end end },
{ text = L["Reset"], onClick = function() local el = po.el; if el then Sess:Reset(el) end end },
{ text = L["Detach"], onClick = function() local el = po.el; if el then Sess:Detach(el) end end },
})
ui.btnCenter, ui.btnReset, ui.btnDetach = acts[1], acts[2], acts[3]
-- ---- the session verbs ------------------------------------------
-- Centred as a CLUSTER rather than spread across the content width: five
-- 22px cells stretched over 270 would read as five unrelated controls, and
-- the row is one toolbar. Measured rather than declared, so the split gap
-- and the cell size stay the only two numbers to keep honest.
local verbW = VERB_CELL * #VERBS
for i, v in ipairs(VERBS) do
if i > 1 then verbW = verbW + (v.split and VERB_SEP or TIGHT) end
end
local vy = row(BTN_H)
ui.verbs = CreateFrame("Frame", nil, rest)
ui.verbs:SetSize(verbW, BTN_H)
ui.verbs:SetPoint("TOP", rest, "TOPLEFT", CW / 2, vy)
ui.verbButtons = {}
local vx = 0
for i, v in ipairs(VERBS) do
if i > 1 then vx = vx + (v.split and VERB_SEP or TIGHT) end
local b = UI:CreateGlyphButton(ui.verbs, {
texture = v.icon, width = VERB_CELL, height = BTN_H, iconSize = VERB_ICON,
-- An icon with no caption anywhere near it: the tooltip is the only
-- thing that names the verb, so every one of them has one.
tooltip = { title = L[v.tip], lines = v.desc and { L[v.desc] } or nil },
-- ☠ No po:AutoPin(). See the header: these act on the session, not
-- on the element this panel is bound to.
onClick = v.onClick,
})
b:SetPoint("TOPLEFT", vx, 0)
ui[v.key] = b
ui.verbButtons[i] = b
vx = vx + VERB_CELL
end
-- Copy-to-twin: full-width bottom row, only for elements whose def names a
-- twin (the refresh shows it and grows the panel by the row).
-- ☠ `text = ""`, not omitted. The label is only knowable at refresh time (it
-- names the twin), but UI:StyleButton creates the button's FontString --
-- and registers it via SetFontString, which is what makes the NATIVE
-- Button:SetText work -- ONLY when `text` is passed. Omit it and the
-- button has no font string at all, so the SetText below silently no-ops
-- and the row renders blank. An empty string is enough to get the font
-- string built; `fitText = false` keeps it from resizing off it.
ui.btnCopy = UI:CreateButton(rest, {
text = "", width = CW, height = BTN_H, fitText = false,
onClick = function() local el = po.el; if el then Sess:CopyToTwin(el) end end,
})
ui.btnCopy:SetPoint("TOPLEFT", 0, row(BTN_H, 0))
ui.btnCopy:Hide()
-- Heights of the rows BELOW the anchor block, with and without the copy row.
-- The block adds its own on top (layoutAnchorBlock) and the two header lines
-- their own above that (layoutContent), so the total is settled per refresh.
ui.restFullH = -y
ui.restBaseH = ui.restFullH - BTN_H - GAP
rest:SetHeight(ui.restBaseH)
end
-- ============================================================
-- PER-INSTANCE REFRESH
-- Everything a panel shows comes from ITS OWN element, never from the
-- selection: a pinned panel keeps reporting the mover it was pinned for while
-- the selection has moved on.
-- ============================================================
local function refreshInstance(po)
if po.closed or not po.ui then return end
local el = po.elId and Registry:Get(po.elId) or nil
if not el then return end
po.el = el
local ui = po.ui
local pos = Registry:GetPos(el)
local addon = Registry:GetAddon(el.addon)
local icon = addon and addon.icon or DEFAULT_ICON
po:SetHeader(el.title, icon)
-- SetTexture answers false for a file the client does not have; the shell
-- takes whatever it is handed, so the fallback is ours to apply.
if po.iconTex and po.iconTex:SetTexture(icon) == false then po.iconTex:SetTexture(DEFAULT_ICON) end
ui.addon:SetText(addon and addon.title or el.addon)
local top = layoutContent(ui, el.openSettings ~= nil)
local a = pos.anchor
if a then
-- Name whichever block is actually driving the element: the backup
-- taking over is the one thing the panel must not stay quiet about, and
-- naming the primary's target while the backup holds it would be a lie.
-- Just the NAME -- the seat has its own labelled row underneath, so the
-- old "(right/center)" tail is said twice over.
local active = Registry:ActiveAnchor(el)
local blk = active or a
local target = Registry:GetTarget(blk.target)
local text = target and target.title or L["(unavailable)"]
if active ~= nil and active == a.fallback then text = L["(backup)"] .. " " .. text end
ui.targetRow.picker:SetDisplayOverride(text)
-- A mirrored axis's box edits the centre line, not an offset (Sess:SetXY).
ui.xLabel:SetText(a.mirrorX and L["Mirror Position"] or L["Offset X"])
ui.yLabel:SetText(a.mirrorY and L["Mirror Position"] or L["Offset Y"])
ui.btnDetach:SetEnabled(true)
for _, b in ipairs(ui.points) do b:Hide() end
else
ui.targetRow.picker:SetDisplayOverride(unanchoredCaption())
ui.xLabel:SetText(L["X"]); ui.yLabel:SetText(L["Y"])
ui.btnDetach:SetEnabled(false)
local cur = pos.point or "CENTER"
for _, b in ipairs(ui.points) do
-- pointLocked: the consumer derives `point` (e.g. a growth corner); a
-- picker would be silently overwritten, so it is not offered.
b:SetShown(not el.pointLocked)
b:SetActive(b.point == cur)
end
end
-- Backup: only meaningful once there IS a primary to fall back FROM, so the
-- row greys rather than vanishing -- an empty row you can see is what tells
-- you the setting exists at all.
local ftext = L["None"]
local fb = a and a.fallback
if fb then
local ft = Registry:GetTarget(fb.target)
ftext = ft and ft.title or L["(unavailable)"]
if ft and not Registry:IsTargetAvailable(ft) then ftext = ftext .. " " .. L["(hidden)"] end
end
ui.backupRow.picker:SetDisplayOverride(ftext)
-- The seat rows follow the mode. Swapping the option set rebuilds the menu
-- rows, so it is done only when the mode actually CHANGED -- a refresh runs
-- on every nudge and a rebuild per keypress would be pure churn.
local mode = specModeOf(el)
if ui.specMode ~= mode then
ui.specMode = mode
ui.edgeRow.label:SetText(mode == "point" and L["Point"] or L["Edge"])
ui.alignRow.label:SetText(mode == "point" and L["Rel point"] or L["Align"])
ui.edgeRow.picker:RebuildOptions(ui.specOptions[mode][1])
ui.alignRow.picker:RebuildOptions(ui.specOptions[mode][2])
else