-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathActionBar.lua
More file actions
1493 lines (1371 loc) · 75.7 KB
/
Copy pathActionBar.lua
File metadata and controls
1493 lines (1371 loc) · 75.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- ============================================================
-- ActionBar.lua — small standalone action bar
--
-- A tiny, movable bar of bot-command buttons that lives outside the main window.
-- v1: Summon + a Passive toggle, both mirroring the Manage tab's party/raid
-- (broadcast) behavior. Shown/hidden via the minimap right-click or a Settings
-- checkbox (persisted). Edit mode (shift-right-click the minimap, or a Settings
-- checkbox; SESSION-only) disables the buttons and lets the bar be dragged so it
-- snaps/anchors to nearby visible frames.
--
-- Both commands are plain chat commands, so these are ordinary insecure buttons —
-- no SecureActionButton/taint concerns; everything works in combat. The bar is
-- BUILT at PLAYER_LOGIN (not file load) so ElvUI detection + theming have run.
-- ============================================================
local NS = CleanBotNS
local BTN = 32 -- button size
local PAD = 6 -- bar inner padding
local GAP = 4 -- gap between buttons
local SNAP_DIST = 20 -- px: how close an edge must be to a frame to ACQUIRE a snap
local SNAP_RELEASE = 40 -- px: how far past the snap the bar must move to RELEASE it (hysteresis)
local SNAP_GAP = 2 -- px gap left when snapped flush to a frame's edge
-- State (assigned by the build; setters guard on `bar` so they're safe pre-build).
local bar, overlay, snapHighlight, configFrame
local barButtons = {} -- the bar's buttons (enabled slots in order); laid out by CB_LayoutBar
local slotBtns = {} -- slot id → bar button frame (all created once from the registry)
local pendingRunawayRevert -- key → { name, prev } captured on Runaway; restored on combat end
-- Persisted button layout (merged with the registry at load): slot order + per-flyout child order, plus
-- disabled sets. Built by CB_LoadLayout, consumed by CB_RebuildBar, written by CB_SaveLayout.
local barLayout = { slotOrder = {}, slotOff = {}, childOrder = {}, childOff = {} }
-- Effective on-screen opacity: the product of the frame's own alpha and all its ancestors'. A frame
-- can be IsVisible() (shown, in a shown parent chain) yet faded to nothing — ElvUI does this to its
-- action bars on mouseover-fade — so we treat near-zero effective alpha as "not really shown".
local function CB_EffectiveAlpha(f)
local a = 1
while f and f.GetAlpha do
a = a * (f:GetAlpha() or 1)
if a <= 0.05 then return a end
f = f:GetParent()
end
return a
end
-- Calls fn(frame, name) for each snap-candidate: a named, visible (shown AND not faded out),
-- reasonably-sized frame. Two sources: direct children of UIParent / ElvUIParent, plus oUF's spawned
-- unit frames (ElvUF_Player, target, party/raid, …). The latter are reparented under their movers —
-- grandchildren of UIParent — so a child scan misses them; oUF tracks them all in ElvUF.objects.
-- Named so the chosen anchor survives a reload (re-found via _G[name]); size-bounded to skip
-- full-screen containers; the bar and its own highlight are excluded.
local function CB_ForEachSnapCandidate(fn)
local maxW, maxH = UIParent:GetWidth() * 0.9, UIParent:GetHeight() * 0.9
local seen = {}
local function tryFrame(f)
if not f or seen[f] then return end
local name = f.GetName and f:GetName()
-- Skip tooltips: they're transient, and GameTooltip is anchored to the bar while its edit
-- tooltip shows (SetOwner), so snapping TO it would be a dependency cycle.
if not (name and f ~= bar and f ~= snapHighlight and f ~= configFrame and not name:find("Tooltip")) then return end
if not (f.IsVisible and f:IsVisible() and f.GetLeft and f:GetLeft()) then return end
if CB_EffectiveAlpha(f) <= 0.05 then return end
local w, h = f:GetWidth(), f:GetHeight()
if not (w and h and w >= 16 and h >= 16 and w <= maxW and h <= maxH) then return end
seen[f] = true
fn(f, name)
end
local roots = { UIParent }
if _G.ElvUIParent then roots[#roots + 1] = _G.ElvUIParent end
for _, root in ipairs(roots) do
for _, f in ipairs({ root:GetChildren() }) do tryFrame(f) end
end
-- oUF unit frames (ElvUI's instance is ElvUF; bare oUF as a fallback). seen[] dedups any that are
-- also direct children so they aren't considered twice.
local function addOUF(ouf)
if type(ouf) == "table" and type(ouf.objects) == "table" then
for _, f in ipairs(ouf.objects) do tryFrame(f) end
end
end
addOUF(_G.ElvUF)
addOUF(_G.oUF)
end
NS.actionBarShown = false -- persisted in CleanBot_SavedVars.actionBarShown
NS.actionBarEditMode = false -- session-only
NS.actionBarSnap = true -- persisted in CleanBot_SavedVars.actionBarSnap; gates edge snapping
-- Reflects group passive state: the icon is saturated (full color) when any bot is passive,
-- desaturated (grayed) otherwise. Registered as a command refresher so it tracks the Manage
-- tab's checkbox and co? replies.
local function refreshPassiveBtn()
local b = slotBtns.passive
if not b then return end
b.icon:SetDesaturated(not NS.CB_GetGroupPassive())
end
-- ── Movement flyout (Follow / Runaway / Stay) ────────────────────────────────
-- The affected/displayed movement context follows the PLAYER's combat state: in combat → entry.combat,
-- else entry.nonCombat. A button is lit when its movement is the active value for that context (the
-- "any member" rule, like Passive). Runaway sets the "runaway" strategy in the COMBAT context, with
-- the previous combat movement restored automatically when combat ends.
local function playerCombatSection()
return UnitAffectingCombat("player") and "combat" or "nonCombat"
end
-- Desaturates the movement host (its current main action) and each visible flyout child by whether its
-- action's movement strategy is the group's active value for the current combat context. Works whichever
-- movement action is currently the main (they're promotable).
local function refreshMoveBtns()
local m = slotBtns.movement
if not m or not m.childFrames then return end
local section = playerCombatSection()
if m._action and m._action.moveField then
m.icon:SetDesaturated(not NS.CB_GroupMovementActive(section, m._action.moveField))
end
for _, cf in ipairs(m.childFrames) do
if cf:IsShown() and cf._action and cf._action.moveField then
cf.icon:SetDesaturated(not NS.CB_GroupMovementActive(section, cf._action.moveField))
end
end
end
-- An explicit Follow/Stay choice supersedes a pending Runaway revert.
local function cancelRunawayRevert() pendingRunawayRevert = nil end
-- Follow / Stay: set the group's movement for the player-combat-state context.
local function setMovement(field)
cancelRunawayRevert()
NS.CB_SetGroupMovement(playerCombatSection(), field)
NS.CB_RefreshCommands()
end
-- Runaway: set the group's COMBAT movement to Run Away, remembering each member's previous combat
-- movement (only on the first press, so repeats don't overwrite the original) for restore on combat end.
local function runawayMovement()
pendingRunawayRevert = pendingRunawayRevert or {}
if NS.CB_ForEachGroupMember then
NS.CB_ForEachGroupMember(function(_, name)
local key = name and strlower(name)
local e = key and CleanBot_PartyBots[key]
if e and pendingRunawayRevert[key] == nil then
local cur = false -- false = "Free Roam" (no movement strategy set)
if e.combat then
for _, m in ipairs(NS.MOVEMENT_STRATEGIES or {}) do
if e.combat[m.field] then cur = m.field; break end
end
end
pendingRunawayRevert[key] = { name = name, prev = cur }
end
end)
end
NS.CB_SetGroupMovement("combat", "mRunaway")
NS.CB_RefreshCommands()
end
-- On combat end: restore each remembered bot's previous combat movement (per-member — they may differ).
local function revertRunaway()
if not pendingRunawayRevert then return end
for key, info in pairs(pendingRunawayRevert) do
local e = CleanBot_PartyBots[key]
if e then
local field = info.prev or nil -- false → nil (Free Roam, clears all five)
e.combat = e.combat or {}
for _, m in ipairs(NS.MOVEMENT_STRATEGIES or {}) do e.combat[m.field] = (m.field == field) end
NS.CB_SendBotCommand(info.name, "co " .. NS.CB_MovementToggleString(field))
end
end
pendingRunawayRevert = nil
NS.CB_RefreshCommands()
end
local function applyVisibility()
if not bar then return end
if NS.actionBarShown or NS.actionBarEditMode then bar:Show() else bar:Hide() end
end
-- ── Anchor model (position + grow-from corner) ───────────────────────────────
-- The bar is anchored BY one of its four corners (`growFrom`) to the SAME-named corner of `relTo`,
-- offset by (x, y). Pinning a corner means that corner stays put if the bar's size changes (e.g. more
-- buttons) — the bar grows away from it. x/y are screen-axis offsets: +x right, +y up, at any corner.
local CORNERS = { "BOTTOMLEFT", "BOTTOMRIGHT", "TOPLEFT", "TOPRIGHT" }
local CORNER_LABEL = {
BOTTOMLEFT = "Bottom Left", BOTTOMRIGHT = "Bottom Right",
TOPLEFT = "Top Left", TOPRIGHT = "Top Right",
}
-- Grow direction: the axis/way the bar's buttons flow out from the anchored corner.
local GROW_DIRS = { "UP", "DOWN", "LEFT", "RIGHT" }
local DIR_LABEL = { UP = "Up", DOWN = "Down", LEFT = "Left", RIGHT = "Right" }
-- Default: top-right of the screen (20px in, 220px down), pinned by its top-right corner; buttons flow
-- to the right.
local anchor = { relTo = "UIParent", growFrom = "TOPRIGHT", x = -20, y = -220, growDir = "RIGHT" }
-- Overlap area of two screen rects (0 if disjoint). Shared by the flyout + config placement logic.
local function CB_RectOverlap(al, ab, ar, at, bl, bb, br, bt)
local ix = math.max(0, math.min(ar, br) - math.max(al, bl))
local iy = math.max(0, math.min(at, bt) - math.max(ab, bb))
return ix * iy
end
-- Config-frame helpers (defined with the build, used during drag). Forward-declared.
local CB_PositionConfig, CB_RefreshConfig
-- Screen coords of `corner` ("TOPLEFT"…) of a frame: x picks the right edge for *RIGHT, else left; y
-- picks the top edge for TOP*, else bottom.
local function CB_FrameCorner(f, corner)
local l, b, w, h = f:GetLeft(), f:GetBottom(), f:GetWidth(), f:GetHeight()
if not (l and b and w and h) then return nil end
return l + (corner:find("RIGHT") and w or 0), b + (corner:find("TOP") and h or 0)
end
-- Re-pins the bar from the current `anchor`. Safe before build (guards on bar).
local function CB_ApplyAnchor()
if not bar then return end
bar:ClearAllPoints()
bar:SetPoint(anchor.growFrom, _G[anchor.relTo] or UIParent, anchor.growFrom, anchor.x, anchor.y)
end
local function CB_SaveAnchor()
if CleanBot_SavedVars then
CleanBot_SavedVars.actionBarAnchor = {
relTo = anchor.relTo, growFrom = anchor.growFrom,
x = anchor.x, y = anchor.y, growDir = anchor.growDir,
}
end
end
-- Lays out the bar's buttons (in flow order) for the current grow direction and sizes the bar to fit.
-- Horizontal directions make a wide bar; vertical ones a tall bar. The anchored corner stays pinned
-- across the resize because the bar is anchored BY that corner (see CB_ApplyAnchor).
local function CB_LayoutBar()
if not bar or #barButtons == 0 then return end
local dir = anchor.growDir or "RIGHT"
local n = #barButtons
local horiz = (dir == "RIGHT" or dir == "LEFT")
local along = n * BTN + (n - 1) * GAP
bar:SetSize(
horiz and (PAD + along + PAD) or (PAD + BTN + PAD),
horiz and (PAD + BTN + PAD) or (PAD + along + PAD))
for i, b in ipairs(barButtons) do
local off = (i - 1) * (BTN + GAP)
b:ClearAllPoints()
if dir == "RIGHT" then
b:SetPoint("TOPLEFT", bar, "TOPLEFT", PAD + off, -PAD)
elseif dir == "LEFT" then
b:SetPoint("TOPRIGHT", bar, "TOPRIGHT", -(PAD + off), -PAD)
elseif dir == "DOWN" then
b:SetPoint("TOPLEFT", bar, "TOPLEFT", PAD, -(PAD + off))
else -- UP
b:SetPoint("BOTTOMLEFT", bar, "BOTTOMLEFT", PAD, PAD + off)
end
end
end
-- Recomputes anchor.x/y so the bar keeps its CURRENT screen position under the current relTo+growFrom.
-- Used after a drag (relTo just changed) and after a grow-from change (so the bar doesn't jump).
local function CB_RecomputeOffsets()
local bx, by = CB_FrameCorner(bar, anchor.growFrom)
local fx, fy = CB_FrameCorner(_G[anchor.relTo] or UIParent, anchor.growFrom)
if bx and fx then anchor.x, anchor.y = bx - fx, by - fy end
end
-- ── Live drag with edge snapping (edit mode) ─────────────────────────────────
-- The bar follows the cursor; when one of its edges comes within SNAP_DIST of a candidate frame's
-- edge, that SINGLE axis locks flush to the frame (the nearest edge wins) while the other axis keeps
-- following the cursor — so the bar slides along the frame's edge. On release it anchors to whatever
-- frame it ended up snapped to (so it follows that frame), else to UIParent. Position is preserved.
local dragGrabX, dragGrabY -- cursor offset from the bar's bottom-left, captured on mouse-down
local dragSnap -- current snap { frame, edge, axis, value, dist } (nil = free)
local dragging -- true while a drag is in progress (guards double mouse-up)
-- Each snap edge-key maps to the side of the candidate frame it touches — used to draw the blue edge
-- strip. Both the flush ("left↔left") and the abutting ("bar left ↔ frame right") snaps share a side.
local EDGE_SIDE = {
xLeft = "LEFT", xAbutLeft = "LEFT",
xRight = "RIGHT", xAbutRight = "RIGHT",
yBottom = "BOTTOM", yAbutBelow = "BOTTOM",
yTop = "TOP", yAbutAbove = "TOP",
}
-- Best single-axis snap for a proposed bottom-left, within `maxDist`: { axis, edge, value, dist, frame },
-- or nil. When `onlyFrame`/`onlyEdge` are set, only that exact candidate edge is considered (sticky
-- release check) so a held snap stays on the same edge instead of jumping sides near a corner.
local function CB_BestSnap(newLeft, newBottom, maxDist, onlyFrame, onlyEdge)
local w, h = bar:GetWidth(), bar:GetHeight()
local bl, br, bb, bt = newLeft, newLeft + w, newBottom, newBottom + h
local best
CB_ForEachSnapCandidate(function(f, name)
if onlyFrame and name ~= onlyFrame then return end
local cl, cr, ct, cb = f:GetLeft(), f:GetRight(), f:GetTop(), f:GetBottom()
-- X-axis (vertical-edge) snaps require the bar to be vertically near the frame; Y the reverse.
local vNear = bb <= ct + maxDist and bt >= cb - maxDist
local hNear = bl <= cr + maxDist and br >= cl - maxDist
-- True extent overlap on the PERPENDICULAR axis marks the edge the bar is sliding along: an
-- x-edge whose vertical ranges overlap, or a y-edge whose horizontal ranges overlap. Preferred
-- over a merely-nearer edge so dragging in from the side of a short, wide frame catches its
-- left/right edge instead of its nearby top/bottom.
local vOverlap = bb < ct and bt > cb
local hOverlap = bl < cr and br > cl
local function consider(axis, edge, value, dist, near, overlap)
if onlyEdge and edge ~= onlyEdge then return end
if not (near and dist <= maxDist) then return end
if not best
or (overlap and not best.overlap)
or (overlap == best.overlap and dist < best.dist) then
best = { axis = axis, edge = edge, value = value, dist = dist, frame = name, overlap = overlap }
end
end
consider("x", "xLeft", cl, math.abs(bl - cl), vNear, vOverlap) -- left ↔ left
consider("x", "xRight", cr - w, math.abs(br - cr), vNear, vOverlap) -- right ↔ right
consider("x", "xAbutRight", cr + SNAP_GAP, math.abs(bl - cr), vNear, vOverlap) -- abut right of frame
consider("x", "xAbutLeft", cl - w - SNAP_GAP, math.abs(br - cl), vNear, vOverlap) -- abut left of frame
consider("y", "yBottom", cb, math.abs(bb - cb), hNear, hOverlap) -- bottom ↔ bottom
consider("y", "yTop", ct - h, math.abs(bt - ct), hNear, hOverlap) -- top ↔ top
consider("y", "yAbutAbove", ct + SNAP_GAP, math.abs(bb - ct), hNear, hOverlap) -- abut above frame
consider("y", "yAbutBelow", cb - h - SNAP_GAP, math.abs(bt - cb), hNear, hOverlap) -- abut below frame
end)
return best
end
-- Highlights the frame the bar is snapped to: a green wash over the whole frame, a brighter blue strip
-- on the exact edge being snapped to, and the frame's name centered on it. Passing nil hides it all.
local EDGE_THICK = 3
local function CB_UpdateSnapHighlight(snap)
if not snapHighlight then return end
local f = snap and _G[snap.frame]
if not (f and f.GetLeft and f:GetLeft()) then snapHighlight:Hide() return end
snapHighlight:ClearAllPoints()
snapHighlight:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
snapHighlight:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)
snapHighlight.label:SetText(snap.frame)
local edge, side = snapHighlight.edge, EDGE_SIDE[snap.edge]
edge:ClearAllPoints()
if side == "LEFT" then
edge:SetPoint("TOPLEFT"); edge:SetPoint("BOTTOMLEFT"); edge:SetWidth(EDGE_THICK)
elseif side == "RIGHT" then
edge:SetPoint("TOPRIGHT"); edge:SetPoint("BOTTOMRIGHT"); edge:SetWidth(EDGE_THICK)
elseif side == "TOP" then
edge:SetPoint("TOPLEFT"); edge:SetPoint("TOPRIGHT"); edge:SetHeight(EDGE_THICK)
else -- BOTTOM
edge:SetPoint("BOTTOMLEFT"); edge:SetPoint("BOTTOMRIGHT"); edge:SetHeight(EDGE_THICK)
end
snapHighlight:Show()
end
-- Per-frame drag tick: follow the cursor, then lock the one nearest snapping edge.
-- Hysteresis: while snapped, keep the SAME frame AND edge until the bar drifts past SNAP_RELEASE from
-- it; only then is a fresh snap acquired within the tighter SNAP_DIST. This stops the bar from
-- flickering between frames (or between the two edges of a corner) that sit close together.
local function CB_DragUpdate()
local scale = bar:GetEffectiveScale()
if not scale or scale == 0 then return end
local cx, cy = GetCursorPosition()
local newLeft = cx / scale - dragGrabX
local newBottom = cy / scale - dragGrabY
local snap
if NS.actionBarSnap then
if dragSnap then
snap = CB_BestSnap(newLeft, newBottom, SNAP_RELEASE, dragSnap.frame, dragSnap.edge)
end
if not snap then
snap = CB_BestSnap(newLeft, newBottom, SNAP_DIST)
end
end
dragSnap = snap
if snap then
if snap.axis == "x" then newLeft = snap.value else newBottom = snap.value end
end
bar:ClearAllPoints()
bar:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", newLeft, newBottom)
CB_UpdateSnapHighlight(snap)
-- Keep the anchor state (and the config readout) live mid-drag without persisting: provisional
-- relTo is whatever we're snapped to, else UIParent. CB_FinishDrag re-derives + saves on release.
anchor.relTo = (snap and snap.frame) or "UIParent"
CB_RecomputeOffsets()
if CB_PositionConfig then CB_PositionConfig() end
if CB_RefreshConfig then CB_RefreshConfig() end
end
-- Mouse-up: anchor to the snapped frame (so the bar follows it) or UIParent, keeping the on-screen
-- position, then persist. (Called by the shared capture frame.)
local function CB_FinishDrag()
if not dragging then return end
dragging = false
NS.CB_EndCapture()
CB_UpdateSnapHighlight(nil)
local snapFrame = dragSnap and dragSnap.frame
local f = snapFrame and _G[snapFrame]
anchor.relTo = (f and f:GetLeft()) and snapFrame or "UIParent"
CB_RecomputeOffsets() -- offsets at growFrom for the final on-screen position
CB_ApplyAnchor()
CB_SaveAnchor()
if CB_PositionConfig then CB_PositionConfig() end
if CB_RefreshConfig then CB_RefreshConfig() end
end
-- Begins dragging immediately on mouse-down (we're in edit mode, so that's fine), via the shared
-- capture frame which tracks the cursor + mouse-up anywhere on screen.
local function CB_BeginDrag()
local scale = bar:GetEffectiveScale()
if not scale or scale == 0 then return end
GameTooltip:Hide() -- drop the edit-tooltip's SetOwner anchor so re-anchoring can't cycle
local cx, cy = GetCursorPosition()
dragGrabX = cx / scale - bar:GetLeft()
dragGrabY = cy / scale - bar:GetBottom()
dragSnap = nil
dragging = true
-- The capture frame catches the mouse-up when the cursor has moved off the overlay; the
-- overlay's own OnMouseUp catches it when released in place (WoW delivers the button-up to
-- the frame that owned the button-down). CB_FinishDrag is guarded so only the first wins.
NS.CB_BeginCapture(CB_DragUpdate, CB_FinishDrag)
end
-- ── State setters (shared by the minimap and Settings, like the debug setters) ──
--- Shows/hides the action bar and persists the choice.
---@param on boolean
NS.CB_SetActionBarShown = function(on)
NS.actionBarShown = on and true or false
if CleanBot_SavedVars then CleanBot_SavedVars.actionBarShown = NS.actionBarShown end
applyVisibility()
if NS.CB_RefreshActionBarChecks then NS.CB_RefreshActionBarChecks() end
end
NS.CB_ToggleActionBar = function() NS.CB_SetActionBarShown(not NS.actionBarShown) end
--- Toggles edit mode (session only): edit forces the bar visible, swaps the overlay on (which
--- covers + disables the buttons), and is the drag handle; off restores the saved shown state.
---@param on boolean
NS.CB_SetActionBarEditMode = function(on)
NS.actionBarEditMode = on and true or false
if overlay then
if NS.actionBarEditMode then overlay:Show() else overlay:Hide() end
end
applyVisibility()
if configFrame then
if NS.actionBarEditMode then
CB_PositionConfig()
CB_RefreshConfig()
configFrame:Show()
else
configFrame:Hide()
end
end
if NS.CB_RefreshActionBarChecks then NS.CB_RefreshActionBarChecks() end
end
NS.CB_ToggleActionBarEditMode = function() NS.CB_SetActionBarEditMode(not NS.actionBarEditMode) end
-- ── Flyout geometry (shared by the single-level and nested flyouts) ──────────
-- One set of helpers so every flyout — the reorderable Attack/Movement/Release ones and the nested
-- Recruit tree — expands the same predictable way: perpendicular to its reference flow, away from the
-- bar and the snapped frame, biased toward the screen center.
-- Screen rect a flyout would occupy growing `dir` from `btn`, for `n` BTN-sized children.
local function CB_FlyoutRect(btn, dir, n)
local bl, bb = btn:GetLeft(), btn:GetBottom()
if not bl then return end
local bw, bh, len = btn:GetWidth(), btn:GetHeight(), n * (BTN + GAP)
if dir == "DOWN" then return bl, bb - len, bl + bw, bb end
if dir == "UP" then return bl, bb + bh, bl + bw, bb + bh + len end
if dir == "RIGHT" then return bl + bw, bb, bl + bw + len, bb + bh end
return bl - len, bb, bl, bb + bh -- LEFT
end
-- Pure: score a candidate flyout rect (higher = better). Priority, by magnitude of each term:
-- staying on-screen (×1000 of the off-screen edge length) ≫ not overlapping any `avoid` rect (overlap
-- AREA, so even weight 1 dwarfs the center term) ≫ sitting near the screen center (a small tiebreaker).
-- `avoid` = list of { l, b, r, t } rects. Exposed for spec/actionbar_flyout_spec.lua.
function NS.CB_ScoreFlyoutDir(l, b, r, t, avoid, sw, sh)
local off = math.max(0, -l) + math.max(0, r - sw) + math.max(0, -b) + math.max(0, t - sh)
local score = -1000 * off
for _, a in ipairs(avoid or {}) do
score = score - CB_RectOverlap(l, b, r, t, a[1], a[2], a[3], a[4])
end
local cx, cy = (l + r) / 2, (b + t) / 2
score = score - 0.5 * (math.abs(cx - sw / 2) + math.abs(cy - sh / 2))
return score
end
-- Rects every flyout dodges: the bar itself, and the frame the bar is snapped to (it sits flush beside
-- the bar). Nested flyouts append their parent flyout's rect so a child never covers its parent.
local function CB_BarAvoidRects()
local out = {}
if bar and bar:GetLeft() then out[#out + 1] = { bar:GetLeft(), bar:GetBottom(), bar:GetRight(), bar:GetTop() } end
local rf = anchor.relTo ~= "UIParent" and _G[anchor.relTo]
if rf and rf.GetLeft and rf:GetLeft() then out[#out + 1] = { rf:GetLeft(), rf:GetBottom(), rf:GetRight(), rf:GetTop() } end
return out
end
-- Best flyout direction: the side of the axis PERPENDICULAR to `refDir` (the bar's grow direction at
-- level 1, the parent flyout's flow when nested) with the best score. `n` = child count.
local function CB_ChooseFlyoutDir(btn, refDir, n, avoid)
local opts = (refDir == "UP" or refDir == "DOWN") and { "RIGHT", "LEFT" } or { "DOWN", "UP" }
local sw, sh = UIParent:GetWidth(), UIParent:GetHeight()
local best, bestScore
for _, dir in ipairs(opts) do
local l, b, r, t = CB_FlyoutRect(btn, dir, n)
if l then
local s = NS.CB_ScoreFlyoutDir(l, b, r, t, avoid, sw, sh)
if not bestScore or s > bestScore then bestScore, best = s, dir end
end
end
return best or opts[1]
end
-- (Re)anchors `flyout` + its `children` for `dir` and sizes it to fit. The near edge is flush with
-- `btn`; children are inset by GAP so the leading strip bridges the hover gap. Stamps flyout._dir
-- (the flow axis — read by shift-drag child reorder and by nested sub-flyout direction choice).
local function CB_LayoutFlyout(flyout, btn, children, dir)
flyout._dir = dir
local n = #children
if n == 0 then return end
local vertical = (dir == "UP" or dir == "DOWN")
flyout:ClearAllPoints()
flyout:SetSize(vertical and BTN or n * (BTN + GAP), vertical and n * (BTN + GAP) or BTN)
local fp = (dir == "DOWN" and "TOP") or (dir == "UP" and "BOTTOM") or (dir == "RIGHT" and "LEFT") or "RIGHT"
local bp = (dir == "DOWN" and "BOTTOM") or (dir == "UP" and "TOP") or (dir == "RIGHT" and "RIGHT") or "LEFT"
flyout:SetPoint(fp, btn, bp, 0, 0)
for i, child in ipairs(children) do
child:ClearAllPoints()
local ref = (i == 1) and flyout or children[i - 1]
if dir == "DOWN" then
child:SetPoint("TOP", ref, i == 1 and "TOP" or "BOTTOM", 0, -GAP)
elseif dir == "UP" then
child:SetPoint("BOTTOM", ref, i == 1 and "BOTTOM" or "TOP", 0, GAP)
elseif dir == "RIGHT" then
child:SetPoint("LEFT", ref, i == 1 and "LEFT" or "RIGHT", GAP, 0)
else
child:SetPoint("RIGHT", ref, i == 1 and "RIGHT" or "LEFT", -GAP, 0)
end
end
end
-- ── Flyout button (expands to reveal more buttons) ───────────────────────────
-- Hint appended to every flyout button's tooltip (after a blank line) so the right-click behavior is
-- discoverable. Kept as a constant so it's edited in one place.
local FLYOUT_HINT = "|cFF80CCFFRight-Click to toggle this flyout|r" -- light blue
-- A bar button whose main (left-click) action is REASSIGNABLE, plus a stack of extra buttons revealed
-- on hover (auto-closes when the pointer leaves) or pinned open by right-click. The host + its child
-- frames are generic display surfaces: SetActiveOrder assigns the enabled actions to them (actions[1]
-- → host, the rest → child frames), so reordering can change which action is the main. The stack
-- expands AWAY from the frame the bar is anchored to (defaults downward when the bar is free).
local function CB_CreateFlyoutButton(parent, name)
local btn = NS.CB_CreateIconButton(parent, name, nil, BTN, nil)
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
-- Tooltip reads the CURRENT main action (btn._title/_desc, set by SetActiveOrder), plus the hint.
NS.CB_SetTooltip(btn,
function() return btn._title end,
function() return (btn._desc and (btn._desc .. "\n\n") or "") .. FLYOUT_HINT end)
-- Child container. Parented to the button so it inherits MEDIUM strata (children stay interactive);
-- raised a few levels so it draws above the bar. Anchored contiguous with the button (no gap
-- between their hit rects) so moving the pointer into it never crosses a dead zone.
local flyout = CreateFrame("Frame", name .. "Flyout", btn)
flyout:SetFrameLevel(btn:GetFrameLevel() + 5)
flyout:Hide()
-- childFrames = the pool of generic flyout display surfaces (created by AddChildSlot); children = the
-- currently VISIBLE ones that layout() draws (set by SetActiveOrder). _actionsById set by the build.
btn.flyout, btn.children, btn.childFrames, btn._actionsById = flyout, {}, {}, {}
-- Lays out + shows the flyout, choosing its direction with the shared geometry (perpendicular to
-- the bar's flow, away from the bar + snapped frame, biased toward screen center).
local function open()
local dir = CB_ChooseFlyoutDir(btn, anchor.growDir, #btn.children, CB_BarAvoidRects())
CB_LayoutFlyout(flyout, btn, btn.children, dir)
flyout:Show()
end
-- Hover opens; close when the pointer is over neither the button nor the open flyout (and it isn't
-- pinned). Polled via OnUpdate (runs only while shown) so no OnLeave can be missed mid-traverse.
local function pointerInside() return btn:IsMouseOver() or flyout:IsMouseOver() end
flyout:SetScript("OnUpdate", function(self, elapsed)
self._t = (self._t or 0) + elapsed
if self._t < 0.1 then return end
self._t = 0
if not self.pinned and not pointerInside() then self:Hide() end
end)
btn:HookScript("OnEnter", open)
btn:SetScript("OnClick", function(_, mouseButton)
if mouseButton == "RightButton" then -- toggle pinned-open (survives the pointer leaving)
flyout.pinned = not flyout.pinned
if flyout.pinned then open() else flyout:Hide() end
elseif btn._action and btn._action.onClick then
btn._action.onClick()
end
end)
-- Sets the host's displayed action (icon + click + tooltip + reorder identity).
function btn:SetMain(action)
self.icon:SetTexture(action and action.icon)
self._action, self._actionId = action, action and action.id
self._title, self._desc = action and action.title, action and action.desc
end
-- Creates one generic flyout display surface (its displayed action is assigned by SetActiveOrder).
-- Returns the frame so the caller can wire shift-drag reorder onto it.
function btn:AddChildSlot(idx)
local cf -- declared first so the click closure captures it as an upvalue (its action is dynamic)
cf = NS.CB_CreateIconButton(flyout, name .. "Fly" .. idx, nil, BTN, function()
if cf._action and cf._action.onClick then cf._action.onClick() end
if not flyout.pinned then flyout:Hide() end
end)
NS.CB_SetTooltip(cf, function() return cf._title end, function() return cf._desc end)
self.childFrames[#self.childFrames + 1] = cf
return cf
end
-- Assigns enabled action ids (in order) to the host + child frames: ids[1] → host, ids[2..] → child
-- frames (shown, in order), extra frames hidden. `children` (what layout() draws) becomes the shown
-- frames. Re-layouts if the flyout is open.
function btn:SetActiveOrder(enabledIds)
self:SetMain(enabledIds[1] and self._actionsById[enabledIds[1]] or nil)
self.children = {}
for i, cf in ipairs(self.childFrames) do
local a = enabledIds[i + 1] and self._actionsById[enabledIds[i + 1]]
if a then
cf.icon:SetTexture(a.icon)
cf._action, cf._actionId, cf._title, cf._desc = a, a.id, a.title, a.desc
cf:Show()
self.children[#self.children + 1] = cf
else
cf:Hide()
end
end
if flyout:IsShown() then open() end
end
return btn
end
-- Flyout node icons can be partly transparent (class crests, LFG role glyphs) — unlike the bar's
-- opaque spell icons — so they'd otherwise show no fill. Give every node button an opaque dark backing
-- behind its icon so it reads as a solid button. Behind an opaque icon (gender) it's simply hidden, so
-- it's safe to apply uniformly. (0.06 matches the addon's other dark fills, e.g. CB_SkinEditBoxSafe.)
local function CB_AddNodeBackground(btn)
local bg = btn:CreateTexture(nil, "BACKGROUND")
bg:SetTexture(0.06, 0.06, 0.06, 1)
bg:SetAllPoints(btn)
end
-- ── Nested flyout (recruit tree) ─────────────────────────────────────────────
-- A recursively-nestable flyout built from a `node` tree. Each node:
-- { id, icon, texcoord = fn|nil, title, desc, onClick = fn|nil, children = {node,…}|nil }
-- A node with `children` opens its own sub-flyout on hover; sub-flyouts are created lazily on first
-- open and pooled (frames can't be destroyed in 3.3.5a). Every level uses the shared geometry, so each
-- sub-flyout grows perpendicular to its parent flyout's flow, away from the bar + snapped frame +
-- parent flyout, biased toward screen center. Hover anywhere in the open chain keeps it up; leaving
-- the whole subtree (or clicking a node, unless pinned) closes it. Right-click the host pins level 1.
-- `host` is the bar button; its left-click action + right-click pin are wired by the caller.
local function CB_CreateNestedFlyout(host, nodes)
local rootFlyout = CreateFrame("Frame", host:GetName() .. "Flyout", host)
rootFlyout:SetFrameLevel(host:GetFrameLevel() + 5)
rootFlyout:Hide()
host.flyout = rootFlyout
local closeTree, openSub, nodeHovered, populate
-- Hides a flyout and everything open beneath it.
closeTree = function(fly)
if not fly then return end
if fly._activeChild then closeTree(fly._activeChild); fly._activeChild = nil end
fly:Hide()
end
-- Opens node-button `nb`'s sub-flyout as the active child of its `container` (building it once).
openSub = function(nb, container)
local sub = nb.subFlyout
if not sub then
sub = CreateFrame("Frame", nb:GetName() .. "Sub", nb)
sub:SetFrameLevel(nb:GetFrameLevel() + 5)
populate(sub, nb._node.children)
nb.subFlyout = sub
end
container._activeChild = sub
local avoid = CB_BarAvoidRects()
if container:GetLeft() then
avoid[#avoid + 1] = { container:GetLeft(), container:GetBottom(), container:GetRight(), container:GetTop() }
end
local dir = CB_ChooseFlyoutDir(nb, container._dir or "DOWN", #sub._btns, avoid)
CB_LayoutFlyout(sub, nb, sub._btns, dir)
sub:Show()
end
-- On hovering a node: close a sibling's open sub-flyout, then open this node's (if it has children).
nodeHovered = function(nb, container)
if container._activeChild and container._activeChild ~= nb.subFlyout then
closeTree(container._activeChild)
container._activeChild = nil
end
if nb._node.children then openSub(nb, container) end
end
-- Builds one button per node into `container` (once; pooled). Recursion happens lazily via openSub.
populate = function(container, nodeList)
if container._btns then return end
container._btns = {}
for i, node in ipairs(nodeList) do
local nb = NS.CB_CreateIconButton(container, container:GetName() .. "_" .. node.id, node.icon, BTN, nil)
CB_AddNodeBackground(nb) -- solid backing behind transparent class/role glyphs
if node.texcoord then nb.icon:SetTexCoord(node.texcoord()) end
NS.CB_SetTooltip(nb, node.title, node.desc)
nb._node = node
nb:HookScript("OnEnter", function() nodeHovered(nb, container) end)
nb:SetScript("OnClick", function()
if node.onClick then node.onClick() end
if not rootFlyout.pinned then closeTree(rootFlyout) end
end)
container._btns[i] = nb
end
end
populate(rootFlyout, nodes)
-- The pointer is "in the chain" if it's over the host or any open flyout from the host down.
local function chainInside()
if host:IsMouseOver() then return true end
local f = rootFlyout
while f do
if f:IsShown() and f:IsMouseOver() then return true end
f = f._activeChild
end
return false
end
local function openRoot()
-- Start at level 1: drop any sub-flyout left over from a prior open (Hide() on a parent
-- doesn't clear a child's own shown flag, so a stale sub would otherwise reappear).
if rootFlyout._activeChild then closeTree(rootFlyout._activeChild); rootFlyout._activeChild = nil end
local dir = CB_ChooseFlyoutDir(host, anchor.growDir, #rootFlyout._btns, CB_BarAvoidRects())
CB_LayoutFlyout(rootFlyout, host, rootFlyout._btns, dir)
rootFlyout:Show()
end
host._openFlyout = openRoot -- right-click pin reopens through this
host._closeFlyout = function() closeTree(rootFlyout) end -- recursively hides the whole chain
rootFlyout:SetScript("OnUpdate", function(self, elapsed)
self._t = (self._t or 0) + elapsed
if self._t < 0.1 then return end
self._t = 0
if not self.pinned and not chainInside() then closeTree(self) end
end)
host:HookScript("OnEnter", openRoot)
return rootFlyout
end
-- Builds the recruit node tree: class → role (the roles that class can fill) → gender. Clicking a
-- node at any level recruits at that specificity (class only / class+role / class+role+gender) via the
-- shared NS.CB_Recruit; deeper levels just refine it. Icons: class atlas, LFG role icons, gender icons.
local RECRUIT_ROLE_LABEL = { TANK = "Tank", HEAL = "Healer", DPS = "DPS" }
local RECRUIT_ROLE_TOKEN = { TANK = "TANK", HEAL = "HEALER", DPS = "DAMAGER" } -- GetTexCoordsForRole
local RECRUIT_GENDER = {
{ id = "MALE", label = "Male", icon = "Interface\\Icons\\Achievement_Character_Human_Male" },
{ id = "FEMALE", label = "Female", icon = "Interface\\Icons\\Achievement_Character_Human_Female" },
}
local function CB_BuildRecruitTree()
local tree = {}
-- Classes alphabetical by display name (Death Knight, Druid, Hunter, …) rather than the registry's
-- canonical order, so the class column is predictable to scan.
local classes = NS.CB_RecruiterAllClasses()
table.sort(classes, function(a, b)
return ((NS.CLASS_DISPLAY and NS.CLASS_DISPLAY[a]) or a) < ((NS.CLASS_DISPLAY and NS.CLASS_DISPLAY[b]) or b)
end)
for _, class in ipairs(classes) do
local classDisp = (NS.CLASS_DISPLAY and NS.CLASS_DISPLAY[class]) or class
local classNode = {
id = class, title = classDisp,
icon = "Interface\\WorldStateFrame\\Icons-Classes",
texcoord = function() return unpack(NS.CLASS_ICON_COORDS[class]) end,
desc = "Recruit a " .. classDisp .. " (random role & gender).",
onClick = function() NS.CB_Recruit(class, nil, nil, NS.CB_Print, classDisp) end,
children = {},
}
for _, role in ipairs(NS.CB_RecruiterRolesForClass(class)) do
local spec = NS.CB_RecruiterSpec(class, role)
local roleDisp = RECRUIT_ROLE_LABEL[role] .. " " .. classDisp
local roleNode = {
id = role, title = roleDisp,
icon = "Interface\\LFGFrame\\UI-LFG-ICON-ROLES",
texcoord = function() return GetTexCoordsForRole(RECRUIT_ROLE_TOKEN[role]) end,
desc = "Recruit a " .. roleDisp .. " (random gender).",
onClick = function() NS.CB_Recruit(class, spec, nil, NS.CB_Print, roleDisp) end,
children = {},
}
for _, g in ipairs(RECRUIT_GENDER) do
local gDisp = g.label .. " " .. roleDisp
roleNode.children[#roleNode.children + 1] = {
id = g.id, title = gDisp, icon = g.icon,
desc = "Recruit a " .. gDisp .. ".",
onClick = function() NS.CB_Recruit(class, spec, g.id, NS.CB_Print, gDisp) end,
}
end
classNode.children[#classNode.children + 1] = roleNode
end
tree[#tree + 1] = classNode
end
return tree
end
-- The Recruit bar button: a non-reorderable host whose left-click recruits a fully random bot, with a
-- nested class→role→gender flyout. Right-click pins the flyout open (mirrors the other flyouts).
local function CB_CreateRecruitButton(parent, name)
local btn = NS.CB_CreateIconButton(parent, name, "Interface\\Icons\\INV_Misc_GroupLooking", BTN, nil)
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
NS.CB_SetTooltip(btn, "Recruit",
function() return "Left-click: recruit a random bot (matches your level).\n\n" .. FLYOUT_HINT end)
CB_CreateNestedFlyout(btn, CB_BuildRecruitTree())
btn:SetScript("OnClick", function(_, mouseButton)
if mouseButton == "RightButton" then
btn.flyout.pinned = not btn.flyout.pinned
if btn.flyout.pinned then btn._openFlyout() else btn._closeFlyout() end
else
local classes = NS.CB_RecruiterAllClasses()
local class = classes[math.random(#classes)]
NS.CB_Recruit(class, nil, nil, NS.CB_Print, (NS.CLASS_DISPLAY and NS.CLASS_DISPLAY[class]) or class)
end
end)
return btn
end
-- ── Config frame (edit mode) ─────────────────────────────────────────────────
-- A small panel shown beside the bar in edit mode: an "Anchor From" corner dropdown, a "Grow
-- Direction" dropdown, x/y position editboxes, four 1px nudge buttons, a snapping toggle and Done.
-- Excluded from snap candidates so the bar can't anchor to it.
local function CB_BuildConfig()
configFrame = CreateFrame("Frame", "CleanBotActionBarConfigFrame", UIParent)
configFrame:SetFrameStrata("DIALOG")
configFrame:SetSize(180, 250)
configFrame:EnableMouse(true) -- swallow clicks so they don't fall through to the world
NS.CB_ApplyFrameSkin(configFrame, 2)
configFrame:Hide()
local PADC = 10
local CONTENT_W = 180 - 2 * PADC -- inner width the full-span rows fill
-- Re-pins the bar from the live anchor, persists, and re-flows the config + readout. Shared by
-- the dropdowns, editboxes, and nudge buttons.
local function applyAndSave()
CB_ApplyAnchor()
CB_SaveAnchor()
CB_PositionConfig()
CB_RefreshConfig()
end
-- "Anchor From" label + corner dropdown (the pinned corner; left-aligned at PADC).
local anchorLabel = configFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
anchorLabel:SetText("Anchor From")
anchorLabel:SetPoint("TOPLEFT", configFrame, "TOPLEFT", PADC, -PADC)
local growDD = NS.CB_CreateDropdown(configFrame, "CleanBotActionBarGrowDD", 150)
-- The template carries a ~16px left inset; pull left so the visible box lines up with PADC.
growDD:SetPoint("TOPLEFT", anchorLabel, "BOTTOMLEFT", -16, -2)
UIDropDownMenu_Initialize(growDD, function()
for _, corner in ipairs(CORNERS) do
local info = UIDropDownMenu_CreateInfo()
info.text = CORNER_LABEL[corner]
info.value = corner
info.notCheckable = 1
info.func = function()
anchor.growFrom = corner
CB_RecomputeOffsets() -- keep the bar where it is; only the pinned corner changes
applyAndSave()
end
UIDropDownMenu_AddButton(info)
end
end)
configFrame.growDD = growDD
-- "Grow Direction" label + dropdown (the way the bar's buttons flow).
local dirLabel = configFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
dirLabel:SetText("Grow Direction")
dirLabel:SetPoint("TOPLEFT", anchorLabel, "BOTTOMLEFT", 0, -40)
local dirDD = NS.CB_CreateDropdown(configFrame, "CleanBotActionBarDirDD", 150)
dirDD:SetPoint("TOPLEFT", dirLabel, "BOTTOMLEFT", -16, -2)
UIDropDownMenu_Initialize(dirDD, function()
for _, d in ipairs(GROW_DIRS) do
local info = UIDropDownMenu_CreateInfo()
info.text = DIR_LABEL[d]
info.value = d
info.notCheckable = 1
info.func = function()
anchor.growDir = d
CB_LayoutBar() -- reflow the buttons + resize; pinned corner stays put
applyAndSave()
end
UIDropDownMenu_AddButton(info)
end
end)
configFrame.dirDD = dirDD
-- x / y position editboxes in a full-width holder; each box stretches to fill its half of the row.
local xyRow = CreateFrame("Frame", nil, configFrame)
xyRow:SetSize(CONTENT_W, 18)
xyRow:SetPoint("TOPLEFT", dirLabel, "BOTTOMLEFT", 0, -40)
local xLabel = xyRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
xLabel:SetText("x:")
xLabel:SetPoint("LEFT", xyRow, "LEFT", 0, 0)
local yLabel = xyRow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
yLabel:SetText("y:")
yLabel:SetPoint("LEFT", xyRow, "CENTER", 4, 0)
-- Both boxes anchored on both sides so they grow: xBox fills xLabel→midpoint, yBox fills yLabel→right.
local xBox = NS.CB_CreateEditBox(xyRow, "CleanBotActionBarXBox")
xBox:SetHeight(18)
xBox:SetAutoFocus(false)
xBox:SetPoint("LEFT", xLabel, "RIGHT", 4, 0)
xBox:SetPoint("RIGHT", yLabel, "LEFT", -8, 0)
configFrame.xBox = xBox
local yBox = NS.CB_CreateEditBox(xyRow, "CleanBotActionBarYBox")
yBox:SetHeight(18)
yBox:SetAutoFocus(false)
yBox:SetPoint("LEFT", yLabel, "RIGHT", 4, 0)
yBox:SetPoint("RIGHT", xyRow, "RIGHT", 0, 0)
configFrame.yBox = yBox
-- Commit a typed value (Enter or focus loss): valid number → store + re-pin; invalid → revert.
local function commit(box, axis)
local v = tonumber(box:GetText())
if v then anchor[axis] = math.floor(v + 0.5); applyAndSave()
else CB_RefreshConfig() end
box:ClearFocus()
end
xBox:SetScript("OnEnterPressed", function(self) commit(self, "x") end)
yBox:SetScript("OnEnterPressed", function(self) commit(self, "y") end)
xBox:SetScript("OnEditFocusLost", function(self) commit(self, "x") end)
yBox:SetScript("OnEditFocusLost", function(self) commit(self, "y") end)
xBox:SetScript("OnEscapePressed", function(self) CB_RefreshConfig(); self:ClearFocus() end)
yBox:SetScript("OnEscapePressed", function(self) CB_RefreshConfig(); self:ClearFocus() end)
-- Four 1px nudge buttons: ⟨ ^ v ⟩ → left, up, down, right (screen-axis, regardless of growFrom).
local function nudge(dx, dy)
anchor.x = anchor.x + dx
anchor.y = anchor.y + dy
applyAndSave()
end
local BW, BH = 28, 22
local dirs = {
{ "<", -1, 0 }, { "^", 0, 1 }, { "v", 0, -1 }, { ">", 1, 0 },
}
-- Spread the fixed-width buttons evenly across the full content width (gap fills the slack).
local BGAP = (CONTENT_W - #dirs * BW) / (#dirs - 1)
local btnRow = CreateFrame("Frame", nil, configFrame)
btnRow:SetSize(CONTENT_W, BH)
btnRow:SetPoint("TOPLEFT", xyRow, "BOTTOMLEFT", 0, -8)
local prev
for i, d in ipairs(dirs) do
local btn = NS.CB_CreateButton(btnRow, "CleanBotActionBarNudge" .. i, d[1], BW, BH,
function() nudge(d[2], d[3]) end)
if i == 1 then
btn:SetPoint("LEFT", btnRow, "LEFT", 0, 0)
else
btn:SetPoint("LEFT", prev, "RIGHT", BGAP, 0)
end
prev = btn
end
-- Done button (bottom center): leaves edit mode.
local doneBtn = NS.CB_CreateButton(configFrame, "CleanBotActionBarDoneBtn", "Done", 80, 22,
function() NS.CB_SetActionBarEditMode(false) end)
doneBtn:SetPoint("BOTTOM", configFrame, "BOTTOM", 0, PADC)
-- Snapping toggle (left-aligned, above Done): persists CleanBot_SavedVars.actionBarSnap.
local snapCB = NS.CB_CreateLabeledCheckBox(configFrame, "CleanBotActionBarSnapCB", "Snapping",
"Snap the bar to nearby frame edges while dragging.")
snapCB:SetSize(20, 20)
snapCB:SetPoint("BOTTOMLEFT", configFrame, "BOTTOMLEFT", PADC, PADC + 22 + 10)
snapCB:SetScript("OnClick", function(self)
NS.actionBarSnap = self:GetChecked() and true or false
if CleanBot_SavedVars then CleanBot_SavedVars.actionBarSnap = NS.actionBarSnap end
end)
configFrame.snapCB = snapCB
-- Pins the config beside the bar. Vertical adjacency (below/above) and horizontal alignment
-- (extend right / centered / extend left) are each preferred from where the bar sits relative to a
-- centered deadzone, so the panel is pushed away from whichever screen edges the bar is near. The
-- vertical default is ABOVE (only a bar near the top pushes it below); bar at top-left → below +
-- extending right, etc. But the preference can be overridden: every
-- candidate placement is scored so the panel stays on-screen AND avoids covering the frame the bar
-- is anchored to (which sits right beside the bar when snapped). On-screen wins first, then
-- not-overlapping the anchor frame, then the deadzone preference.
local GAPC, DZ = 6, 0.32
-- Screen rect the config would occupy for a given (vert, horiz) placement.
local function rectFor(vert, horiz, bl, bb, br, bt, cw, ch)
local l = (horiz == "LEFT" and bl) or (horiz == "RIGHT" and br - cw) or ((bl + br) / 2 - cw / 2)
local b = (vert == "BELOW") and (bb - GAPC - ch) or (bt + GAPC)
return l, b, l + cw, b + ch
end
-- Total length of edges hanging off-screen (linear; 0 when fully on-screen).
local function offScreen(l, b, r, t, sw, sh)
return math.max(0, -l) + math.max(0, r - sw) + math.max(0, -b) + math.max(0, t - sh)
end
local overlap = CB_RectOverlap
CB_PositionConfig = function()
if not (configFrame and bar) then return end