|
| 1 | +using System; |
| 2 | +using System.ComponentModel; |
| 3 | +using System.Windows.Forms; |
| 4 | +using Advanced_Combat_Tracker; |
| 5 | + |
| 6 | +namespace EQ2Lexicon.ACTPlugin |
| 7 | +{ |
| 8 | + /// <summary> |
| 9 | + /// Adds an "Upload to EQ2 Lexicon" item to the right-click menu on |
| 10 | + /// ACT's encounter tree. Click → invokes the callback the Plugin |
| 11 | + /// supplies, which runs the manual-upload path (bypasses blacklist |
| 12 | + /// + upload-enabled toggle, still enforces token + version + HMAC + |
| 13 | + /// placeholder-title check). |
| 14 | + /// |
| 15 | + /// ── ACT integration notes ────────────────────────────────────────── |
| 16 | + /// |
| 17 | + /// ACT does NOT expose a documented extension point for its |
| 18 | + /// context menu. Plugins reach into the WinForms control tree by |
| 19 | + /// name and mutate the existing ContextMenuStrip. Reference |
| 20 | + /// implementation: ActStatter |
| 21 | + /// (https://github.com/eq2reapp/ActStatter/blob/main/StatterMain.cs). |
| 22 | + /// |
| 23 | + /// * The encounter view is a TreeView named "tvDG" — NOT |
| 24 | + /// "lvEncounters" or anything ListView-shaped. The MainTreeView |
| 25 | + /// property exists but is documented as "do not enumerate" |
| 26 | + /// (nodes are populated lazily on expand). |
| 27 | + /// |
| 28 | + /// * The controls do NOT exist at IActPluginV1.InitPlugin() time. |
| 29 | + /// A delayed WinForms Timer (5s) waits for ACT's UI to finish |
| 30 | + /// loading before walking the tree. One-shot — if tvDG still |
| 31 | + /// isn't there at 5s, the menu just won't appear (rare; the |
| 32 | + /// user can disable+reenable the plugin to retry). |
| 33 | + /// |
| 34 | + /// * Each encounter TreeNode has its Tag set to the literal string |
| 35 | + /// "EncounterData". Zone nodes have a different tag. Walk up |
| 36 | + /// parents until we find one — handles right-clicking on a |
| 37 | + /// child element under an encounter node. |
| 38 | + /// |
| 39 | + /// * Resolution: ActGlobals.oFormActMain.ZoneList[parent.Index] |
| 40 | + /// .Items[node.Index] returns the live EncounterData. ACT's |
| 41 | + /// "All" pseudo-encounter is not in the tree as a tagged node, |
| 42 | + /// so the Tag check filters it out for free. |
| 43 | + /// |
| 44 | + /// * DeInitPlugin MUST unsubscribe the handlers and Remove the |
| 45 | + /// menu item. ACT's auto-updater swaps plugin DLLs at runtime by |
| 46 | + /// disabling + re-enabling — leftover dead delegates pile up |
| 47 | + /// otherwise (and your menu item appears twice after a reload). |
| 48 | + /// |
| 49 | + /// Threading: WinForms Timer Tick fires on the UI thread, as do |
| 50 | + /// the ContextMenuStrip Opening + Click handlers. No marshalling |
| 51 | + /// needed for anything touched here. The callback to Plugin may |
| 52 | + /// kick off async upload work — that's Plugin's concern. |
| 53 | + /// </summary> |
| 54 | + internal sealed class ActMenuExtension : IDisposable |
| 55 | + { |
| 56 | + // Walked by name from ActGlobals.oFormActMain. ActStatter and |
| 57 | + // other EQ2 plugins use the same string — change only if a |
| 58 | + // future ACT release renames the control. |
| 59 | + private const string EncounterTreeName = "tvDG"; |
| 60 | + |
| 61 | + // 5s lines up with ActStatter's empirically-derived wait. Long |
| 62 | + // enough for ACT's UI to construct the encounter tree on cold |
| 63 | + // start; short enough that a user opening the plugin tab right |
| 64 | + // after launch doesn't feel a noticeable lag. |
| 65 | + private const int AttachDelayMs = 5000; |
| 66 | + |
| 67 | + private readonly Action<EncounterData> _onUploadClicked; |
| 68 | + private readonly Timer _delayedAttach; |
| 69 | + |
| 70 | + // Populated on successful attach. Null means we never found |
| 71 | + // the tree (rare) or we've been Disposed. |
| 72 | + private TreeView? _tvDG; |
| 73 | + private ContextMenuStrip? _menu; |
| 74 | + private ToolStripMenuItem? _menuItem; |
| 75 | + private CancelEventHandler? _openingHandler; |
| 76 | + |
| 77 | + /// <summary>True once the menu item has been added to ACT's |
| 78 | + /// context menu. Useful for diagnostics if the menu doesn't |
| 79 | + /// appear (e.g. ACT changed the tvDG control name).</summary> |
| 80 | + public bool IsAttached => _menuItem != null; |
| 81 | + |
| 82 | + public ActMenuExtension(Action<EncounterData> onUploadClicked) |
| 83 | + { |
| 84 | + _onUploadClicked = onUploadClicked ?? throw new ArgumentNullException(nameof(onUploadClicked)); |
| 85 | + |
| 86 | + _delayedAttach = new Timer { Interval = AttachDelayMs }; |
| 87 | + _delayedAttach.Tick += (s, e) => |
| 88 | + { |
| 89 | + _delayedAttach.Stop(); |
| 90 | + TryAttach(); |
| 91 | + }; |
| 92 | + _delayedAttach.Start(); |
| 93 | + } |
| 94 | + |
| 95 | + private void TryAttach() |
| 96 | + { |
| 97 | + try |
| 98 | + { |
| 99 | + _tvDG = FindControl(ActGlobals.oFormActMain, EncounterTreeName) as TreeView; |
| 100 | + if (_tvDG?.ContextMenuStrip == null) |
| 101 | + { |
| 102 | + // Either tvDG doesn't exist (ACT changed?) or its |
| 103 | + // ContextMenuStrip hasn't been wired up. Don't |
| 104 | + // create our own — appending to ACT's keeps the |
| 105 | + // existing items (Clear, Properties, etc.) intact. |
| 106 | + return; |
| 107 | + } |
| 108 | + _menu = _tvDG.ContextMenuStrip; |
| 109 | + |
| 110 | + _menuItem = new ToolStripMenuItem("Upload to EQ2 Lexicon"); |
| 111 | + _menuItem.Click += OnMenuClick; |
| 112 | + _menu.Items.Add(_menuItem); |
| 113 | + |
| 114 | + _openingHandler = OnMenuOpening; |
| 115 | + _menu.Opening += _openingHandler; |
| 116 | + } |
| 117 | + catch |
| 118 | + { |
| 119 | + // Menu-extension failures must never crash ACT. The |
| 120 | + // worst-case is "the right-click menu doesn't have our |
| 121 | + // item" — the auto-upload path still works regardless. |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + private void OnMenuOpening(object sender, CancelEventArgs e) |
| 126 | + { |
| 127 | + if (_menuItem == null) return; |
| 128 | + // Gray out when there's no real encounter selected (zone |
| 129 | + // node, empty area, etc.) so the user gets the right |
| 130 | + // affordance instead of clicking a no-op item. |
| 131 | + _menuItem.Enabled = GetSelectedEncounter() != null; |
| 132 | + } |
| 133 | + |
| 134 | + private void OnMenuClick(object sender, EventArgs e) |
| 135 | + { |
| 136 | + var enc = GetSelectedEncounter(); |
| 137 | + if (enc == null) return; |
| 138 | + try |
| 139 | + { |
| 140 | + _onUploadClicked(enc); |
| 141 | + } |
| 142 | + catch |
| 143 | + { |
| 144 | + // Plugin's callback is expected to surface failures via |
| 145 | + // SettingsPanel.SetUploadStatus — this is a final |
| 146 | + // safety net so a bug in the upload path never throws |
| 147 | + // an unhandled exception into ACT's UI thread. |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + /// <summary> |
| 152 | + /// Resolve the current TreeView selection to a real |
| 153 | + /// EncounterData, or null if the selection isn't on an |
| 154 | + /// encounter node (zone, empty area, control selected |
| 155 | + /// programmatically with a non-Encounter tag, etc.). |
| 156 | + /// </summary> |
| 157 | + private EncounterData? GetSelectedEncounter() |
| 158 | + { |
| 159 | + var tn = _tvDG?.SelectedNode; |
| 160 | + if (tn == null) return null; |
| 161 | + // Walk up to the EncounterData-tagged node. The user might |
| 162 | + // have right-clicked on a child element (some ACT plugins |
| 163 | + // nest more under each encounter). |
| 164 | + while (tn != null && !"EncounterData".Equals(tn.Tag)) |
| 165 | + { |
| 166 | + tn = tn.Parent; |
| 167 | + } |
| 168 | + if (tn?.Parent == null) return null; |
| 169 | + try |
| 170 | + { |
| 171 | + return ActGlobals.oFormActMain?.ZoneList[tn.Parent.Index].Items[tn.Index]; |
| 172 | + } |
| 173 | + catch |
| 174 | + { |
| 175 | + // Indices can briefly point at stale state if ACT |
| 176 | + // mutates ZoneList during our walk. Treat as "no |
| 177 | + // selection" rather than throwing — the user just |
| 178 | + // right-clicks again. |
| 179 | + return null; |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + /// <summary> |
| 184 | + /// Recursive depth-first walk of the WinForms control tree to |
| 185 | + /// find a child by Name. ACT's encounter tree is several |
| 186 | + /// nested SplitContainers deep so a single Controls[name] |
| 187 | + /// lookup won't find it. |
| 188 | + /// </summary> |
| 189 | + private static Control? FindControl(Control? parent, string name) |
| 190 | + { |
| 191 | + if (parent == null) return null; |
| 192 | + if (parent.Name == name) return parent; |
| 193 | + foreach (Control c in parent.Controls) |
| 194 | + { |
| 195 | + var found = FindControl(c, name); |
| 196 | + if (found != null) return found; |
| 197 | + } |
| 198 | + return null; |
| 199 | + } |
| 200 | + |
| 201 | + public void Dispose() |
| 202 | + { |
| 203 | + try |
| 204 | + { |
| 205 | + _delayedAttach.Stop(); |
| 206 | + _delayedAttach.Dispose(); |
| 207 | + } |
| 208 | + catch { /* swallow */ } |
| 209 | + |
| 210 | + try |
| 211 | + { |
| 212 | + if (_menuItem != null && _menu != null) |
| 213 | + { |
| 214 | + _menuItem.Click -= OnMenuClick; |
| 215 | + _menu.Items.Remove(_menuItem); |
| 216 | + _menuItem.Dispose(); |
| 217 | + } |
| 218 | + if (_openingHandler != null && _menu != null) |
| 219 | + { |
| 220 | + _menu.Opening -= _openingHandler; |
| 221 | + } |
| 222 | + } |
| 223 | + catch |
| 224 | + { |
| 225 | + // Plugin disable/reenable cycle (or ACT shutdown) must |
| 226 | + // not throw. Dead delegates would be ugly but the |
| 227 | + // process is going down anyway in the shutdown case. |
| 228 | + } |
| 229 | + finally |
| 230 | + { |
| 231 | + _menuItem = null; |
| 232 | + _menu = null; |
| 233 | + _openingHandler = null; |
| 234 | + _tvDG = null; |
| 235 | + } |
| 236 | + } |
| 237 | + } |
| 238 | +} |
0 commit comments