-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPrompt.lua
More file actions
3256 lines (2970 loc) · 124 KB
/
Copy pathPrompt.lua
File metadata and controls
3256 lines (2970 loc) · 124 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
-- Manners -- the on-screen prompt.
--
-- The button is a SecureActionButtonTemplate: Blizzard owns the click, and its
-- attributes, size, scale and position are protected in combat. Every visual
-- lives on `art`, an ordinary child frame, so animations never touch protected
-- state. Everything is drawn from one white texture plus gradients, alpha and
-- motion: a missing atlas or art file renders as a green placeholder, and a
-- solid texture cannot fail.
local _, ns = ...
-- Player-facing text, in the client's language: see Locales/Init.lua.
local L = ns.L
local LSM = LibStub("LibSharedMedia-3.0")
local InCombatLockdown = _G.InCombatLockdown
local GetTime = _G.GetTime
-- Guarded because a library that changed its Register signature must not stop
-- the prompt being built; the addon is then silent, not broken.
ns.Guard("register sound", function()
LSM:Register("sound", ns.SOUND_KEY, ns.SOUND_FILE)
end)
function ns.PlayPromptSound(file)
if not file or file == "None" then return end
-- noDefault: an uninstalled pack's entry would resolve to "None" and play
-- nothing. A missing key falls back to our sound here, not in the saved
-- setting: a pack loading after this addon has not registered yet.
local data = LSM:Fetch("sound", file, true) or LSM:Fetch("sound", ns.SOUND_KEY, true)
if not data then return end
local willPlay = ns.plain(PlaySoundFile(data, "Master"))
-- The client can refuse a file outright. A toggle that is on and silent is
-- the whole complaint, so say which sound it was rather than nothing.
if willPlay == false and ns.db and ns.db.profile.verbose then
ns.addon:Print(L["|cffff8080%s did not play.|r Pick another sound."]:format(tostring(file)))
end
end
local Prompt = {}
ns.Prompt = Prompt
local WHITE = "Interface\\Buttons\\WHITE8X8"
local button, art, textLayer
local panel, hairTop, hairBottom
-- The drop shadow: steps of falloff from a crisp dark rim out to almost
-- nothing, each a little lower, so the light reads as coming from above.
local shadows
local SHADOW_STEPS = {
-- spread, alpha, drop
{ 1, 0.50, 0 },
{ 3, 0.16, 1 },
{ 6, 0.09, 2 },
{ 10, 0.05, 3 },
}
-- Light falling on the top half of the glass. The panel's own gradient darkens
-- towards the bottom; this is the other half of the same idea.
local sheen
-- The framed look's four edges, from the same white texture. A backdrop needs
-- BackdropTemplate and a border needs art or an atlas, either of which this
-- client may lack; four one-pixel rectangles cannot fail.
local edges
local accentTop, accentBottom, sweep, sweepFrame
local iconBack, icon, glowFrame, iconMask
-- A halo round the icon, starting at the ring so the icon stays readable at
-- every point of the pulse. glowFrame is a frame of its own so it can be
-- animated, and a child frame draws over its parent. See Halo.
local glowHalo
-- A dark line between the icon and its ring, and a shade over the icon's lower
-- half, so the ring reads as a frame rather than a flat square.
local iconEdge, iconShade
-- The global cooldown, swept over the icon. See SyncCooldown.
local cooldown
-- The confirmation a landed buff gets, and the light on a new favour: a ring
-- popping outward from the icon, and a band of light crossing the panel once.
-- Both play once and stop.
local burstFrame, burstHalo, shineFrame, shineLeft, shineRight
-- Whether the panel colour is dark enough for the reason line to carry a tint
-- of the reason colour. On a light panel a tinted grey loses its contrast.
local tintSub
-- What PaintAccent last painted, so a repaint in the same colour costs nothing.
local accentPainted
local nameText, subText, countChip, countText, queueRows
-- A flood of colour over the whole panel for the half-second after a click:
-- the panel is where the eye already is.
local resultFill
-- The queue list hangs outside the panel, so it needs its own background.
-- queueBars are the reason stripes down the left of each row.
local queueBack, queueHair, queueBars
local queueTextX = 0
-- Goes into every click line, so a log says which build produced it.
ns.BUILD = "1.0.0"
local current, testMode, testExpiry, lastTop, appliedKey, lastClickAt, lastPreClickAt, lastSkipAt
-- Why the last painted person was on the panel, beside lastTop's who.
local lastTopReason
-- The newest favour stamp a repaint has seen, and how old a favour can be and
-- still count as just done; one first seen late is not news.
local seenDebtAt
local ARRIVAL_SECONDS = 3
-- Its own stamp rather than one of the three above: the refusal it rate-limits
-- happens on presses none of those are counting.
local lastStaleAt
-- What the last resolved press left on the button. The debounce may skip
-- re-resolving only while the button still holds this.
local pressKey
-- Whether the press being resolved is on somebody the fresh queue no longer
-- holds (the hold or the empty-queue fuse kept them). Their entry's range
-- reading is from a scan the latest one overruled, so it says nothing about
-- whether a shout reached them. Set in PreClick, read and cleared in PostClick.
local pressStale
-- Who the macro frozen for a fight is aimed at, kept when a disarm in that
-- fight (switched off, unlocked, a preview) could only clear `current`.
local frozenEntry
-- How many names this file has written into ns.lastGave since it was last
-- emptied; see OnPostClick.
local lastGaveCount = 0
local LAST_GAVE_CAP = 400
-- The moment of a press the cooldown turned away, stamped in PreClick and
-- consumed by PostClick in the same frame. Stamped ahead of the combat return:
-- in a fight only the bookkeeping can be refused.
local cooldownPressAt
-- ...and what the button held when the guard disarmed it, so PostClick can put
-- it back; left disarmed, a fight starting before the next scan froze the
-- prompt empty.
local guardedEntry
-- ...and the spoken line it carried, put back with it so the tooltip and the
-- next press quote the same roll.
local guardedPhraseKey, guardedPhraseText
---------------------------------------------------------------------------
-- hysteresis
--
-- The scan rebuilds the queue 2.5 times a second, and in a crowd its top
-- churns. Three floors -- who is on it, whether it is shown, the sound -- each
-- refuse a change for a short time and then allow it.
---------------------------------------------------------------------------
-- How long a freshly painted candidate is protected from one of equal or lower
-- priority. Somebody strictly better takes the panel at once.
local HOLD_SECONDS = 1.5
-- How long an empty queue is given to refill before the prompt comes down.
-- Shorter than the hold: "there is nobody" should be believed quickly.
local EMPTY_FUSE_SECONDS = 0.75
-- The floor between two sounds. Without it the sound is tied to the name
-- changing, and the name changing is exactly what churns.
local SOUND_FLOOR_SECONDS = 3
-- How long a click's outcome sits over the panel.
local OUTCOME_SECONDS = 0.6
-- The entry last painted and when, which the hold is measured against. The
-- whole entry, because re-arming the macro needs the buff as well as the name
-- after the queue has dropped them.
local heldEntry, heldAt
-- When the queue first came back empty, cleared the moment it refills.
local emptyAt
local lastSoundAt
-- Whether the panel is dimmed for combat, so the alpha is written once per
-- transition.
local combatHeld
-- Whether a drag actually started. The client delivers OnDragStop for every
-- drag gesture, including the ones OnDragStart refused (locked, in combat).
local dragging
-- What the last click turned into: "cast", "sent" or "failed", who it was
-- about, and the game's own words where it had any.
local outcomeKind, outcomeAt, outcomeName, outcomeDetail
-- Who the name line is about while an outcome is over it: the outcome expires
-- on the clock but its words stay until a repaint, and a press follows the
-- words.
local outcomePainted
-- Which outcome the expiry timer was set for, so a timer left from an earlier
-- outcome is ignored.
local outcomeGen = 0
-- The spoken line settled for the candidate on the button, and its key, so the
-- tooltip quotes the roll the press will cast.
local phraseKey, phraseText
-- Whether that line is on the button now: it is kept but left out for somebody
-- out of reach or just refused (see ApplyTarget), and the tooltip must agree.
local phraseArmed
-- Which side of the panel the queue list hangs off. Decided in ApplyStyle,
-- because the only things that move the prompt come back through it.
local queueAbove
-- Everything the hysteresis holds, dropped whenever the prompt goes down for a
-- reason of its own (switched off, unlocked, nothing learned), so coming back
-- up is a fresh start.
local function ClearHold()
heldEntry, heldAt, emptyAt = nil, nil, nil
end
-- Lights the fuse on an empty queue, once, and asks for a repaint when it has
-- burnt out rather than waiting for the next scan.
local function LightFuse(now)
if emptyAt then return end
emptyAt = now
if C_Timer and C_Timer.After then
C_Timer.After(EMPTY_FUSE_SECONDS + 0.05, function()
ns.Guard("fuse repaint", Prompt.Refresh, Prompt)
end)
end
end
-- On the never-offer list with no favour to return, asked as BuildQueue asks
-- it, so the hold and the fuse drop a name listed by any route.
local function ListedWithoutDebt(name, now)
if not (name and ns.IsNeverOffered and ns.IsNeverOffered(name)) then return false end
local db = ns.db and ns.db.profile
local debt = ns.owed and ns.owed[name]
local owed = db and db.sources and db.sources.owed and debt
and ns.DebtExpiry(debt) > now
return not owed
end
-- Whether this entry was deliberately retired: blocked (the retry cooldown a
-- click wrote, or a right-press refusal) or on the never-offer list with
-- nothing owed. The repaint and the press both ask this and must agree.
local function Retired(entry, now)
return entry ~= nil and entry.name ~= nil
and (ns.IsBlocked(entry.name, entry.buff and entry.buff.key, now)
or ListedWithoutDebt(entry.name, now))
end
-- The list and its background live outside the panel, so hiding the button
-- does not hide them. Every branch that takes the prompt down has to say so.
local function HideQueue()
if not queueRows then return end
for i, fs in ipairs(queueRows) do
fs:SetText("")
queueBars[i]:Hide()
end
queueBack:Hide()
queueHair:Hide()
end
-- Show and Hide are protected, and in combat the client refuses both silently.
-- `false` means the panel stayed where the fight found it.
local function SetPanelShown(want)
if InCombatLockdown() then return false end
if want then button:Show() else button:Hide() end
return true
end
-- Ends a move under way: the frame let go, where it landed saved, and the
-- prompt locked. Shared by the release and by a fight arriving mid-drag, which
-- must leave the prompt in the same place.
local function FinishDrag()
dragging = nil
button:StopMovingOrSizing()
local point, _, relPoint, x, y = button:GetPoint()
local p = ns.db.profile.prompt
-- The client returns the offsets in the frame's scaled units; the profile
-- keeps UIParent's. The frame's own scale is the one it was moved at, not
-- p.scale, which can be ahead of the frame: a Scale slider moved in a fight
-- waits for the fight to end before ApplyStyle applies it.
local okScale, s = pcall(button.GetScale, button)
if not okScale or type(s) ~= "number" or s <= 0 then s = p.scale end
p.point, p.relPoint = point, relPoint
p.x, p.y = math.floor(x * s + 0.5), math.floor(y * s + 0.5)
-- Lock straight after a drag: an unlocked prompt cannot cast, and looks
-- just like a working one with nobody to offer.
p.locked = true
Prompt:ApplyStyle()
-- The options page is usually open for this; repaint it so its Locked box
-- and position controls match.
ns.RepaintOptions()
ns.addon:Print(L["moved and locked."])
end
-- Below the panel normally, above it when the prompt sits in the bottom third
-- of the screen. Every call is guarded (an unplaced frame has no centre), and
-- the centre is converted from the prompt's scaled units to UIParent's.
local function QueueGoesAbove()
local okCentre, _, y = pcall(button.GetCenter, button)
if not okCentre or type(y) ~= "number" then return false end
local okHeight, screenHeight = pcall(UIParent.GetHeight, UIParent)
if not okHeight or type(screenHeight) ~= "number" or screenHeight <= 0 then return false end
local ratio = ns.db.profile.prompt.scale
local okOwn, own = pcall(button.GetEffectiveScale, button)
local okParent, parent = pcall(UIParent.GetEffectiveScale, UIParent)
if okOwn and okParent and type(own) == "number" and type(parent) == "number"
and parent > 0 then
ratio = own / parent
end
if type(ratio) ~= "number" or ratio <= 0 then ratio = 1 end
return y * ratio < screenHeight / 3
end
-- What the macro on the button is aimed at: { targeted, selfCast, aimedAt },
-- or nil. PostClick copies it onto the pending click, so the settle handler
-- judges the press by what actually went out. Set beside appliedKey. This
-- client does not name a cast's recipient, so a /target of ours aimed at this
-- person is the only thing tying a press to a person.
local armed
-- Amber for a favour returned, the case worth noticing; the others stay quiet.
-- Picked to survive the commonest colour blindness: target and group differ in
-- lightness, owed and target are amber against blue.
-- Rec.601 greys: target 0.83, owed 0.79, group 0.56, nearby 0.54.
local REASON_COLOR = {
target = { 0.62, 0.90, 1.00 },
owed = { 1.00, 0.78, 0.30 },
group = { 0.34, 0.60, 0.96 },
nearby = { 0.52, 0.54, 0.62 },
-- Somebody who asked in chat: a warm pink, apart from the four in hue and
-- between owed and group in grey (0.68).
asked = { 0.96, 0.52, 0.80 },
}
local REASON_KEY = { target = "reasonTarget", owed = "reasonOwed",
group = "reasonGroup", nearby = "reasonNearby", asked = "reasonAsked" }
-- For somebody the set above still fails: chosen by search so the closest pair
-- is furthest apart in CIE76 under normal sight, protanopia and deuteranopia
-- (Machado et al. 2009); tests/scenarios/look2.lua holds it to that. Opt-in
-- under Prompt > Style. Rec.601 greys: target 0.92, owed 0.57, group 0.70,
-- nearby 0.47, asked 0.37.
local REASON_COLOR_CVD = {
target = { 0.98, 0.96, 0.56 },
owed = { 0.92, 0.48, 0.08 },
group = { 0.42, 0.78, 1.00 },
nearby = { 0.80, 0.20, 1.00 },
-- A deep pink, searched for the same way against the four above, and
-- darker than the violet and the orange: lightness survives all three
-- ways of seeing.
asked = { 0.72, 0.20, 0.34 },
}
local REASON_PALETTES = { standard = REASON_COLOR, colourblind = REASON_COLOR_CVD }
-- The colour for a reason in the player's palette; anything unknown reads as
-- the standard set.
local function ReasonColor(reason)
local p = ns.db and ns.db.profile.prompt
local set = REASON_PALETTES[p and p.reasonPalette] or REASON_COLOR
return set[reason or "nearby"] or set.nearby
end
-- Whole minutes, like the refresh threshold: a ticking countdown reads as
-- urgency. Seconds only under a minute.
local function RemainingText(seconds)
if type(seconds) ~= "number" or seconds <= 0 then return nil end
if seconds < 60 then return L["%ds"]:format(math.floor(seconds)) end
return L["%dm"]:format(math.floor(seconds / 60))
end
---------------------------------------------------------------------------
-- capability-checked drawing helpers
---------------------------------------------------------------------------
-- SetGradient changed signature between client generations and SetGradientAlpha
-- was removed. Try both, and fall back to a flat colour that always works.
local function Gradient(tex, orientation, r1, g1, b1, a1, r2, g2, b2, a2)
if tex.SetGradient and CreateColor then
if pcall(tex.SetGradient, tex, orientation, CreateColor(r1, g1, b1, a1), CreateColor(r2, g2, b2, a2)) then
return true
end
end
if tex.SetGradientAlpha then
if pcall(tex.SetGradientAlpha, tex, orientation, r1, g1, b1, a1, r2, g2, b2, a2) then
return true
end
end
tex:SetVertexColor(r1, g1, b1, a1)
return false
end
local function Solid(parent, layer, sublevel)
local t = parent:CreateTexture(nil, layer, nil, sublevel)
t:SetTexture(WHITE)
return t
end
-- A method this client may not have, called if it is there. The effects use a
-- few calls nobody here has watched run; a missing one costs that detail,
-- never the prompt.
local function Try(obj, method, ...)
local fn = obj and obj[method]
if type(fn) ~= "function" then return false end
return (pcall(fn, obj, ...))
end
-- The soft glows round the icon, drawn once by tools/make-glow.py: white, with
-- the shape in the alpha, so they take the reason colour from SetVertexColor.
local GLOW = "Interface\\AddOns\\Manners\\Textures\\Glow"
local GLOW_ROUND = "Interface\\AddOns\\Manners\\Textures\\GlowRound"
-- Where the rim of the round glow sits, as a fraction of the texture's
-- half-width: RING_AT in tools/make-glow.py, which must agree.
local GLOW_RING_AT = 0.70
-- Where each piece of the square halo is cut from Glow.tga (SetTexCoord's
-- left, right, top, bottom): quarters for corners and a thin middle line for
-- sides, so every join has matching brightness.
local MID0, MID1 = 0.49, 0.51
local HALO_CUTS = {
{ MID0, MID1, 0, 0.5 }, -- top
{ MID0, MID1, 0.5, 1 }, -- bottom
{ 0, 0.5, MID0, MID1 }, -- left
{ 0.5, 1, MID0, MID1 }, -- right
{ 0, 0.5, 0, 0.5 }, -- top-left
{ 0.5, 1, 0, 0.5 }, -- top-right
{ 0, 0.5, 0.5, 1 }, -- bottom-left
{ 0.5, 1, 0.5, 1 }, -- bottom-right
}
-- A halo round a box: the eight pieces of the square one, and the ring a
-- rounded icon wears instead. Additive, so it is light added to what is there.
local function Halo(parent, layer, sublevel)
local halo = { strips = {} }
for i, cut in ipairs(HALO_CUTS) do
local t = parent:CreateTexture(nil, layer, nil, sublevel)
t:SetTexture(GLOW)
t:SetTexCoord(cut[1], cut[2], cut[3], cut[4])
t:SetBlendMode("ADD")
halo.strips[i] = t
end
halo.round = parent:CreateTexture(nil, layer, nil, sublevel)
halo.round:SetTexture(GLOW_ROUND)
halo.round:SetBlendMode("ADD")
halo.round:Hide()
return halo
end
-- Placed round `box`, outside an inset of `gap`: `sx` out to the sides and `sy`
-- above and below. With `round` (a round icon's size, passed because the box
-- may not be laid out yet) the ring instead, its rim on the icon's edge.
local function PlaceHalo(halo, box, sx, sy, gap, round)
local strips = halo.strips
local top, bottom, left, right = strips[1], strips[2], strips[3], strips[4]
for _, t in ipairs(strips) do
t:ClearAllPoints()
t:SetShown(not round)
end
halo.round:ClearAllPoints()
halo.round:SetShown(round and true or false)
if round then
local size = round / GLOW_RING_AT
halo.round:SetPoint("CENTER", box, "CENTER", 0, 0)
halo.round:SetSize(size, size)
return
end
top:SetPoint("BOTTOMLEFT", box, "TOPLEFT", -gap, gap)
top:SetPoint("BOTTOMRIGHT", box, "TOPRIGHT", gap, gap)
top:SetHeight(sy)
bottom:SetPoint("TOPLEFT", box, "BOTTOMLEFT", -gap, -gap)
bottom:SetPoint("TOPRIGHT", box, "BOTTOMRIGHT", gap, -gap)
bottom:SetHeight(sy)
left:SetPoint("TOPRIGHT", box, "TOPLEFT", -gap, gap)
left:SetPoint("BOTTOMRIGHT", box, "BOTTOMLEFT", -gap, -gap)
left:SetWidth(sx)
right:SetPoint("TOPLEFT", box, "TOPRIGHT", gap, gap)
right:SetPoint("BOTTOMLEFT", box, "BOTTOMRIGHT", gap, -gap)
right:SetWidth(sx)
-- Top-left, top-right, bottom-left, bottom-right.
for i, at in ipairs({
{ "BOTTOMRIGHT", "TOPLEFT", -gap, gap },
{ "BOTTOMLEFT", "TOPRIGHT", gap, gap },
{ "TOPRIGHT", "BOTTOMLEFT", -gap, -gap },
{ "TOPLEFT", "BOTTOMRIGHT", gap, -gap },
}) do
local corner = strips[4 + i]
corner:SetPoint(at[1], box, at[2], at[3], at[4])
corner:SetSize(sx, sy)
end
end
-- The whole halo in one colour: the fade is in the texture, so a vertex colour
-- per piece is all it takes.
local function PaintHalo(halo, r, g, b, alpha)
for _, t in ipairs(halo.strips) do t:SetVertexColor(r, g, b, alpha) end
halo.round:SetVertexColor(r, g, b, alpha)
end
---------------------------------------------------------------------------
-- construction
---------------------------------------------------------------------------
-- The one question the click path asks. Deliberately not Core's "is the addon
-- switched on", which governs bookkeeping; this governs whether the button in
-- front of somebody should fire.
local function PromptIsLive()
local db = ns.db and ns.db.profile
if not db or not db.enabled then return false end
-- Unlocked is drag mode, and a prompt being dragged must not cast.
if not db.prompt.locked then return false end
if testMode then return false end
-- Belt and braces: BuildQueue already returns nothing without anyKnown, so
-- no mutation of this line can go red. It keeps the click path stating the
-- same condition the panel does.
if not ns.caps.anyKnown then return false end
return true
end
-- The button's scripts are written out here rather than inside
-- Prompt:Create(): Lua 5.1 allows a function 60 upvalues, a closure's count
-- against the function it sits in, and written inline they took Create() past
-- the limit, so Prompt.lua failed to load at all (beta.6).
-- Whether the person the macro is about to be aimed at is known to be out of
-- reach at this moment, for PreClick to leave the spoken line out. The scan
-- that armed the macro can be a tick old, and a macro runs every line even
-- when its /cast fails (beta.8: two thank-yous, no buff). Only a unit token
-- still naming them is asked; a recycled one says nothing about them, and an
-- unknown answer keeps the line, as the scan's does.
local function OutOfReachNow(entry)
if not (entry and entry.unit and entry.buff and entry.name) then return false end
if ns.UnitFullName(entry.unit) ~= entry.name then return false end
return ns.ReachNow(entry.unit, entry.buff) == false
end
-- PreClick runs before the secure handler reads the attributes, so out of
-- combat the target is re-resolved at the last moment: a nameplate token may
-- since have been recycled to somebody else. Every mouse button
-- arrives ("AnyDown"); only the left one casts.
local function OnPreClick(self, mouseButton)
if mouseButton and mouseButton ~= "LeftButton" then return end
local now = GetTime()
-- The macro's own /target and hand-back are not the player choosing
-- anybody; Core's PLAYER_TARGET_CHANGED ignores changes this close to it.
ns.pressAt = now
-- Asked before the combat return: in combat the frozen macro still goes
-- out, and the bookkeeping can still be refused where the macro cannot.
local ready, left = ns.CastReady()
-- In combat a press late in the cooldown is queued by the client, so it
-- counts as ready. Out of combat the whole cooldown is held back: a queued
-- /cast would fire after /targetlasttarget.
if InCombatLockdown() and not ready and left <= ns.SpellQueueWindow() then
ready = true
end
-- Then the combat return: in combat nothing below can change the macro,
-- since the attributes are frozen, so neither the target nor the spoken
-- line (range, a refusal) is looked at again until the fight ends.
cooldownPressAt = (not ready) and now or nil
if InCombatLockdown() then
pressStale = nil
return
end
-- Down and up both land here; one rebuild per press, but only while the
-- button still holds what that rebuild armed (an error or a cooldown in
-- between can change it).
if lastPreClickAt and (now - lastPreClickAt) < 0.25 then
if not ready then
guardedEntry = current
guardedPhraseKey, guardedPhraseText = phraseKey, phraseText
Prompt:ApplyTarget(nil)
elseif appliedKey ~= pressKey then
Prompt:ApplyTarget(nil)
end
return
end
lastPreClickAt = now
pressKey = nil
pressStale = nil
-- A keypress on an empty prompt says why it is empty, or it looks like a
-- broken binding. Commands and option names go in as arguments: they are
-- not translated.
if not self:IsShown() then
local db = ns.db and ns.db.profile
if db and not db.enabled then
ns.addon:Print(L["Manners is |cffff8080switched off|r -- %s to start again."]
:format("|cffffd100/manners on|r"))
elseif ns.SnoozeLeft(now) then
ns.addon:Print(L["Manners is snoozed until %s -- %s brings the prompt back now."]
:format(ns.SnoozeEndsAt(), "|cffffd100/manners snooze off|r"))
elseif ns.HiddenWhileMounted() then
ns.addon:Print(L["the prompt stays away while you are mounted -- get off, or switch off %s on the %s tab."]
:format("|cffffd100" .. L["Not while mounted"] .. "|r", L["When"]))
elseif not ns.caps.anyKnown then
local class = ns.caps.class
if class and ns.CLASSES_WITHOUT_BUFFS and ns.CLASSES_WITHOUT_BUFFS[class] then
ns.addon:Print(ns.NO_CLASS_BUFFS)
else
ns.addon:Print(L["nothing learned to cast yet."])
end
else
ns.addon:Print(L["nobody to buff right now."])
end
Prompt:ApplyTarget(nil)
return
end
-- PreClick is the last chance to stop an unlocked or disabled prompt
-- casting.
if not PromptIsLive() then
Prompt:ApplyTarget(nil)
return
end
-- Nor during the global cooldown: disarmed here, the press never reaches
-- the server to be refused. Its stamp is taken back so the next press
-- resolves.
if not ready then
lastPreClickAt = nil
guardedEntry = current
guardedPhraseKey, guardedPhraseText = phraseKey, phraseText
Prompt:ApplyTarget(nil)
Prompt:SayWaiting(left)
return
end
local queue = ns.BuildQueue()
local top = Prompt:PickTop(queue, queue[1])
local named = Prompt:PanelName()
-- An empty queue under a panel still naming somebody: the press agrees with
-- the screen (at worst the game refuses the cast, in red) rather than
-- silently doing nothing. Not for somebody retired, and not under a flash
-- about somebody else: the press follows the words on the panel.
if not top and current and not Retired(current, now) and named == current.name then
LightFuse(now)
pressStale = true
-- Re-keyed rather than rebuilt: whether the macro hands your target back
-- depends on who is targeted now, which can have changed since the
-- repaint that armed it. Their range is asked again too: this entry's
-- reading is the oldest there is.
Prompt:ApplyTarget(current, OutOfReachNow(current))
pressKey = appliedKey
return
end
-- The press goes to whoever the panel names, not whoever the queue just
-- promoted: PickTop hands the panel to somebody strictly better at once,
-- which is right for the next repaint and wrong for a press made on this
-- one.
if top and named and top.name ~= named then
local fresh
for _, candidate in ipairs(queue) do
if candidate.name == named then fresh = candidate break end
end
if not fresh then
Prompt:MovedOn(top)
return
end
top = fresh
end
-- A held entry is not in the queue it was just picked against.
if top then
pressStale = true
for _, candidate in ipairs(queue) do
if candidate.name == top.name then pressStale = nil break end
end
end
appliedKey = nil
-- A held entry's range reading is from an overruled scan, and even a fresh
-- one is a tick old, so it is asked once more right before the macro runs.
Prompt:ApplyTarget(top, OutOfReachNow(top))
pressKey = appliedKey
end
local function OnPostClick(self, mouseButton, down)
-- Bookkeeping only, and switched off, unlocked or previewing there is none:
-- a CLICK binding reaches a hidden frame. In combat the frozen macro may
-- still have cast; the debt stands and the line says so.
local db = ns.db and ns.db.profile
if not PromptIsLive() then
local now = GetTime()
-- Only a press that could have cast gets the warning: type2 to type5
-- are "none", so the right button casts nothing. A keybinding arrives
-- with no button and counts as a left press, as in the cast path below.
local couldCast = mouseButton == nil or mouseButton == "LeftButton"
if couldCast and db and db.verbose and InCombatLockdown() and self:GetAttribute("macrotext1")
and not (lastStaleAt and (now - lastStaleAt) < 0.25) then
lastStaleAt = now
ns.addon:Print(L["|cffff8080that may still have cast|r -- the prompt cannot be disarmed in combat, and nothing was recorded for it."])
end
return
end
-- A right-press says "not this one": the debt stands, nothing is cast, and
-- the offer is postponed. The block is on the person, not the buff.
if mouseButton == "RightButton" then
-- Its own stamp: sharing the cast path's would let a right-press
-- swallow a real left click landing just after it.
local now = GetTime()
if lastSkipAt and (now - lastSkipAt) < 0.25 then return end
lastSkipAt = now
-- Whoever the panel names, as the left press follows: under a red flash
-- that is the person the flash is about, not the next one armed
-- underneath.
local victim = Prompt:PanelName() or (current and current.name)
if not victim then
ns.addon:Print(L["nobody to skip right now."])
return
end
local db = ns.db and ns.db.profile
-- The retry cooldown, not the two seconds a failed cast writes:
-- that would put them straight back on the prompt.
ns.BlockPerson(victim)
Prompt:StopAttention()
-- Held shift makes it "never": onto the never-offer list. Nothing here
-- touches the button (type2 is "none"), so it is as safe in a fight as
-- the skip. The block above still matters: it takes them off the panel
-- now, not after the hold and the fuse. The list itself reaches the
-- queue at its next rebuild, which in a fight is when the fight ends.
if IsShiftKeyDown and ns.plain(IsShiftKeyDown()) then
-- The repaint comes with the listing: see the wrapper below
-- Prompt:Refresh.
ns.PutOnNeverList(victim)
return
end
if db and db.verbose then
local shown = (current and current.name == victim and current.short)
or (ns.ShortName and ns.ShortName(victim)) or victim
ns.addon:Print(L["skipping |cffffffff%s|r for now."]:format(shown))
end
-- The panel moves on now rather than at the next scan, so a left press
-- cannot cast at the person just declined. Refresh knows about the
-- fight.
ns.Guard("skip repaint", Prompt.Refresh, Prompt)
return
end
if mouseButton and mouseButton ~= "LeftButton" then return end
local now = GetTime()
-- A press the cooldown turned away is not a press: nothing is filed. Out of
-- combat this puts back what PreClick disarmed.
if cooldownPressAt == now then
cooldownPressAt = nil
local found = guardedEntry
guardedEntry = nil
if found and not InCombatLockdown() then
-- The line it carried goes back with it, so the macro keeps the
-- roll the tooltip has been quoting.
phraseKey, phraseText = guardedPhraseKey, guardedPhraseText
Prompt:ApplyTarget(found)
end
guardedPhraseKey, guardedPhraseText = nil, nil
if ns.db and ns.db.profile.debugClicks then
ns.addon:Print("|cffffd100CLICK|r " .. L["held back -- the cooldown was still running"])
end
return
end
-- One press delivers both a down and an up; count and settle once.
if lastClickAt and (now - lastClickAt) < 0.25 then return end
lastClickAt = now
-- Lets the error and cast handlers tell our own outcome apart from
-- everything else the game is shouting about.
ns.lastClickTime = now
-- Only assembled when asked for: /manners clicks. The build stamp says
-- which build produced the log.
if ns.db and ns.db.profile.debugClicks then
ns.addon:Print(("|cffffd100CLICK|r build=%s macro=%s"):format(
tostring(ns.BUILD),
tostring(button:GetAttribute("macrotext1") or "nil"):gsub("%s+", " ")))
end
if not (current and current.name) then return end
-- Park the press rather than clearing the debt: the game says a moment
-- later whether anything was cast. What the macro was aimed at and the old
-- rotation pointer ride along. Anything already parked is abandoned first,
-- since one slot cannot match two records to their events, and before
-- ns.lastGave is read, because abandoning restores it. And it is read here,
-- above the rotation write below, the last point it holds the old value.
ns.AbandonPendingClick()
-- A held or fused entry's range reading comes from a scan the latest one
-- overruled, so it answers neither way: the settle keeps the debt as
-- nothing being able to tell.
local stale = pressStale
pressStale = nil
ns.pendingClick = { name = current.name, at = GetTime(),
buffKey = current.buff and current.buff.key,
selfCast = armed ~= nil and armed.selfCast == true,
targeted = armed and armed.targeted,
-- The spelling the macro aimed at, straight from the builder, for the
-- settle path to compare against whoever the client says was hit.
aimedAt = armed and armed.aimedAt,
-- Whether the scan measured them inside a shout's reach: a selfCast
-- press clears a debt only where this is true.
withinShout = current.ranged == true and not stale,
-- ...and outside it, so the line can say the answer was no rather than
-- that nothing answered.
outOfShout = (not stale and current.ranged == false) or nil,
-- For the favour ledger only.
class = current.class,
inGroup = current.inGroup,
gave = ns.lastGave[current.name] }
-- Per buff, so casting Fortitude does not stop the walk reaching
-- Divine Spirit on the next click.
if current.buff then
ns.MarkAttempted(current.name, current.buff.key)
-- Only where the walk will read it back: a paladin's blessings
-- overwrite one another, so PickBuffFor never rotates them.
if ns.RotatesBuffs() then
-- Bounded like every other per-person table. It is only a rotation
-- hint for people whose auras cannot be read, so losing an old
-- pointer costs at most one repeated first buff.
if ns.lastGave[current.name] == nil then
if lastGaveCount >= LAST_GAVE_CAP then
wipe(ns.lastGave)
lastGaveCount = 0
end
lastGaveCount = lastGaveCount + 1
end
ns.lastGave[current.name] = current.buff.key
end
end
Prompt:StopAttention()
end
-- The button's other scripts, and the list Create() sets them from. A do
-- block, so their names cost the main chunk one local rather than five.
local BUTTON_SCRIPTS
do
local function OnDragStart(self)
if ns.db.profile.prompt.locked or InCombatLockdown() then return end
self:StartMoving()
dragging = true
end
-- Only a drag that started has anything to end: the release arrives for the
-- refused ones too. A drag still held when a fight starts was ended then
-- (FinishDragForFight), so one released in combat only forgets itself.
local function OnDragStop()
if not dragging then return end
if InCombatLockdown() then
dragging = nil
return
end
FinishDrag()
end
local function OnEnter(self)
-- Nothing armed is nothing to describe, and a tooltip left from the
-- last person goes with it.
if not current or not current.buff then
if GameTooltip:IsOwned(self) then GameTooltip:Hide() end
return
end
-- In combat the macro is frozen at whoever it held when the fight
-- started, and a detailed tooltip about somebody stale is worse than
-- none.
if InCombatLockdown() then return end
GameTooltip:SetOwner(self, "ANCHOR_TOP")
GameTooltip:AddLine("Manners")
GameTooltip:AddDoubleLine(current.short or current.name, ns.BuffName(current.buff),
1, 1, 1, 0.8, 0.8, 0.8)
-- A top-up reads as one, and "missing it" only where it was read as
-- missing; "Always offer" and unreadable auras get the plain reason.
local left = RemainingText(current.remaining)
local why
if current.reason == "owed" then
why = L["Buffed you -- return the favour."]
elseif current.reason == "asked" then
why = L["Asked you for it in chat."]
elseif left then
why = current.reason == "group" and L["In your group, and theirs is running out."]
or current.reason == "target" and L["Your target, and theirs is running out."]
or L["Nearby, and theirs is running out."]
elseif current.known == false then
why = current.reason == "group" and L["In your group and missing it."]
or current.reason == "target" and L["Your target, and missing it."]
or L["Nearby and missing it."]
else
why = current.reason == "group" and L["In your group."]
or current.reason == "target" and L["Your target."]
or L["Nearby."]
end
GameTooltip:AddLine(why, 0.7, 0.7, 0.7, true)
-- Why they are ahead of the others like them, where Who comes first
-- put them there. Only ever set for a group member or a passer-by.
if current.close == "friend" then
GameTooltip:AddLine(L["On your friends list."], 0.7, 0.7, 0.7, true)
elseif current.close == "guild" then
GameTooltip:AddLine(L["In your guild."], 0.7, 0.7, 0.7, true)
end
if left then
GameTooltip:AddLine(L["Theirs expires in %s."]:format(left), 0.7, 0.7, 0.7, true)
end
if current.checked and current.known == nil then
GameTooltip:AddLine(L["Buff state unreadable on this build -- they may already have it."],
1, 0.5, 0.5, true)
elseif not current.checked then
GameTooltip:AddLine(L["Not checking whether they have it -- set by your options."],
0.7, 0.7, 0.7, true)
end
GameTooltip:AddLine(" ")
-- Plain English first; the raw macro only with /manners clicks, below.
-- ClickSummary quotes the settled roll.
for _, line in ipairs(Prompt:ClickSummary(current)) do
GameTooltip:AddLine(line, 0.62, 0.78, 0.62, true)
end
GameTooltip:AddLine(" ")
-- The raw macro is a debugging tool and reads like one, so it goes
-- where the other debugging tools are. /manners clicks turns it back
-- on.
if ns.db.profile.debugClicks and ns.lastMacro then
GameTooltip:AddLine(L["Will run:"], 0.5, 0.5, 0.5)
for line in ns.lastMacro:gmatch("[^\r\n]+") do
GameTooltip:AddLine(" " .. line, 0.4, 0.8, 0.4)
end
GameTooltip:AddLine(" ")
end
-- The command goes in as an argument, as it does in PreClick's lines:
-- it is typed in English whatever the client's language.
GameTooltip:AddLine(L["Click to cast. %s for options."]:format("|cffffd100/manners|r"),
0.5, 0.5, 0.5)
-- A gesture nobody can discover is not a feature.
GameTooltip:AddLine(L["Right-click to skip this one."], 0.5, 0.5, 0.5)
-- Somebody already on the list is only here because they are owed, and
-- for them the same press lets that favour go.
if ns.IsNeverOffered and ns.IsNeverOffered(current.name) then
GameTooltip:AddLine(L["Shift-right-click to let this favour go."], 0.5, 0.5, 0.5)
else
GameTooltip:AddLine(L["Shift-right-click to put them on your never-offer list."], 0.5, 0.5, 0.5)
end
GameTooltip:Show()
end
local function OnLeave()
GameTooltip:Hide()
end
-- Keep the tooltip honest if the entry changes while it is open, checked a
-- few times a second.
local function OnUpdate(self, elapsed)
self.sinceCheck = (self.sinceCheck or 0) + elapsed
if self.sinceCheck < 0.2 then return end
self.sinceCheck = 0
if not GameTooltip:IsOwned(self) then return end
if not (current and current.buff) then
self.tooltipFor = nil
GameTooltip:Hide()
return
end
-- Keyed on everything the tooltip says, not the name alone: the same
-- person can move to another buff, become owed, or get a re-rolled
-- line.
local shown = table.concat({ current.name, current.buff.key, tostring(current.reason),
tostring(phraseText), tostring(appliedKey) }, "\1")
if self.tooltipFor ~= shown then
self.tooltipFor = shown
local onEnter = self:GetScript("OnEnter")
if onEnter then onEnter(self) end
end
end
-- In the order Create() always set them.
BUTTON_SCRIPTS = {
{ "OnDragStart", OnDragStart },
{ "OnDragStop", OnDragStop },
{ "PreClick", OnPreClick },
{ "PostClick", OnPostClick },
{ "OnEnter", OnEnter },
{ "OnLeave", OnLeave },
{ "OnUpdate", OnUpdate },
}
end
function Prompt:Create()
if button then return end
button = CreateFrame("Button", "MannersPrompt", UIParent, "SecureActionButtonTemplate")
button:SetFrameStrata("MEDIUM")
-- Copied from the secure buttons that work on this client (MountActions,
-- GroupTools): "AnyDown" with pressAndHoldAction. Registering down without
-- pressAndHoldAction delivers the click and casts nothing.
button:RegisterForClicks("AnyDown")
button:SetAttribute("pressAndHoldAction", true)
button:SetMovable(true)
button:SetClampedToScreen(true)
button:RegisterForDrag("LeftButton")
art = CreateFrame("Frame", nil, button)
art:SetAllPoints()
-- Stacked black rectangles stand in for a soft drop shadow: four steps of
-- falloff, each a pixel lower, with a crisp dark rim innermost to keep the
-- edge clean over a bright floor.
shadows = {}
for i, step in ipairs(SHADOW_STEPS) do
local spread, alpha, drop = step[1], step[2], step[3]
-- Outermost first, so it sits underneath; sublevels -8 to -7 are all
-- the layer has below the panel, and two share one where they must.
local t = Solid(art, "BACKGROUND", i <= 2 and -7 or -8)
t:SetPoint("TOPLEFT", -spread, spread - drop)
t:SetPoint("BOTTOMRIGHT", spread, -spread - drop)
t:SetVertexColor(0, 0, 0, alpha)
shadows[i] = t
end
panel = Solid(art, "BACKGROUND", -6)
panel:SetAllPoints()
sheen = Solid(art, "BORDER", 0)
sheen:SetPoint("TOPLEFT")