From 3806fa3130422f49ecedc15f567fa10e76d84975 Mon Sep 17 00:00:00 2001 From: Duncan Crowson Date: Sun, 26 Apr 2026 05:07:51 -0600 Subject: [PATCH] Added bulk dependency-ignore actions, local mod aliases, and an Ignored filter. Resolves #421. - Added a context menu on the per-mod Required Mods grid: - "Ignore '' for all mods that require it" with a confirmation prompt showing the dependent count, plus a symmetric "Stop ignoring '' for all mods" inverse. - "Find substitute for ''..." opens a picker dialog (filterable, sortable, single-select with an explicit Substitute / Remove substitute button) to declare an installed mod as a substitute for the Workshop dependency. - "Ignore selected (N) on this mod" / inverse, conditional on multi-select in the grid. - Added ModEntry.WorkshopIdAliases (List). When a Workshop dependency cannot be resolved by exact WorkshopID match, GetRequiredMods now also checks WorkshopIdAliases so a locally-installed mod (e.g. a non-Workshop install of LWOTC, or a beta version under a different Workshop ID) can satisfy the dependency. Exact WorkshopID matches always take priority over alias matches via OrderBy. Mirrored in LoadNotInstalledDependencies and the cheap GetDependentMods path. - Added a new "Ignored" filter checkbox in the legend pane to surface mods that have at least one ignored dep. Useful for review and recovery from accidental bulk-ignores. Implemented via a new showIgnoredDepsOnly parameter on ModListFilter. - Routed all dep-state mutations through a new RefreshDependencyState helper that calls RefreshObjects, UpdateStateFilterLabels, and RefreshModelFilter together. Active filters and the legend counters now reflect state changes immediately. Wired into the existing per-row Ignore checkbox handler, the new bulk operations, the alias picker, and the activation-toggle path. - Bottom split container: changed FixedPanel from Panel2 to None so both panels grow proportionally with the form, increased SplitterWidth from 5 to 16 for grabbability, and set Panel1MinSize / Panel2MinSize. --- .../xcom2-launcher/Classes/Mod/ModEntry.cs | 8 + .../xcom2-launcher/Classes/Mod/ModList.cs | 76 +++- .../Classes/Mod/ModListFilter.cs | 10 +- .../xcom2-launcher/Forms/MainForm.Designer.cs | 47 ++- .../xcom2-launcher/Forms/MainForm.Events.cs | 1 + .../xcom2-launcher/Forms/MainForm.ModList.cs | 8 +- .../xcom2-launcher/Forms/MainForm.cs | 349 +++++++++++++++++- 7 files changed, 462 insertions(+), 37 deletions(-) diff --git a/xcom2-launcher/xcom2-launcher/Classes/Mod/ModEntry.cs b/xcom2-launcher/xcom2-launcher/Classes/Mod/ModEntry.cs index be42726..aad673b 100644 --- a/xcom2-launcher/xcom2-launcher/Classes/Mod/ModEntry.cs +++ b/xcom2-launcher/xcom2-launcher/Classes/Mod/ModEntry.cs @@ -87,6 +87,14 @@ public class ModEntry /// public List IgnoredDependencies { get; set; } = new List(); + /// + /// Workshop ids that this installed mod claims to also satisfy. Bridges dependency + /// references when the user has installed a Workshop mod under a custom folder + /// (no publishedfileid) or has installed a beta whose Workshop id differs from the + /// stable id another mod requires. + /// + public List WorkshopIdAliases { get; set; } = new List(); + /// /// Contains the tags that were downloaded from steam. /// diff --git a/xcom2-launcher/xcom2-launcher/Classes/Mod/ModList.cs b/xcom2-launcher/xcom2-launcher/Classes/Mod/ModList.cs index 9f0e5fd..377bda1 100644 --- a/xcom2-launcher/xcom2-launcher/Classes/Mod/ModList.cs +++ b/xcom2-launcher/xcom2-launcher/Classes/Mod/ModList.cs @@ -126,13 +126,62 @@ public void UpdatedModDependencyState(ModEntry mod) { var requiredMods = GetRequiredMods(mod, true, true); var allRequiredModsAvailable = requiredMods.All(m => m.WorkshopID != 0 && m.isActive && !m.State.HasFlag(ModState.NotInstalled) && !m.State.HasFlag(ModState.NotLoaded)); - + if (allRequiredModsAvailable) mod.RemoveState(ModState.MissingDependencies); - else + else mod.AddState(ModState.MissingDependencies); } + /// + /// Adds to on + /// every installed mod whose contains it, then + /// re-evaluates the missing-dependency state for each affected mod. + /// + /// The mods whose IgnoredDependencies was modified (empty if no dependents, + /// or every dependent already ignored it). + public List IgnoreDependencyEverywhere(long workshopId) + { + var affected = new List(); + if (workshopId <= 0) return affected; + + foreach (var mod in All) + { + if (!mod.Dependencies.Contains(workshopId)) continue; + if (mod.IgnoredDependencies.Contains(workshopId)) continue; + + mod.IgnoredDependencies.Add(workshopId); + UpdatedModDependencyState(mod); + affected.Add(mod); + } + + Log.Info($"Bulk-ignored workshop id {workshopId} on {affected.Count} mods."); + return affected; + } + + /// + /// Removes from + /// on every installed mod that currently ignores it, then re-evaluates state. + /// + /// The mods whose IgnoredDependencies was modified. + public List UnignoreDependencyEverywhere(long workshopId) + { + var affected = new List(); + if (workshopId <= 0) return affected; + + foreach (var mod in All) + { + if (mod.IgnoredDependencies.Remove(workshopId)) + { + UpdatedModDependencyState(mod); + affected.Add(mod); + } + } + + Log.Info($"Bulk-unignored workshop id {workshopId} on {affected.Count} mods."); + return affected; + } + public List ImportMods(List modPaths) { Log.Info("Checking mod directories for new mods"); @@ -601,10 +650,13 @@ private async Task> LoadNotInstalledDependencies(List requi foreach (var requiredModId in requiredModIds) { - var result = All.FirstOrDefault(m => m.WorkshopID == requiredModId); + // Exact-WorkshopID match wins over an alias match if both exist. + var result = All + .OrderBy(m => m.WorkshopID == requiredModId ? 0 : 1) + .FirstOrDefault(m => m.WorkshopID == requiredModId || m.WorkshopIdAliases.Contains(requiredModId)); if (result != null) { - // dependency is already installed + // dependency is already installed (or aliased to a local mod) continue; } @@ -676,7 +728,11 @@ public List GetDependentMods(ModEntry mod, bool compareModId = true) return result; } - return All.Where(m => m.Dependencies.Contains(mod.WorkshopID)).ToList(); + // A mod is a dependent if it lists this mod's WorkshopID OR any of its declared aliases. + return All.Where(m => + m.Dependencies.Contains(mod.WorkshopID) || + (mod.WorkshopIdAliases.Count > 0 && mod.WorkshopIdAliases.Any(a => m.Dependencies.Contains(a))) + ).ToList(); } /// @@ -703,10 +759,18 @@ public List GetRequiredMods(ModEntry mod, bool substituteDuplicates = foreach (var id in dependencies) { // Check if required mod is already installed and use it if available. - var result = installedMods.FirstOrDefault(m => m.WorkshopID == id); + // Exact WorkshopID match wins over an alias match (a real Workshop install + // beats a user-declared alias for the same id every time). + var result = installedMods + .OrderBy(m => m.WorkshopID == id ? 0 : 1) + .FirstOrDefault(m => m.WorkshopID == id || m.WorkshopIdAliases.Contains(id)); if (result != null) { + if (result.WorkshopID != id) + { + Log.Info($"Resolved workshop dep {id} via alias on '{result.Name}' (WorkshopID {result.WorkshopID})."); + } // If the required mod is installed but disabled a duplicate, use the primary duplicate if (substituteDuplicates && result.State.HasFlag(ModState.DuplicateDisabled)) { diff --git a/xcom2-launcher/xcom2-launcher/Classes/Mod/ModListFilter.cs b/xcom2-launcher/xcom2-launcher/Classes/Mod/ModListFilter.cs index bfc3e2e..9d47cae 100644 --- a/xcom2-launcher/xcom2-launcher/Classes/Mod/ModListFilter.cs +++ b/xcom2-launcher/xcom2-launcher/Classes/Mod/ModListFilter.cs @@ -12,11 +12,13 @@ class ModListFilter : TextMatchFilter { private readonly List _States; private readonly bool _ShowHiddenMods; + private readonly bool _ShowIgnoredDepsOnly; - public ModListFilter(ObjectListView olv, string text, List states, bool showHiddenMods) : base(olv, text) + public ModListFilter(ObjectListView olv, string text, List states, bool showHiddenMods, bool showIgnoredDepsOnly = false) : base(olv, text) { _States = states; _ShowHiddenMods = showHiddenMods; + _ShowIgnoredDepsOnly = showIgnoredDepsOnly; } public override bool Filter(object modelObject) @@ -53,6 +55,12 @@ public override bool Filter(object modelObject) filterMatch |= mod.isHidden; } + if (_ShowIgnoredDepsOnly) + { + isAdditionalFilterActive = true; + filterMatch |= mod.IgnoredDependencies != null && mod.IgnoredDependencies.Count > 0; + } + if (!isAdditionalFilterActive) { return textMatch; diff --git a/xcom2-launcher/xcom2-launcher/Forms/MainForm.Designer.cs b/xcom2-launcher/xcom2-launcher/Forms/MainForm.Designer.cs index a58766b..68a8bea 100644 --- a/xcom2-launcher/xcom2-launcher/Forms/MainForm.Designer.cs +++ b/xcom2-launcher/xcom2-launcher/Forms/MainForm.Designer.cs @@ -109,6 +109,7 @@ private void InitializeComponent() this.pModsLegend = new System.Windows.Forms.Panel(); this.bClearStateFilter = new System.Windows.Forms.Button(); this.cFilterMissingDependency = new System.Windows.Forms.CheckBox(); + this.cFilterIgnoredDependencies = new System.Windows.Forms.CheckBox(); this.bRefreshStateFilter = new System.Windows.Forms.Button(); this.cFilterHidden = new System.Windows.Forms.CheckBox(); this.cFilterNew = new System.Windows.Forms.CheckBox(); @@ -686,7 +687,7 @@ private void InitializeComponent() // horizontal_splitcontainer // this.horizontal_splitcontainer.Dock = System.Windows.Forms.DockStyle.Fill; - this.horizontal_splitcontainer.FixedPanel = System.Windows.Forms.FixedPanel.Panel2; + this.horizontal_splitcontainer.FixedPanel = System.Windows.Forms.FixedPanel.None; this.horizontal_splitcontainer.Location = new System.Drawing.Point(3, 3); this.horizontal_splitcontainer.Name = "horizontal_splitcontainer"; this.horizontal_splitcontainer.Orientation = System.Windows.Forms.Orientation.Horizontal; @@ -702,7 +703,9 @@ private void InitializeComponent() this.horizontal_splitcontainer.Panel2.Controls.Add(this.modinfo_groupbox); this.horizontal_splitcontainer.Size = new System.Drawing.Size(970, 656); this.horizontal_splitcontainer.SplitterDistance = 382; - this.horizontal_splitcontainer.SplitterWidth = 5; + this.horizontal_splitcontainer.SplitterWidth = 16; + this.horizontal_splitcontainer.Panel1MinSize = 100; + this.horizontal_splitcontainer.Panel2MinSize = 150; this.horizontal_splitcontainer.TabIndex = 5; // // modlist_ListObjectListView @@ -948,6 +951,7 @@ private void InitializeComponent() // this.pModsLegend.Controls.Add(this.bClearStateFilter); this.pModsLegend.Controls.Add(this.cFilterMissingDependency); + this.pModsLegend.Controls.Add(this.cFilterIgnoredDependencies); this.pModsLegend.Controls.Add(this.bRefreshStateFilter); this.pModsLegend.Controls.Add(this.cFilterHidden); this.pModsLegend.Controls.Add(this.cFilterNew); @@ -963,7 +967,7 @@ private void InitializeComponent() // // bClearStateFilter // - this.bClearStateFilter.Location = new System.Drawing.Point(906, 3); + this.bClearStateFilter.Location = new System.Drawing.Point(940, 3); this.bClearStateFilter.Name = "bClearStateFilter"; this.bClearStateFilter.Size = new System.Drawing.Size(60, 25); this.bClearStateFilter.TabIndex = 16; @@ -981,18 +985,35 @@ private void InitializeComponent() this.cFilterMissingDependency.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); this.cFilterMissingDependency.Location = new System.Drawing.Point(239, 3); this.cFilterMissingDependency.Name = "cFilterMissingDependency"; - this.cFilterMissingDependency.Size = new System.Drawing.Size(173, 24); + this.cFilterMissingDependency.Size = new System.Drawing.Size(105, 24); this.cFilterMissingDependency.TabIndex = 15; - this.cFilterMissingDependency.Text = "Missing dependency"; + this.cFilterMissingDependency.Text = "Missing dep"; this.cFilterMissingDependency.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; this.toolTip.SetToolTip(this.cFilterMissingDependency, "A mod will indicate a \"Missing dependency\", when not all required mods\r\nare enabl" + "ed or installed. Check \"Mod Info -> Dependency Tab\"."); this.cFilterMissingDependency.UseVisualStyleBackColor = false; this.cFilterMissingDependency.CheckedChanged += new System.EventHandler(this.cStateFilter_CheckedChanged); - // + // + // cFilterIgnoredDependencies + // + this.cFilterIgnoredDependencies.Appearance = System.Windows.Forms.Appearance.Button; + this.cFilterIgnoredDependencies.BackColor = System.Drawing.Color.LightYellow; + this.cFilterIgnoredDependencies.FlatAppearance.BorderColor = System.Drawing.Color.DimGray; + this.cFilterIgnoredDependencies.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.cFilterIgnoredDependencies.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.cFilterIgnoredDependencies.Location = new System.Drawing.Point(348, 3); + this.cFilterIgnoredDependencies.Name = "cFilterIgnoredDependencies"; + this.cFilterIgnoredDependencies.Size = new System.Drawing.Size(100, 24); + this.cFilterIgnoredDependencies.TabIndex = 17; + this.cFilterIgnoredDependencies.Text = "Ignored (0)"; + this.cFilterIgnoredDependencies.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + this.toolTip.SetToolTip(this.cFilterIgnoredDependencies, "Show only mods that have one or more dependencies the user has marked as ignored.\r\nUse this to find and review what was bulk-ignored."); + this.cFilterIgnoredDependencies.UseVisualStyleBackColor = false; + this.cFilterIgnoredDependencies.CheckedChanged += new System.EventHandler(this.cStateFilter_CheckedChanged); + // // bRefreshStateFilter - // - this.bRefreshStateFilter.Location = new System.Drawing.Point(840, 3); + // + this.bRefreshStateFilter.Location = new System.Drawing.Point(874, 3); this.bRefreshStateFilter.Name = "bRefreshStateFilter"; this.bRefreshStateFilter.Size = new System.Drawing.Size(60, 25); this.bRefreshStateFilter.TabIndex = 14; @@ -1009,7 +1030,7 @@ private void InitializeComponent() this.cFilterHidden.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.cFilterHidden.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); this.cFilterHidden.ForeColor = System.Drawing.Color.Gray; - this.cFilterHidden.Location = new System.Drawing.Point(641, 3); + this.cFilterHidden.Location = new System.Drawing.Point(675, 3); this.cFilterHidden.Name = "cFilterHidden"; this.cFilterHidden.Size = new System.Drawing.Size(94, 24); this.cFilterHidden.TabIndex = 13; @@ -1026,7 +1047,7 @@ private void InitializeComponent() this.cFilterNew.FlatAppearance.BorderColor = System.Drawing.Color.DimGray; this.cFilterNew.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.cFilterNew.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.cFilterNew.Location = new System.Drawing.Point(741, 3); + this.cFilterNew.Location = new System.Drawing.Point(775, 3); this.cFilterNew.Name = "cFilterNew"; this.cFilterNew.Size = new System.Drawing.Size(86, 24); this.cFilterNew.TabIndex = 12; @@ -1043,7 +1064,7 @@ private void InitializeComponent() this.cFilterConflicted.FlatAppearance.BorderColor = System.Drawing.Color.DimGray; this.cFilterConflicted.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.cFilterConflicted.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.cFilterConflicted.Location = new System.Drawing.Point(418, 3); + this.cFilterConflicted.Location = new System.Drawing.Point(452, 3); this.cFilterConflicted.Name = "cFilterConflicted"; this.cFilterConflicted.Size = new System.Drawing.Size(106, 24); this.cFilterConflicted.TabIndex = 11; @@ -1097,7 +1118,7 @@ private void InitializeComponent() this.cFilterDuplicate.FlatAppearance.BorderColor = System.Drawing.Color.DimGray; this.cFilterDuplicate.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.cFilterDuplicate.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.cFilterDuplicate.Location = new System.Drawing.Point(530, 3); + this.cFilterDuplicate.Location = new System.Drawing.Point(564, 3); this.cFilterDuplicate.Name = "cFilterDuplicate"; this.cFilterDuplicate.Size = new System.Drawing.Size(105, 24); this.cFilterDuplicate.TabIndex = 8; @@ -1781,6 +1802,7 @@ private void InitializeComponent() this.olvRequiredMods.View = System.Windows.Forms.View.Details; this.olvRequiredMods.FormatRow += new System.EventHandler(this.olvRequiredMods_FormatRow); this.olvRequiredMods.ItemActivate += new System.EventHandler(this.olvDependencyMods_ItemActivate); + this.olvRequiredMods.CellRightClick += new System.EventHandler(this.RequiredModsCellRightClick); // // olvColReqModsActive // @@ -2447,6 +2469,7 @@ private void InitializeComponent() private System.Windows.Forms.Button bRefreshStateFilter; private System.Windows.Forms.CheckBox cFilterHidden; private System.Windows.Forms.CheckBox cFilterMissingDependency; + private System.Windows.Forms.CheckBox cFilterIgnoredDependencies; private System.Windows.Forms.Panel panel6; private System.Windows.Forms.CheckBox cShowPrimaryDuplicates; private BrightIdeasSoftware.OLVColumn olvColReqModsIgnore; diff --git a/xcom2-launcher/xcom2-launcher/Forms/MainForm.Events.cs b/xcom2-launcher/xcom2-launcher/Forms/MainForm.Events.cs index 349c342..88e14ed 100644 --- a/xcom2-launcher/xcom2-launcher/Forms/MainForm.Events.cs +++ b/xcom2-launcher/xcom2-launcher/Forms/MainForm.Events.cs @@ -858,6 +858,7 @@ private void bClearStateFilter_Click(object sender, EventArgs e) cFilterDuplicate.Checked = false; cFilterHidden.Checked = false; cFilterMissingDependency.Checked = false; + cFilterIgnoredDependencies.Checked = false; cFilterNew.Checked = false; cFilterNotInstalled.Checked = false; cFilterNotLoaded.Checked = false; diff --git a/xcom2-launcher/xcom2-launcher/Forms/MainForm.ModList.cs b/xcom2-launcher/xcom2-launcher/Forms/MainForm.ModList.cs index d41e81c..4e0e3e4 100644 --- a/xcom2-launcher/xcom2-launcher/Forms/MainForm.ModList.cs +++ b/xcom2-launcher/xcom2-launcher/Forms/MainForm.ModList.cs @@ -689,7 +689,12 @@ private void RefreshModelFilter() stateFlags.Add(ModState.MissingDependencies); } - modlist_ListObjectListView.ModelFilter = new ModListFilter(modlist_ListObjectListView, modlist_FilterCueTextBox.Text, stateFlags, cFilterHidden.Checked); + modlist_ListObjectListView.ModelFilter = new ModListFilter( + modlist_ListObjectListView, + modlist_FilterCueTextBox.Text, + stateFlags, + cFilterHidden.Checked, + cFilterIgnoredDependencies.Checked); } /// @@ -1342,6 +1347,7 @@ void ProcessModListItemCheckChanged(ModEntry modChecked) } UpdateStateFilterLabels(); + RefreshModelFilter(); UpdateLabels(); UpdateDependencyInformation(ModList.SelectedObject); } diff --git a/xcom2-launcher/xcom2-launcher/Forms/MainForm.cs b/xcom2-launcher/xcom2-launcher/Forms/MainForm.cs index 53de40d..3a3786a 100644 --- a/xcom2-launcher/xcom2-launcher/Forms/MainForm.cs +++ b/xcom2-launcher/xcom2-launcher/Forms/MainForm.cs @@ -12,6 +12,7 @@ using XCOM2Launcher.XCOM; using JR.Utils.GUI.Forms; using XCOM2Launcher.Classes.Mod; +using XCOM2Launcher.UserElements; namespace XCOM2Launcher.Forms { @@ -481,7 +482,8 @@ private void UpdateStateFilterLabels() cFilterNew.Text = $"New ({allMods.Count(m => m.State.HasFlag(ModState.New))})"; cFilterNotInstalled.Text = $"Not installed ({allMods.Count(m => m.State.HasFlag(ModState.NotInstalled))})"; cFilterNotLoaded.Text = $"Not loaded ({allMods.Count(m => m.State.HasFlag(ModState.NotLoaded))})"; - cFilterMissingDependency.Text = $"Missing dependencies ({allMods.Count(m => m.isActive && m.State.HasFlag(ModState.MissingDependencies))})"; + cFilterMissingDependency.Text = $"Missing dep ({allMods.Count(m => m.isActive && m.State.HasFlag(ModState.MissingDependencies))})"; + cFilterIgnoredDependencies.Text = $"Ignored ({allMods.Count(m => m.IgnoredDependencies != null && m.IgnoredDependencies.Count > 0)})"; cFilterHidden.Text = $"Hidden ({allMods.Count(m => m.isHidden)})"; } @@ -817,24 +819,11 @@ private void InitDependencyListViews() if (CurrentMod == null || !(rowObject is ModEntry mod) || !(value is bool checkState)) return; - // Add the mod id to or remove it from the IgnoredDependencies list. - if (checkState) + if (IgnoreDependencyOnMod(CurrentMod, mod.WorkshopID, checkState)) { - if (!CurrentMod.IgnoredDependencies.Contains(mod.WorkshopID)) - { - CurrentMod.IgnoredDependencies.Add(mod.WorkshopID); - } - } - else - { - if (CurrentMod.IgnoredDependencies.Contains(mod.WorkshopID)) - { - CurrentMod.IgnoredDependencies.Remove(mod.WorkshopID); - } + Mods.UpdatedModDependencyState(CurrentMod); + RefreshDependencyState(new[] { CurrentMod }); } - - Mods.UpdatedModDependencyState(CurrentMod); - modlist_ListObjectListView.RefreshObject(CurrentMod); }; olvRequiredMods.SubItemChecking += (sender, args) => @@ -885,6 +874,332 @@ private void olvRequiredMods_FormatRow(object sender, FormatRowEventArgs e) SetModListItemColor(e.Item, mod); } + /// + /// Adds (ignore=true) or removes (ignore=false) to/from + /// 's IgnoredDependencies. Caller is responsible for the state + /// recompute and refresh, so multiple calls can batch into a single recompute. + /// + /// true iff the list was modified. + private bool IgnoreDependencyOnMod(ModEntry mod, long workshopId, bool ignore) + { + if (mod == null || workshopId <= 0) return false; + + if (ignore) + { + if (mod.IgnoredDependencies.Contains(workshopId)) return false; + mod.IgnoredDependencies.Add(workshopId); + return true; + } + + return mod.IgnoredDependencies.Remove(workshopId); + } + + private void BulkIgnoreOnCurrentMod(ModEntry mod, IList selectedDeps, bool ignore) + { + if (mod == null || selectedDeps == null || selectedDeps.Count == 0) return; + + var changed = new List(); + foreach (var dep in selectedDeps) + { + if (dep == null || dep.WorkshopID <= 0) continue; + if (IgnoreDependencyOnMod(mod, dep.WorkshopID, ignore)) + changed.Add(dep); + } + + if (changed.Count == 0) return; + + Mods.UpdatedModDependencyState(mod); + RefreshDependencyState(new[] { mod }); + olvRequiredMods.RefreshObjects(changed); + } + + private void IgnoreDependencyEverywhereWithConfirm(ModEntry dep, bool ignore) + { + if (dep == null || dep.WorkshopID <= 0) return; + + if (ignore) + { + var dependents = Mods.GetDependentMods(dep, false); + var count = dependents.Count; + if (count == 0) return; + + var prompt = count == 1 && dependents[0] != null + ? $"Ignore '{dep.Name}' as a dependency on '{dependents[0].Name}'?" + : $"Ignore '{dep.Name}' on the {count} mods that require it?"; + + if (FlexibleMessageBox.Show(this, prompt, "Confirm bulk ignore", MessageBoxButtons.YesNo) != DialogResult.Yes) + return; + } + + var affected = ignore + ? Mods.IgnoreDependencyEverywhere(dep.WorkshopID) + : Mods.UnignoreDependencyEverywhere(dep.WorkshopID); + + if (affected.Count == 0) return; + + RefreshDependencyState(affected); + olvRequiredMods.RefreshObject(dep); + } + + /// + /// Opens a modal picker letting the user toggle which installed mod(s) substitute + /// (alias) for the given Workshop dep. Filter-as-you-type, sortable columns, checkbox + /// column for current alias state. On close, recomputes dep state for every mod that + /// listed the dep's WorkshopID and refreshes the UI once. + /// + private void OpenAliasPicker(ModEntry dep) + { + if (dep == null || dep.WorkshopID <= 0) return; + + var depId = dep.WorkshopID; + var candidates = Mods.All + .Where(m => m != null && m.WorkshopID != depId) + .OrderBy(m => m.Source == ModSource.SteamWorkshop ? 1 : 0) + .ThenBy(m => m.Name ?? m.ID ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToList(); + + using (var picker = new System.Windows.Forms.Form()) + { + picker.Text = $"Substitute for '{dep.Name}' (Workshop ID {depId})"; + picker.Size = new System.Drawing.Size(640, 520); + picker.StartPosition = FormStartPosition.CenterParent; + picker.MinimumSize = new System.Drawing.Size(420, 320); + picker.ShowInTaskbar = false; + picker.MinimizeBox = false; + picker.MaximizeBox = false; + + var instructions = new Label + { + Text = "Pick a mod that should satisfy this Workshop dependency, then click Substitute. Click again on the same row (now showing 'Yes') to remove. A real Workshop install of this id always wins over a substitute.", + Dock = DockStyle.Top, + Padding = new Padding(8, 8, 8, 4), + Height = 56, + AutoEllipsis = true + }; + + var filterBox = new CueTextBox + { + Dock = DockStyle.Top, + Margin = new Padding(8, 4, 8, 4), + CueText = "Filter mods (e.g. \"lwotc\", \"highlander\")...", + ShowCueTextWithFocus = true + }; + + var olv = new ObjectListView + { + Dock = DockStyle.Fill, + FullRowSelect = true, + UseFiltering = true, + UseAlternatingBackColors = true, + AlternateRowBackColor = System.Drawing.Color.WhiteSmoke, + View = System.Windows.Forms.View.Details, + UseCompatibleStateImageBehavior = false, + HeaderStyle = ColumnHeaderStyle.Clickable, + OwnerDraw = false, + HasCollapsibleGroups = false, + ShowGroups = false + }; + + var nameCol = new OLVColumn("Mod", "Name") { Width = 280, Sortable = true }; + var sourceCol = new OLVColumn("Source", null) { Width = 100, Sortable = true }; + sourceCol.AspectGetter = m => + { + var entry = m as ModEntry; + if (entry == null) return string.Empty; + if (entry.Source == ModSource.SteamWorkshop) return "Workshop"; + if (entry.Source == ModSource.Manual) return "Local / Manual"; + return "Unknown"; + }; + var workshopIdCol = new OLVColumn("Workshop ID", null) { Width = 110, Sortable = true }; + workshopIdCol.AspectGetter = m => + { + var entry = m as ModEntry; + if (entry == null || entry.WorkshopID <= 0) return "(none)"; + return entry.WorkshopID.ToString(); + }; + var aliasCol = new OLVColumn("Substitutes?", null) { Width = 100, Sortable = true }; + aliasCol.AspectGetter = m => + { + var entry = m as ModEntry; + return entry != null && entry.WorkshopIdAliases.Contains(depId) ? "Yes" : ""; + }; + + olv.AllColumns.Add(nameCol); + olv.AllColumns.Add(sourceCol); + olv.AllColumns.Add(workshopIdCol); + olv.AllColumns.Add(aliasCol); + olv.Columns.Add(nameCol); + olv.Columns.Add(sourceCol); + olv.Columns.Add(workshopIdCol); + olv.Columns.Add(aliasCol); + olv.MultiSelect = false; + olv.SetObjects(candidates); + olv.Sort(nameCol, SortOrder.Ascending); + + filterBox.TextChanged += (s, a) => + { + olv.AdditionalFilter = string.IsNullOrEmpty(filterBox.Text) + ? null + : TextMatchFilter.Contains(olv, filterBox.Text); + olv.UpdateColumnFiltering(); + }; + + var bottomPanel = new Panel { Dock = DockStyle.Bottom, Height = 50 }; + var substituteButton = new Button + { + Text = "Substitute", + Enabled = false, + Anchor = AnchorStyles.Right | AnchorStyles.Top, + Size = new System.Drawing.Size(140, 30) + }; + var closeButton = new Button + { + Text = "Close", + DialogResult = DialogResult.OK, + Anchor = AnchorStyles.Right | AnchorStyles.Top, + Size = new System.Drawing.Size(100, 30) + }; + + Action layoutButtons = () => + { + closeButton.Location = new System.Drawing.Point(bottomPanel.ClientSize.Width - 116, 10); + substituteButton.Location = new System.Drawing.Point(bottomPanel.ClientSize.Width - 264, 10); + }; + layoutButtons(); + bottomPanel.Resize += (s, a) => layoutButtons(); + bottomPanel.Controls.Add(substituteButton); + bottomPanel.Controls.Add(closeButton); + + Action updateButton = () => + { + var sel = olv.SelectedObject as ModEntry; + if (sel == null) + { + substituteButton.Enabled = false; + substituteButton.Text = "Substitute"; + } + else + { + substituteButton.Enabled = true; + substituteButton.Text = sel.WorkshopIdAliases.Contains(depId) + ? $"Remove substitute" + : $"Substitute"; + } + }; + olv.SelectionChanged += (s, a) => updateButton(); + updateButton(); + + substituteButton.Click += (s, a) => + { + var sel = olv.SelectedObject as ModEntry; + if (sel == null) return; + + if (sel.WorkshopIdAliases.Contains(depId)) + { + sel.WorkshopIdAliases.Remove(depId); + Log.Info($"Removed alias {depId} ('{dep.Name}') from '{sel.Name}'."); + } + else + { + sel.WorkshopIdAliases.Add(depId); + Log.Info($"Added alias {depId} ('{dep.Name}') on '{sel.Name}'."); + } + olv.RefreshObject(sel); + updateButton(); + }; + + picker.AcceptButton = closeButton; + picker.Controls.Add(olv); + picker.Controls.Add(filterBox); + picker.Controls.Add(instructions); + picker.Controls.Add(bottomPanel); + + picker.ShowDialog(this); + } + + // Single batch state recompute + UI refresh after the picker closes. + var affectedDependents = Mods.All + .Where(m => m.Dependencies.Contains(depId)) + .ToList(); + + foreach (var m in affectedDependents) + Mods.UpdatedModDependencyState(m); + + RefreshDependencyState(affectedDependents); + olvRequiredMods.RefreshObject(dep); + } + + /// + /// Common refresh tail for any operation that mutates a mod's dependency-state-flags. + /// Re-renders affected rows in the main list, recomputes the filter-label counters, + /// and re-applies the active filter so a row stops appearing under e.g. "Missing + /// dependencies" the moment its state actually changes. + /// + private void RefreshDependencyState(IEnumerable affected) + { + if (affected != null) + { + var list = affected as List ?? affected.ToList(); + if (list.Count > 0) + modlist_ListObjectListView.RefreshObjects(list); + } + UpdateStateFilterLabels(); + RefreshModelFilter(); + } + + private void RequiredModsCellRightClick(object sender, CellRightClickEventArgs e) + { + if (CurrentMod == null) return; + + var rightClicked = e.Model as ModEntry; + if (rightClicked == null || rightClicked.WorkshopID <= 0) return; + + var selected = olvRequiredMods.SelectedObjects.Cast().Where(m => m != null).ToList(); + var menu = CreateRequiredModsContextMenu(CurrentMod, rightClicked, selected); + if (menu.Items.Count == 0) return; + + menu.Show(e.ListView, e.Location); + } + + private ContextMenuStrip CreateRequiredModsContextMenu(ModEntry currentMod, ModEntry dep, IList selectedDeps) + { + var menu = new ContextMenuStrip(); + if (currentMod == null || dep == null || dep.WorkshopID <= 0) + return menu; + + // Phase 3 / Feature B — open a real picker dialog so the user can search & sort. + var findSubstitute = new ToolStripMenuItem($"Find substitute for '{dep.Name}'…"); + findSubstitute.Click += (s, a) => OpenAliasPicker(dep); + menu.Items.Add(findSubstitute); + + menu.Items.Add(new ToolStripSeparator()); + + // Phase 2 / Feature A — cross-mod ignore toggle for the right-clicked dep. + var ignoreAll = new ToolStripMenuItem($"Ignore '{dep.Name}' for all mods that require it"); + ignoreAll.Click += (s, a) => IgnoreDependencyEverywhereWithConfirm(dep, true); + menu.Items.Add(ignoreAll); + + var stopIgnoreAll = new ToolStripMenuItem($"Stop ignoring '{dep.Name}' for all mods"); + stopIgnoreAll.Click += (s, a) => IgnoreDependencyEverywhereWithConfirm(dep, false); + menu.Items.Add(stopIgnoreAll); + + // Phase 1 / Feature C — bulk on selection for the currently-viewed mod. + if (selectedDeps != null && selectedDeps.Count > 1) + { + menu.Items.Add(new ToolStripSeparator()); + + var ignoreSelected = new ToolStripMenuItem($"Ignore selected ({selectedDeps.Count}) on this mod"); + ignoreSelected.Click += (s, a) => BulkIgnoreOnCurrentMod(currentMod, selectedDeps, true); + menu.Items.Add(ignoreSelected); + + var unignoreSelected = new ToolStripMenuItem($"Stop ignoring selected ({selectedDeps.Count}) on this mod"); + unignoreSelected.Click += (s, a) => BulkIgnoreOnCurrentMod(currentMod, selectedDeps, false); + menu.Items.Add(unignoreSelected); + } + + return menu; + } + #endregion } } \ No newline at end of file