-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathOptionsFrame.lua
More file actions
3597 lines (3357 loc) · 144 KB
/
Copy pathOptionsFrame.lua
File metadata and controls
3597 lines (3357 loc) · 144 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
-- WeakestAuras -- the main config window: an aura list down the left (add/
-- delete), a tabbed editor on the right (Info/Display/Trigger). Built entirely
-- in Lua (no XML) since nothing here needs to survive a reload independent of
-- WeakestAurasDB -- follows the same shared config-panel pattern as sibling
-- config panel does the same.
if WeakestAuras.disabled then return end
local WA = WeakestAuras
local W = WA.Widgets
-- Friendly labels for WA.RegionTypeList()'s raw regionType strings -- shared by
-- the Info tab's Region type dropdown and the "+ New" type-picker menu
-- (buildPanel) so both read the same, rather than one showing raw
-- "dynamicgroup" text. Taken from each type's own spec.displayName so a new
-- region type names itself where it's registered instead of needing a second
-- entry here; safe to build at load, since this is the last file in the .toc
-- and every RegisterRegionType call has already run.
local REGION_TYPE_LABELS = {}
for name, spec in pairs(WA.regionTypes) do
REGION_TYPE_LABELS[name] = spec.displayName or name
end
-- Lua 5.0 caps a function at 32 upvalues, and buildPanel below -- one large
-- function nesting a dozen+ button/row closures -- blew past that referencing
-- this many chunk-level locals directly (every constant/state var/forward-
-- declared function any nested closure touches counts against the *enclosing*
-- function too, not just the closure itself). Bundling all of it into one
-- table fixes it: a table costs ONE upvalue regardless of how many fields it
-- holds. Same fix as Quartermaster's listEditor and FearWardHelper's
-- buildConfig -- the table keeps the large builder within Lua 5.0's upvalue limit.
local S = {
-- Two-row layout per WeakAuras2's AceGUIWidget-WeakAurasNewButton.lua: a
-- full-height icon on the left, the aura name anchored to the row's top
-- edge, and a trigger summary anchored to the row's bottom edge, both
-- right of the icon.
ROW_H = 34,
VISIBLE = 10,
-- Not the list's width -- the list is elastic (see buildPanel). This is the
-- minimum MIN_W is sized to guarantee it.
LIST_W = 200,
-- The options pane's fixed width, and the one number the window's minimum is
-- built from. Deliberately the width that pane had when it was the elastic
-- side at the default window size, so pinning it changed no tab's layout.
CONTENT_W = 442,
INDENT_W = 14,
STATUS_SIZE = 10,
UNGROUP_W = 12,
-- The New pane's rows: a 32px preview plus two lines of text.
NEW_ROW_H = 40,
-- A bucket header is one line of text, not a two-line aura row (WA2's is 20px
-- against its own 42px rows). Rows are therefore *not* a uniform grid: every
-- position below is accumulated from the heights actually painted.
HEADER_H = 20,
FALLBACK_ICON = "Interface\\Icons\\INV_Misc_QuestionMark",
SEARCH_H = 26, -- search box height (20) + gap above the list (6)
TOOLBAR_H = 26, -- toolbar button row (22 tall + 4 gap) above the search box
TAB_ROW_H = 22, -- one row of the tab strip
TAB_ROW_GAP = 4, -- between two rows of a wrapped strip
TAB_TO_CONTENT = 6, -- gap below the strip's last row
BOTTOM_RESERVED = 12, -- bottom margin only; the list runs to the panel's edge
-- MIN_W = left inset + LIST_W + gap + CONTENT_W + right inset, i.e. the
-- narrowest window that still gives the list its minimum beside a
-- full-width options pane.
MIN_W = 12 + 200 + 12 + 442 + 14, MIN_H = 360,
MAX_W = 1000, MAX_H = 800,
TAB_DEFS = {
{ key = "info", name = "Info" },
{ key = "display", name = "Display" },
{ key = "trigger", name = "Trigger" },
{ key = "conditions", name = "Conditions" },
{ key = "animation", name = "Animations" },
{ key = "action", name = "Actions" },
{ key = "load", name = "Load" },
{ key = "config", name = "Custom" },
},
-- The context menu's Copy settings submenu, mirroring upstream's less the
-- parts this addon has no subsystem for (author options, custom
-- config). A group offers the single "Group" entry: it has
-- no triggers, conditions or load of its own to copy.
COPY_PARTS_LEAF = {
{ text = "Everything", part = "all", paste = "Paste Settings" },
{ text = "Display", part = "display", paste = "Paste Display Settings" },
{ text = "Trigger", part = "trigger", paste = "Paste Trigger Settings" },
{ text = "Conditions", part = "condition", paste = "Paste Condition Settings" },
{ text = "Animations", part = "animation", paste = "Paste Animation Settings" },
{ text = "Actions", part = "action", paste = "Paste Action Settings" },
{ text = "Load", part = "load", paste = "Paste Load Settings" },
},
COPY_PARTS_GROUP = {
{ text = "Group", part = "display", paste = "Paste Group Settings" },
},
-- Keys a "Display" copy never carries: the aura's identity, its place in the
-- tree, and the parts that have their own entry. subRegions is deliberately
-- absent -- the text/border/glow *are* the display here, and a copy that
-- dropped them would surprise.
COPY_IGNORE = {
triggers = true, conditions = true, animation = true, actions = true, load = true,
id = true, parent = true, controlledChildren = true,
uid = true, internalVersion = true,
},
searchTerms = {},
-- Per-group expand/collapse state, keyed by aura id. Absence means
-- expanded -- only an explicit `false` collapses a group -- so newly
-- created groups start open without needing to be added here.
expanded = {},
-- Loaded/Not Loaded bucket collapse state, keyed by "loaded"/"unloaded" --
-- a separate table from S.expanded (which is keyed by aura id) since an
-- aura literally named "loaded" would otherwise collide with the bucket's
-- own entry. Same absence-means-expanded convention.
bucketExpanded = {},
-- Ordered array of selected aura ids (click/insertion order), replacing
-- the old single S.selectedId scalar. Empty
-- means nothing picked; a single entry is the ordinary case every
-- existing single-aura code path reads via S.primaryId(); more than one means
-- a multi-selection, which supports the placeholder pane and bulk actions,
-- not per-field editing.
selection = {},
}
S.DEFAULT_W, S.DEFAULT_H = 680, 440 + S.SEARCH_H + S.TOOLBAR_H
S.visibleRows = S.VISIBLE
-- Read-only handle for Debug.lua's /wa rows, which measures the list's laid-out
-- geometry -- the one thing the headless harness has no way to see.
WA.OptionsState = S
function S.trim(s)
local t = string.gsub(s or "", "^%s*(.-)%s*$", "%1")
return t
end
-- Same OR-search syntax as WeakAuras2's own filter box (GenericTrigger.lua's
-- splitAtOr): "|" or " or " between fragments broadens the match instead of
-- narrowing it, so e.g. "rend or thrash" matches either.
function S.splitFilterTerms(text)
local terms = {}
text = string.lower(S.trim(text or ""))
if text == "" then return terms end
text = string.gsub(text, " or ", "|")
local start = 1
while true do
local s, e = string.find(text, "|", start, true)
local piece = string.sub(text, start, s and (s - 1) or -1)
if piece ~= "" then table.insert(terms, piece) end
if not s then break end
start = e + 1
end
return terms
end
function S.matchesFilter(id, terms)
if table.getn(terms) == 0 then return true end
local lower = string.lower(id)
for i = 1, table.getn(terms) do
if string.find(lower, terms[i], 1, true) then return true end
end
return false
end
function S.isSelected(id)
for i = 1, table.getn(S.selection) do
if S.selection[i] == id then return true end
end
return false
end
-- The single selected id, or nil when nothing or more than one is selected --
-- every pre-multi-select code path (Info/Display/Trigger tabs, Rename, the
-- Delete button) is single-aura-only and reads this instead of S.selection
-- directly, so it stays inert (rather than acting on an arbitrary member)
-- once a second aura joins the selection.
function S.primaryId()
if table.getn(S.selection) == 1 then return S.selection[1] end
return nil
end
-- Common refresh after any selection change -- was S.selectAura's body
-- before the single-id model became an array; every mutator below ends by
-- calling this instead of repeating the same three refreshes. Also closes
-- the context menu if it's open, so it can't linger over a
-- row after a plain click elsewhere changes the selection out from under it
-- -- S.menu doesn't exist until buildPanel runs, hence the guard.
function S.applySelectionChange()
if S.menu then S.menu.Close() end
S.searchBox:ClearFocus()
-- Update the forced-visibility selection BEFORE refreshList: the row eyes
-- render WA.ForcedState, which reads selectionLeaves, so painting the list
-- first would show the previous selection's amber until the next refresh.
-- Paints a dummy region for the aura being edited even when its real trigger
-- doesn't currently match -- primaryId() is nil for no/multi (clears preview).
WA.SetPreview(S.primaryId())
-- Attach the in-world mover to the (now force-shown) single selection;
-- nil/multi detaches it.
WA.Mover.Attach(S.primaryId())
S.refreshList()
S.updateTabAvailability()
-- Picking an aura leaves the New pane, since the pane is about the thing
-- that doesn't exist yet -- except for an empty group, where offering to
-- fill it is the whole point.
local sole = S.primaryId() and WeakestAurasDB.displays[S.primaryId()]
if sole and WA.IsGroup(sole) and table.getn(sole.controlledChildren or {}) == 0 then
S.activeTab = "new"
elseif S.activeTab == "new" and table.getn(S.selection) > 0 then
S.activeTab = "info"
if S.tabStrip then S.tabStrip.select("info") end
end
S.refreshTabContent()
end
function S.setSelection(id)
S.selection = id and { id } or {}
S.applySelectionChange()
end
function S.clearSelection()
S.selection = {}
S.applySelectionChange()
end
-- The ordered sibling list a given parentId's children live in -- top-level
-- WA.GetOrder() when nil, a group's own controlledChildren otherwise. Lets
-- the bulk drag-move code below (buildPanel's endDrag) translate a drop
-- position into WA.ReorderAura's numeric `before` index without duplicating
-- Data.lua's own private currentList/indexOf.
function S.siblingList(parentId)
if parentId == nil then return WA.GetOrder() end
local parent = WeakestAurasDB.displays[parentId]
return (parent and parent.controlledChildren) or {}
end
function S.indexOfId(list, id)
for i = 1, table.getn(list) do
if list[i] == id then return i end
end
return nil
end
-- Recursively collects every non-group leaf under id (upstream's TraverseLeafs
-- pattern, WeakAuras.lua:6660-6748, applied to a new context: expanding a
-- ctrl-clicked group into the selection instead of a single-group export/
-- duplicate/move). A plain leaf just returns itself. This is what lets
-- S.selection keep its "always leaf ids, never a group" invariant even once
-- group rows become valid ctrl-click targets.
function S.leafDescendants(id, out)
out = out or {}
local data = WeakestAurasDB.displays[id]
if not data then return out end
if WA.IsGroup(data) then
local children = data.controlledChildren or {}
for i = 1, table.getn(children) do
S.leafDescendants(children[i], out)
end
else
table.insert(out, id)
end
return out
end
-- True only when id is a group with at least one leaf descendant and every
-- one of them is currently selected -- drives the group row's own highlight
-- in S.refreshList, since a group's id never itself enters S.selection (see
-- S.leafDescendants) and a collapsed group would otherwise show no feedback
-- at all after a ctrl-click selects its hidden children.
function S.allDescendantsSelected(id)
local leaves = S.leafDescendants(id)
if table.getn(leaves) == 0 then return false end
for i = 1, table.getn(leaves) do
if not S.isSelected(leaves[i]) then return false end
end
return true
end
-- Ctrl-click a leaf: add if not already selected, remove (from anywhere in
-- the list, not just the end) if it is -- unchanged from before groups could
-- be clicked here. Ctrl-click a group: same toggle, but applied to the
-- group's *entire* recursive leaf-descendant set at once (S.leafDescendants)
-- rather than the group's own id, which never enters S.selection. "Already
-- selected" for a group means every one of its descendants already is; any
-- other state (partial or none) adds whichever aren't selected yet, the
-- usual tri-state-parent-checkbox convention. Never called while the current
-- sole selection is a group -- see the row OnClick handler -- so id here can
-- be a group, but S.selection itself never ends up mixing a literal group id
-- into a multi-member selection.
function S.toggleSelection(id)
local data = WeakestAurasDB.displays[id]
local ids = (data and WA.IsGroup(data)) and S.leafDescendants(id) or { id }
local allSelected = true
for i = 1, table.getn(ids) do
if not S.isSelected(ids[i]) then allSelected = false end
end
if allSelected then
local remove = {}
for i = 1, table.getn(ids) do remove[ids[i]] = true end
local kept = {}
for i = 1, table.getn(S.selection) do
if not remove[S.selection[i]] then table.insert(kept, S.selection[i]) end
end
S.selection = kept
else
for i = 1, table.getn(ids) do
if not S.isSelected(ids[i]) then table.insert(S.selection, ids[i]) end
end
end
S.applySelectionChange()
end
-- Shift-click: extend the selection to every same-parent leaf between the
-- current anchor (the most-recently-added member -- derived on the fly
-- rather than tracked separately, same as upstream WeakAuras2) and the
-- clicked row, walking S.buildRows()'s current *rendered* output. That means
-- a search filter narrows what a range can span for free (filtered rows
-- never appear in buildRows), and a range can never cross a group boundary
-- (anchor and target must share .parent) -- both match upstream's own
-- shift-range-select rules. Adds to the existing selection rather than
-- replacing it, so ctrl-click picks plus a trailing shift-click compose.
function S.selectRange(id)
local anchor = S.selection[table.getn(S.selection)]
if not anchor or anchor == id then
S.setSelection(id)
return
end
local anchorData = WeakestAurasDB.displays[anchor]
local targetData = WeakestAurasDB.displays[id]
if not anchorData or not targetData or anchorData.parent ~= targetData.parent then return end
local rows = S.buildRows()
local anchorIdx, targetIdx
for i = 1, table.getn(rows) do
if rows[i].id == anchor then anchorIdx = i end
if rows[i].id == id then targetIdx = i end
end
if not anchorIdx or not targetIdx then return end
if anchorIdx > targetIdx then anchorIdx, targetIdx = targetIdx, anchorIdx end
for i = anchorIdx, targetIdx do
local entry = rows[i]
local rowData = WeakestAurasDB.displays[entry.id]
if rowData and rowData.parent == anchorData.parent and not WA.IsGroup(rowData)
and not S.isSelected(entry.id) then
table.insert(S.selection, entry.id)
end
end
S.applySelectionChange()
end
-- Right-click menu for an active multi-selection. Both group entries are
-- refused when every member already shares one dynamic-group parent, since a
-- dynamic group lays out its own children and cannot own a nested group -- the
-- new group would silently land at top level instead. Upstream's other disabled
-- rule (a selected member being itself a group) is unreachable here: a group id
-- never enters a multi-member S.selection, see S.leafDescendants.
function S.showBulkMenu(row)
local parent = S.commonParent()
local nestBlocked = (parent and not WA.CanPlaceAura("group", parent)) and true or nil
S.menu.Open({
{ text = "Add to new Group", disabled = nestBlocked,
onClick = function() S.groupSelection("group") end },
{ text = "Add to new Dynamic Group", disabled = nestBlocked,
onClick = function() S.groupSelection("dynamicgroup") end },
{ text = "Duplicate All", onClick = S.duplicateSelection },
{ separator = true },
{ text = "Delete Selected", confirm = true, onClick = S.deleteSelection },
}, row)
end
-- Opens the inline rename box on whichever painted row currently holds `id`.
-- Rows are pooled and rebound, so the row a menu was opened over is not
-- guaranteed to still hold the aura the menu was built for.
function S.renameById(id)
for i = 1, table.getn(S.rows or {}) do
local row = S.rows[i]
if row.id == id and row:IsShown() then
S.beginRename(row)
return
end
end
end
-- Copies one part of `source` onto `dest` (upstream's copyAuraPart). Deep at
-- both ends -- copy and paste alike -- or the two auras end up sharing one
-- physical triggers table and an edit to either shows up in both.
function S.copyAuraPart(source, dest, part)
local all = (part == "all")
if part == "display" or all then
for k, v in pairs(source) do
if not S.COPY_IGNORE[k] then dest[k] = WA.DeepCopy(v) end
end
end
if WA.IsGroup(source) then return end
if part == "trigger" or all then dest.triggers = WA.DeepCopy(source.triggers) end
if part == "condition" or all then dest.conditions = WA.DeepCopy(source.conditions) end
if part == "action" or all then dest.actions = WA.DeepCopy(source.actions) end
if part == "load" or all then dest.load = WA.DeepCopy(source.load) end
end
-- S.clipboard holds the last copied part: { part, paste text, a snapshot of the
-- source }. Session-scoped and never written to WeakestAurasDB -- upstream's is
-- a file-local table too, and a settings copy is a gesture within one sitting.
function S.copySettings(data, part, pasteText)
S.clipboard = { part = part, text = pasteText, source = WA.DeepCopy(data) }
end
-- Pasting a leaf's settings onto a group applies them to every leaf under it
-- (upstream does the same); anything else pastes onto the one aura.
-- MergeDefaults first, since the pasted part may predate a field the target's
-- type now expects.
function S.pasteSettings(id)
local clip = S.clipboard
local data = WeakestAurasDB.displays[id]
if not clip or not data then return end
local targets = { id }
if not WA.IsGroup(clip.source) and WA.IsGroup(data) then
targets = S.leafDescendants(id)
end
for i = 1, table.getn(targets) do
local target = WeakestAurasDB.displays[targets[i]]
if target then
S.copyAuraPart(clip.source, target, clip.part)
WA.MergeDefaults(target)
WA.Add(target)
end
end
S.refreshTabContent()
end
-- The per-aura right-click menu, built from that aura's own data: a leaf, a
-- child and a group each get a different list. `anchor` only positions the menu
-- when the client gives no cursor position, so a repaint rebinding that row
-- between the click and a pick costs nothing.
function S.showAuraMenu(id, anchor)
local data = WeakestAurasDB.displays[id]
if not data then return end
local isGroup = WA.IsGroup(data)
-- Same leaf/group categories the Info tab's Region type dropdown offers,
-- less the type the aura already is.
local convert = {}
local types = WA.RegionTypeList(isGroup)
for i = 1, table.getn(types) do
local regionType = types[i]
if regionType ~= data.regionType then
table.insert(convert, {
text = REGION_TYPE_LABELS[regionType] or regionType,
onClick = function() S.convertRegionType(data, regionType) end,
})
end
end
local items = { { text = "Rename", onClick = function() S.renameById(id) end } }
local parts = isGroup and S.COPY_PARTS_GROUP or S.COPY_PARTS_LEAF
local copyItems = {}
for i = 1, table.getn(parts) do
local entry = parts[i]
table.insert(copyItems, {
text = entry.text,
onClick = function() S.copySettings(data, entry.part, entry.paste) end,
})
end
table.insert(items, { text = "Copy settings", submenu = copyItems })
-- Paste appears only once something has been copied, and never from a group
-- onto a leaf -- a group's settings have nothing a leaf can take.
local clip = S.clipboard
if clip and not (WA.IsGroup(clip.source) and not isGroup) then
table.insert(items, { text = clip.text, onClick = function() S.pasteSettings(id) end })
end
if table.getn(convert) > 0 then
table.insert(items, { text = "Convert to", submenu = convert })
end
table.insert(items, {
text = "Duplicate",
onClick = function()
local newId = WA.DuplicateAura(id)
if newId then S.setSelection(newId) end
end,
})
table.insert(items, { text = "Export...", onClick = function() S.openExport(id) end })
-- Shift-clicking a row with the editbox already open is undiscoverable, and
-- from here the editbox can be opened for the user instead.
table.insert(items, { text = "Link to Chat", onClick = function() WA.Comm.LinkAura(id) end })
if data.parent then
table.insert(items, {
text = "Ungroup",
onClick = function()
WA.RemoveChildFromGroup(id)
S.refreshList()
S.refreshTabContent()
end,
})
end
table.insert(items, { separator = true })
-- Delete promotes a group's children; the cascading variant is the entry
-- below it, offered only where the distinction exists.
table.insert(items, {
text = "Delete", confirm = true,
onClick = function()
WA.DeleteAura(id)
S.clearSelection()
end,
})
if isGroup then
table.insert(items, {
text = "Delete children and group", confirm = true,
onClick = function()
WA.DeleteAuraTree(id)
S.clearSelection()
end,
})
end
S.menu.Open(items, anchor)
end
-- Inline rename: which row currently has its box open, and the aura it was
-- opened *for*. Both are needed because rows are pooled by slot and rebound on
-- every repaint -- the captured id is re-checked at commit so an open box can
-- never rename whatever aura happens to occupy its slot by then.
S.renameRow, S.renameId = nil, nil
function S.beginRename(row)
if not row or not row.id then return end
S.closeRename()
S.renameRow, S.renameId = row, row.id
row.title:Hide()
row.rename:SetText(row.id)
row.rename:Show()
row.rename:SetFocus()
row.rename:HighlightText()
end
function S.closeRename()
local row = S.renameRow
S.renameRow, S.renameId = nil, nil
if not row then return end
row.rename:Hide()
row.rename:ClearFocus()
row.title:Show()
end
-- Enter. An empty name, an unchanged one, or one already taken reverts in
-- silence rather than erroring -- upstream does the same, and there is nowhere
-- on a list row to put an error message.
function S.commitRename()
local row, id = S.renameRow, S.renameId
if not row then return end
local text = S.trim(row.rename:GetText() or "")
S.closeRename()
if not id or row.id ~= id then return end
if text == "" or text == id or WeakestAurasDB.displays[text] then return end
if WA.RenameAura(id, text) then S.setSelection(text) end
end
-- The toolbar's New: creates against whatever is picked, so a picked group takes
-- the new aura as a child and a picked leaf gets it as its next sibling
-- (WA.PlaceAura decides which). Expands the owning group before selecting, since
-- a child dropped into a collapsed group is invisible and reads as New having
-- done nothing. Returns nil for a placement the target refuses.
function S.createAura(regionType)
local data = WA.NewAura(regionType, S.primaryId())
if not data then return nil end
if data.parent then S.expanded[data.parent] = true end
S.setSelection(data.id)
return data
end
-- The parent every selected aura shares, or nil when they disagree (or are all
-- top-level) -- the two cases behave identically everywhere this is read.
function S.commonParent()
local first = WeakestAurasDB.displays[S.selection[1]]
local parent = first and first.parent
for i = 2, table.getn(S.selection) do
local data = WeakestAurasDB.displays[S.selection[i]]
if not data or data.parent ~= parent then return nil end
end
return parent
end
-- "Add to new Group"/"Add to new Dynamic Group" (the bulk menu): reuses the
-- same Groups primitives a manual drag-into-group would (WA.NewAura/
-- WA.AddChildToGroup) applied to every selected leaf instead of one. Nests the
-- new group inside the selection's shared parent if there is one (append --
-- controlledChildren order isn't otherwise meaningful yet beyond what the
-- user last dragged) and that parent will take a group; otherwise the new group
-- lands at top level, same as WA.NewAura's own default. Selects the new group
-- afterward so the user is looking at what they just made, matching upstream's
-- own habit.
function S.groupSelection(regionType)
regionType = regionType or "group"
local n = table.getn(S.selection)
local commonParent = S.commonParent()
local group = WA.NewAura(regionType)
if not group then return end
if commonParent and WA.CanPlaceAura(regionType, commonParent) then
WA.AddChildToGroup(commonParent, group.id)
end
for i = 1, n do
WA.AddChildToGroup(group.id, S.selection[i])
end
S.setSelection(group.id)
end
-- "Duplicate All" (the bulk menu). Selects the batch of copies afterward, so
-- the obvious next gesture -- dragging them somewhere -- acts on the new ones.
function S.duplicateSelection()
local made = {}
for i = 1, table.getn(S.selection) do
local newId = WA.DuplicateAura(S.selection[i])
if newId then table.insert(made, newId) end
end
S.selection = made
S.applySelectionChange()
end
-- "Delete Selected" (the bulk menu): always leaf-only since groups
-- can never be selection members, so unlike the single Delete button
-- there's no cascade-vs-ungroup ambiguity to resolve here -- every call is
-- a plain leaf delete.
function S.deleteSelection()
for i = 1, table.getn(S.selection) do
WA.DeleteAura(S.selection[i])
end
S.clearSelection()
end
-- Re-types an aura in place, shared by the Info tab's Region type dropdown and
-- the context menu's "Convert to". MergeDefaults fills whatever the new type
-- needs; the tab has to be rebuilt because a different type offers different
-- fields.
function S.convertRegionType(data, regionType)
data.regionType = regionType
WA.MergeDefaults(data)
WA.Add(data)
S.updateTabAvailability()
S.refreshTabContent()
end
function S.getInfoOptions(data)
local fields = {
{ type = "header", name = data.id },
{
type = "input", name = "Rename",
get = function() return data.id end,
set = function(v)
v = S.trim(v)
if WA.RenameAura(data.id, v) then S.setSelection(v) end
end,
},
{
type = "select", name = "Region type",
-- Filtered to data's own leaf/group category -- see WA.RegionTypeList's
-- comment for why converting across that boundary isn't offered here.
values = WA.RegionTypeList(WA.IsGroup(data)),
labels = REGION_TYPE_LABELS,
get = function() return data.regionType end,
set = function(v) S.convertRegionType(data, v) end,
},
-- data.uid is deliberately not shown: it's an internal identity for
-- import/export and cross-references, not something the user acts on
-- (upstream never surfaces it in the Information tab either).
{ type = "button", name = "Export", onClick = function() S.openExport(data.id) end },
}
-- The only way to promote a grouped aura back to top level: the tree's
-- drag-and-drop only ever reorders within the dragged item's current
-- parent (see buildPanel's trackDrag) since a drop boundary between two
-- different parents' rows is genuinely ambiguous once groups interleave
-- their children into the flat list -- dropping *onto* a group row is
-- the unambiguous way in, this button is the unambiguous way out.
if data.parent then
table.insert(fields, {
type = "button", name = "Ungroup (remove from \"" .. data.parent .. "\")",
onClick = function()
WA.RemoveChildFromGroup(data.id)
S.refreshList()
S.refreshTabContent()
end,
})
end
-- Two-click confirm, the same morph-then-revert affordance the bulk menu
-- uses. poolButton binds this field's onClick as the button's own OnClick
-- script, so `this` is the button and the confirming flag can live on it;
-- a tab repaint resets the label, which is the wanted behaviour -- a
-- half-confirmed delete should not survive switching away and back.
table.insert(fields, {
type = "button", name = "Delete",
onClick = function()
if this.confirming then
this.confirming = nil
WA.DeleteAura(data.id)
S.clearSelection()
else
this.confirming = true
this.label:SetText("Confirm?")
S.scheduleUnconfirm(this)
end
end,
})
return fields
end
-- ---------------------------------------------------------------------------
-- Collapse state for the Display/Trigger tabs' collapsible sections. Session-
-- only (upstream keeps it out of saved data too) and keyed by aura id as well
-- as section key, so one aura's folded triggers don't carry over onto the next
-- aura's. Collapsing itself is done by the field-list generators below --
-- a folded section simply omits its body fields -- so BuildOptions only ever
-- paints the arrow/delete affordance.
-- ---------------------------------------------------------------------------
S.collapsed = {}
function S.isCollapsed(data, key, default)
local v = S.collapsed[data.id .. "::" .. key]
if v == nil then return default end
return v
end
function S.setCollapsed(data, key, v)
S.collapsed[data.id .. "::" .. key] = v
end
-- Drops every collapse entry in a namespace ("trigger:"/"sub:") for this aura.
-- Removing an entry renumbers everything after it, so the saved fold states
-- would otherwise land on the wrong sections; resetting the namespace is the
-- honest answer for state that only lives for the session anyway.
function S.clearCollapsed(data, prefix)
local full = data.id .. "::" .. prefix
local n = string.len(full)
for k in pairs(S.collapsed) do
if string.sub(k, 1, n) == full then S.collapsed[k] = nil end
end
end
-- Makes every plain header in `fields` collapsible, returning a new array with
-- folded sections' bodies dropped. For generated sections whose generator has
-- no fold state of its own (Regions.lua's Icon/Size/Position, which are the
-- same shape for every region type) -- a header that already carries
-- `collapsed`/`onDelete` manages itself and is passed through untouched.
-- A section runs from its header to the next one, so nesting isn't expressible
-- here; the generators that need it (triggers, display effects) build their own
-- headers instead. `prefix` namespaces the keys so two tabs' identically-named
-- sections don't share one fold state.
function S.collapsibleSections(fields, data, prefix)
local out, folded = {}, false
for i = 1, table.getn(fields) do
local f = fields[i]
if f.type == "header" then
folded = false
if f.collapsed ~= nil or f.onDelete then
table.insert(out, f)
else
local key = prefix .. (f.name or tostring(i))
local collapsed = S.isCollapsed(data, key, false)
folded = collapsed
-- Copied, not mutated: the generator may hand back a table it
-- reuses, and fold state is ours rather than its.
local hdr = {}
for k, v in pairs(f) do hdr[k] = v end
hdr.collapsed = collapsed
hdr.onToggle = function()
S.setCollapsed(data, key, not collapsed)
S.refreshTabContent()
end
table.insert(out, hdr)
end
elseif not folded then
table.insert(out, f)
end
end
return out
end
-- Deep-copies a subregion type's `default` so a newly-added instance never
-- shares a table field (e.g. a colour array) with the registry default or
-- another instance.
local function copySubDefault(v)
if type(v) ~= "table" then return v end
local out = {}
for k, vv in pairs(v) do out[k] = copySubDefault(vv) end
return out
end
-- Appends the sub-region editor ("Display Effects") onto the Display tab's field
-- list (matching upstream, where subtext/border/glow live under Display, not a
-- separate tab): one block per instance in data.subRegions rendered from its own
-- spec.options field array, plus per-type add and per-instance remove. Walks
-- WA.subRegionTypes so a newly-registered subregion type shows up here for free;
-- the "+ Add" list is filtered to types that support this region type. Each
-- block's closures capture their own `sub`/`idx` locals (fresh per loop
-- iteration in Lua); per-field edits route through WA.Add (the live region
-- rebuilds its subregions via modifyFinish), structural add/remove additionally
-- re-render the tab.
function S.appendDisplayEffectsOptions(fields, data)
table.insert(fields, { type = "header", name = "Display Effects" })
local subs = data.subRegions or {}
for i = 1, table.getn(subs) do
local sub = subs[i]
local idx = i
local spec = WA.subRegionTypes[sub.type]
if spec and spec.options then
local label = (spec.displayName or sub.type) .. " " .. idx
local key = "sub:" .. idx
-- Unfolded by default. Upstream folds subregions instead
-- (DisplayOptions.lua's __collapsed = true), but it can afford to:
-- its Display tab is long enough that an effect's fields are clearly
-- more content below, whereas here they're most of the tab, and a
-- column of collapsed headers reads as an empty page.
local collapsed = S.isCollapsed(data, key, false)
table.insert(fields, {
type = "header", name = label, collapsed = collapsed,
onToggle = function()
S.setCollapsed(data, key, not collapsed)
S.refreshTabContent()
end,
onDelete = function()
table.remove(data.subRegions, idx)
S.clearCollapsed(data, "sub:")
WA.Add(data)
S.refreshTabContent()
end,
})
if not collapsed then
local typeFields = spec.options(data, sub, idx)
for j = 1, table.getn(typeFields) do
table.insert(fields, typeFields[j])
end
end
end
end
-- One drop button covering every subregion type that supports this region
-- type, in stable display order -- the alternative is a stack of near-
-- identical "+ Add X" buttons that grows with each new subregion type.
local addable, addLabels = {}, {}
for name, spec in pairs(WA.subRegionTypes) do
if spec.options and (not spec.supports or spec.supports(data.regionType)) then
table.insert(addable, name)
addLabels[name] = spec.displayName or name
end
end
table.sort(addable)
if table.getn(addable) > 0 then
table.insert(fields, {
type = "menu", name = "+ Add Display Effect", values = addable, labels = addLabels,
onSelect = function(name)
local spec = WA.subRegionTypes[name]
if not spec then return end
data.subRegions = data.subRegions or {}
local default = spec.defaultFor and spec.defaultFor(data.regionType) or spec.default
table.insert(data.subRegions, copySubDefault(default))
-- Open the one just added -- it's what the user is about to edit.
S.setCollapsed(data, "sub:" .. table.getn(data.subRegions), false)
WA.Add(data)
S.refreshTabContent()
end,
})
end
-- The %c function is one per *aura*, shared by the region's own text and
-- every subtext of it, so its editor is one block here rather than a copy
-- inside each text's section -- upstream shows one subtext at a time and can
-- afford to repeat it; this page shows them all at once. Empty until
-- something in the aura's text references %c.
local customText = WA.regionPrototype.CustomTextOptionFields(data)
for i = 1, table.getn(customText) do table.insert(fields, customText[i]) end
end
-- ---------------------------------------------------------------------------
-- Conditions tab: edits data.conditions -- each is a check
-- (trigger/variable/op/value), optionally an AND/OR tree, plus a list of
-- property changes. The vocabulary (checkable variables, changeable
-- properties) is pulled from the engine (WA.GetConditionTemplates /
-- WA.GetProperties / WA.globalConditions) so a new trigger or region property
-- shows up here for free. Structural edits (add/remove a condition, nested
-- check or change, or switching a variable/property whose type changes the
-- value widget) re-render the tab via S.refreshTabContent.
-- ---------------------------------------------------------------------------
function S.sortedKeys(map)
local out = {}
for k in pairs(map) do table.insert(out, k) end
table.sort(out)
return out
end
-- (type, template) for a check's variable: a combination pseudo-trigger at
-- -2, a global condition at -1, otherwise the trigger's condition template.
-- Defaults keep the editor functional even if a saved variable no longer exists.
local CONDITION_COMBINATION_VALUES = { "AND", "OR" }
local CONDITION_COMBINATION_LABELS = { AND = "All of", OR = "Any of" }
function S.conditionVarType(check, templates)
if check.trigger == -2 then
return "combination", nil
elseif check.trigger == -1 then
local g = WA.globalConditions[check.variable]
return g and g.type or "bool", g
end
local t = templates and templates[check.trigger]
local v = t and t[check.variable]
return v and v.type or "number", v
end
-- Sorted variable keys + display labels for a trigger (or the global set).
function S.conditionVariableList(trigger, templates)
if trigger == -2 then
return CONDITION_COMBINATION_VALUES, CONDITION_COMBINATION_LABELS
end
local map = (trigger == -1) and WA.globalConditions or ((templates and templates[trigger]) or {})
local vals = S.sortedKeys(map)
local labels = {}
for i = 1, table.getn(vals) do labels[vals[i]] = (map[vals[i]] and map[vals[i]].display) or vals[i] end
return vals, labels
end
-- Sensible starting op+value when a check's variable (hence its type) changes.
function S.defaultOpValue(vtype, template)
if vtype == "timer" or vtype == "elapsedTimer" then return "<", 5
elseif vtype == "number" then return ">=", 1
elseif vtype == "bool" then return "==", true
elseif vtype == "select" then return "==", (template and template.values and template.values[1]) or ""
elseif vtype == "combination" then return nil, nil end
return "==", ""
end
function S.defaultPropertyValue(pentry)
if not pentry then return nil end
if pentry.type == "bool" then return true
elseif pentry.type == "color" then return { 1, 0, 0, 1 }
elseif pentry.type == "number" then return pentry.min or 0
elseif pentry.type == "list" then return pentry.default
elseif pentry.type == "icon" then return pentry.default or "" end
return nil
end
-- Appends the op/value editor for a check, picking the widget by variable type.
local function appendCheckValue(fields, data, check, vtype, template, indent)
if vtype == "combination" then return end
if vtype == "number" or vtype == "timer" or vtype == "elapsedTimer" then
local label = vtype == "timer" and "Remaining (s)"
or (vtype == "elapsedTimer" and "Elapsed (s)" or "Value")
table.insert(fields, {
type = "opnumber", name = label, indent = indent,
getOp = function() return check.op or ">=" end,
setOp = function(v) check.op = v; WA.Add(data) end,
getVal = function() return check.value end,
setVal = function(v) check.value = v; WA.Add(data) end,
})
elseif vtype == "bool" then
table.insert(fields, {
type = "toggle", name = "Is true", indent = indent,
get = function() return check.value == true or check.value == "true" end,
set = function(v) check.op = "=="; check.value = v and true or false; WA.Add(data) end,
})
elseif vtype == "select" then
table.insert(fields, {
type = "select", name = "Value", indent = indent,
values = (template and template.values) or {},
get = function() return check.value end,
set = function(v) check.op = "=="; check.value = v; WA.Add(data) end,
})
else -- string
table.insert(fields, {
type = "select", name = "Op", half = true, indent = indent,
values = { "==", "~=" },
get = function() return check.op or "==" end,
set = function(v) check.op = v; WA.Add(data) end,
})
table.insert(fields, {
type = "input", name = "Value", half = true, indent = indent,
get = function() return check.value end,
set = function(v) check.value = v; WA.Add(data) end,
})
end
end
-- Appends the value widget for one property change, picked by property type.
local function appendChangeValue(fields, data, change, pentry)
local ptype = pentry and pentry.type
if pentry and pentry.action then
if ptype == "sound" and WA.ActionSoundFields then
local value = change.value or {}
WA.ActionSoundFields(fields, data, value, true)
elseif ptype == "chat" and WA.ActionMessageFields then
local value = change.value or {}
WA.ActionMessageFields(fields, data, value, "condition", true)
elseif ptype == "customcode" then
table.insert(fields, { type = "code", height = 80, name = "Custom Code",
get = function() return change.value end,
set = function(v) change.value = v; WA.Add(data) end })
elseif ptype == "glowexternal" then
table.insert(fields, { type = "select", name = "Glow Action", values = { "show", "hide" },
get = function() return change.value and change.value.glow_action or "show" end,
set = function(v) change.value = change.value or {}; change.value.glow_action = v; WA.Add(data) end })
table.insert(fields, { type = "select", name = "Frame Type", values = { "PARENTFRAME", "FRAMESELECTOR" },