using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using CheapLoc; using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal.Types; namespace Dalamud.Interface.Internal; /// /// Manage category filters for PluginInstallerWindow. /// internal class PluginCategoryManager { /// /// First categoryId for tag based categories. /// private const int FirstTagBasedCategoryId = 100; private readonly CategoryInfo[] categoryList = [ new(CategoryKind.All, "special.all", () => Locs.Category_All), new(CategoryKind.IsTesting, "special.isTesting", () => Locs.Category_IsTesting, CategoryInfo.AppearCondition.DoPluginTest), new(CategoryKind.AvailableForTesting, "special.availableForTesting", () => Locs.Category_AvailableForTesting, CategoryInfo.AppearCondition.DoPluginTest), new(CategoryKind.Hidden, "special.hidden", () => Locs.Category_Hidden, CategoryInfo.AppearCondition.AnyHiddenPlugins), new(CategoryKind.DevInstalled, "special.devInstalled", () => Locs.Category_DevInstalled), new(CategoryKind.IconTester, "special.devIconTester", () => Locs.Category_IconTester), new(CategoryKind.DalamudChangelogs, "special.dalamud", () => Locs.Category_Dalamud), new(CategoryKind.PluginChangelogs, "special.plugins", () => Locs.Category_Plugins), new(CategoryKind.PluginProfiles, "special.profiles", () => Locs.Category_PluginProfiles), new(CategoryKind.UpdateablePlugins, "special.updateable", () => Locs.Category_UpdateablePlugins), // Tag-driven categories new(CategoryKind.Other, "other", () => Locs.Category_Other), new(CategoryKind.Jobs, "jobs", () => Locs.Category_Jobs), new(CategoryKind.Ui, "ui", () => Locs.Category_UI), new(CategoryKind.MiniGames, "minigames", () => Locs.Category_MiniGames), new(CategoryKind.Inventory, "inventory", () => Locs.Category_Inventory), new(CategoryKind.Sound, "sound", () => Locs.Category_Sound), new(CategoryKind.Social, "social", () => Locs.Category_Social), new(CategoryKind.Utility, "utility", () => Locs.Category_Utility) // order doesn't matter, all tag driven categories should have Id >= FirstTagBasedCategoryId ]; private GroupInfo[] groupList = [ new(GroupKind.DevTools, () => Locs.Group_DevTools, CategoryKind.DevInstalled, CategoryKind.IconTester), new(GroupKind.Installed, () => Locs.Group_Installed, CategoryKind.All, CategoryKind.IsTesting, CategoryKind.UpdateablePlugins, CategoryKind.PluginProfiles), new(GroupKind.Available, () => Locs.Group_Available, CategoryKind.All), new(GroupKind.Changelog, () => Locs.Group_Changelog, CategoryKind.All, CategoryKind.DalamudChangelogs, CategoryKind.PluginChangelogs) // order important, used for drawing, keep in sync with defaults for currentGroupIdx ]; private int currentGroupIdx = 2; private CategoryKind currentCategoryKind = CategoryKind.All; private bool isContentDirty; private Dictionary mapPluginCategories = new(); private List highlightedCategoryKinds = new(); /// /// Type of category group. /// public enum GroupKind { /// /// UI group: dev mode only. /// DevTools, /// /// UI group: installed plugins. /// Installed, /// /// UI group: plugins that can be installed. /// Available, /// /// UI group: changelog of plugins. /// Changelog, } /// /// Type of category. /// public enum CategoryKind { /// /// All plugins. /// All = 0, /// /// Plugins currently being tested. /// IsTesting = 1, /// /// Plugins available for testing. /// AvailableForTesting = 2, /// /// Plugins that were hidden. /// Hidden = 3, /// /// Installed dev plugins. /// DevInstalled = 10, /// /// Icon tester. /// IconTester = 11, /// /// Changelogs for Dalamud. /// DalamudChangelogs = 12, /// /// Changelogs for plugins. /// PluginChangelogs = 13, /// /// Change plugin profiles. /// PluginProfiles = 14, /// /// Updateable plugins. /// UpdateablePlugins = 15, /// /// Plugins tagged as "other". /// Other = FirstTagBasedCategoryId + 0, /// /// Plugins tagged as "jobs". /// Jobs = FirstTagBasedCategoryId + 1, /// /// Plugins tagged as "ui". /// Ui = FirstTagBasedCategoryId + 2, /// /// Plugins tagged as "minigames". /// MiniGames = FirstTagBasedCategoryId + 3, /// /// Plugins tagged as "inventory". /// Inventory = FirstTagBasedCategoryId + 4, /// /// Plugins tagged as "sound". /// Sound = FirstTagBasedCategoryId + 5, /// /// Plugins tagged as "social". /// Social = FirstTagBasedCategoryId + 6, /// /// Plugins tagged as "utility". /// Utility = FirstTagBasedCategoryId + 7, } /// /// Gets the list of all known categories. /// public CategoryInfo[] CategoryList => this.categoryList; /// /// Gets the list of all known UI groups. /// public GroupInfo[] GroupList => this.groupList; /// /// Gets or sets the current group kind. /// public GroupKind CurrentGroupKind { get => this.groupList[this.currentGroupIdx].GroupKind; set { var newIdx = Array.FindIndex(this.groupList, x => x.GroupKind == value); if (newIdx >= 0) { this.currentGroupIdx = newIdx; this.currentCategoryKind = this.CurrentGroup.Categories.First(); this.isContentDirty = true; } } } /// /// Gets information about currently selected group. /// public GroupInfo CurrentGroup => this.groupList[this.currentGroupIdx]; /// /// Gets or sets the current category kind. /// public CategoryKind CurrentCategoryKind { get => this.currentCategoryKind; set { if (this.currentCategoryKind != value) { this.currentCategoryKind = value; this.isContentDirty = true; } } } /// /// Gets information about currently selected category. /// public CategoryInfo CurrentCategory => this.categoryList.First(x => x.CategoryKind == this.currentCategoryKind); /// /// Gets a value indicating whether current group + category selection changed recently. /// Changes in Available group should be followed with , everything else can use . /// public bool IsContentDirty => this.isContentDirty; /// /// Gets a value indicating whether and are valid. /// public bool IsSelectionValid => (this.currentGroupIdx >= 0) && (this.currentGroupIdx < this.groupList.Length) && this.groupList[this.currentGroupIdx].Categories.Contains(this.currentCategoryKind); /// /// Rebuild available categories based on currently available plugins. /// /// list of all available plugin manifests to install. public void BuildCategories(IEnumerable availablePlugins) { // rebuild map plugin name -> categoryIds this.mapPluginCategories.Clear(); var groupAvail = Array.Find(this.groupList, x => x.GroupKind == GroupKind.Available); var prevCategoryIds = new List(); prevCategoryIds.AddRange(groupAvail.Categories); var categoryList = new List(); var allCategoryIndices = new List(); foreach (var manifest in availablePlugins) { categoryList.Clear(); var pluginCategoryTags = this.GetCategoryTagsForManifest(manifest); if (pluginCategoryTags != null) { foreach (var tag in pluginCategoryTags) { // only tags from whitelist can be accepted var matchIdx = Array.FindIndex(this.CategoryList, x => x.Tag.Equals(tag, StringComparison.InvariantCultureIgnoreCase)); if (matchIdx >= 0) { var categoryKind = this.CategoryList[matchIdx].CategoryKind; if ((int)categoryKind >= FirstTagBasedCategoryId) { categoryList.Add(categoryKind); if (!allCategoryIndices.Contains(matchIdx)) { allCategoryIndices.Add(matchIdx); } } } } } if (manifest.IsTestingExclusive || manifest.IsAvailableForTesting) categoryList.Add(CategoryKind.AvailableForTesting); // always add, even if empty this.mapPluginCategories.Add(manifest, categoryList.ToArray()); } // sort all categories by their loc name allCategoryIndices.Sort((idxX, idxY) => this.CategoryList[idxX].Name.CompareTo(this.CategoryList[idxY].Name)); allCategoryIndices.Insert(0, 2); // "Available for testing" // rebuild all categories in group, leaving first entry = All intact and always on top if (groupAvail.Categories.Count > 1) { groupAvail.Categories.RemoveRange(1, groupAvail.Categories.Count - 1); } foreach (var categoryIdx in allCategoryIndices) { groupAvail.Categories.Add(this.CategoryList[categoryIdx].CategoryKind); } // Hidden at the end groupAvail.Categories.Add(CategoryKind.Hidden); // compare with prev state and mark as dirty if needed var noCategoryChanges = prevCategoryIds.SequenceEqual(groupAvail.Categories); if (!noCategoryChanges) { this.isContentDirty = true; } } /// /// Filters list of available plugins based on currently selected category. /// Resets . /// /// List of available plugins to install. /// Filtered list of plugins. public List GetCurrentCategoryContent(IEnumerable plugins) { var result = new List(); if (this.IsSelectionValid) { var groupInfo = this.groupList[this.currentGroupIdx]; var includeAll = this.currentCategoryKind == CategoryKind.All || this.currentCategoryKind == CategoryKind.Hidden || groupInfo.GroupKind != GroupKind.Available; if (includeAll) { result.AddRange(plugins); } else { var selectedCategoryInfo = Array.Find(this.categoryList, x => x.CategoryKind == this.currentCategoryKind); foreach (var plugin in plugins) { if (this.mapPluginCategories.TryGetValue(plugin, out var pluginCategoryIds)) { var matchIdx = Array.IndexOf(pluginCategoryIds, selectedCategoryInfo.CategoryKind); if (matchIdx >= 0) { result.Add(plugin); } } } } } this.ResetContentDirty(); return result; } /// /// Clears flag, indicating that all cached values about currently selected group + category have been updated. /// public void ResetContentDirty() { this.isContentDirty = false; } /// /// Sets category highlight based on list of plugins. Used for searching. /// /// List of plugins whose categories should be highlighted. public void SetCategoryHighlightsForPlugins(IEnumerable plugins) { ArgumentNullException.ThrowIfNull(plugins); this.highlightedCategoryKinds.Clear(); foreach (var entry in plugins) { if (this.mapPluginCategories.TryGetValue(entry, out var pluginCategories)) { foreach (var categoryKind in pluginCategories) { if (!this.highlightedCategoryKinds.Contains(categoryKind)) { this.highlightedCategoryKinds.Add(categoryKind); } } } } } /// /// Checks if category should be highlighted. /// /// CategoryKind to check. /// true if highlight is needed. public bool IsCategoryHighlighted(CategoryKind categoryKind) => this.highlightedCategoryKinds.Contains(categoryKind); private IEnumerable GetCategoryTagsForManifest(PluginManifest pluginManifest) { if (pluginManifest.CategoryTags != null) { return pluginManifest.CategoryTags; } return null; } /// /// Plugin installer category info. /// public struct CategoryInfo { /// /// Unique Id number of category, tag match based should be greater of equal . /// public CategoryKind CategoryKind; /// /// Tag from plugin manifest to match. /// public string Tag; private Func nameFunc; /// /// Initializes a new instance of the struct. /// /// Kind of the category. /// Tag to match. /// Function returning localized name of category. /// Condition to be checked when deciding whether this category should be shown. public CategoryInfo(CategoryKind categoryKind, string tag, Func nameFunc, AppearCondition condition = AppearCondition.None) { this.CategoryKind = categoryKind; this.Tag = tag; this.nameFunc = nameFunc; this.Condition = condition; } /// /// Conditions for categories. /// public enum AppearCondition { /// /// Check no conditions. /// None, /// /// Check if plugin testing is enabled. /// DoPluginTest, /// /// Check if there are any hidden plugins. /// AnyHiddenPlugins, } /// /// Gets or sets the condition to be checked when rendering. /// public AppearCondition Condition { get; set; } /// /// Gets the name of category. /// public string Name => this.nameFunc(); } /// /// Plugin installer UI group, a container for categories. /// public struct GroupInfo { /// /// Type of group. /// public GroupKind GroupKind; /// /// List of categories in container. /// public List Categories; private Func nameFunc; /// /// Initializes a new instance of the struct. /// /// Type of group. /// Function returning localized name of category. /// List of category Ids to hardcode. public GroupInfo(GroupKind groupKind, Func nameFunc, params CategoryKind[] categories) { this.GroupKind = groupKind; this.nameFunc = nameFunc; this.Categories = new(); this.Categories.AddRange(categories); } /// /// Gets the name of UI group. /// public string Name => this.nameFunc(); } [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "locs")] internal static class Locs { #region UI groups public static string Group_DevTools => Loc.Localize("InstallerDevTools", "Dev Tools"); public static string Group_Installed => Loc.Localize("InstallerInstalledPlugins", "Installed Plugins"); public static string Group_Available => Loc.Localize("InstallerAllPlugins", "All Plugins"); public static string Group_Changelog => Loc.Localize("InstallerChangelog", "Changelog"); #endregion #region Categories public static string Category_All => Loc.Localize("InstallerCategoryAll", "All"); public static string Category_IsTesting => Loc.Localize("InstallerCategoryIsTesting", "Currently Testing"); public static string Category_AvailableForTesting => Loc.Localize("InstallerCategoryAvailableForTesting", "Testing Available"); public static string Category_Hidden => Loc.Localize("InstallerCategoryHidden", "Hidden"); public static string Category_DevInstalled => Loc.Localize("InstallerInstalledDevPlugins", "Installed Dev Plugins"); public static string Category_IconTester => "Image/Icon Tester"; public static string Category_PluginProfiles => Loc.Localize("InstallerCategoryPluginProfiles", "Plugin Collections"); public static string Category_UpdateablePlugins => Loc.Localize("InstallerCategoryCanBeUpdated", "Can be updated"); public static string Category_Other => Loc.Localize("InstallerCategoryOther", "Other"); public static string Category_Jobs => Loc.Localize("InstallerCategoryJobs", "Jobs"); public static string Category_UI => Loc.Localize("InstallerCategoryUI", "UI"); public static string Category_MiniGames => Loc.Localize("InstallerCategoryMiniGames", "Mini games"); public static string Category_Inventory => Loc.Localize("InstallerCategoryInventory", "Inventory"); public static string Category_Sound => Loc.Localize("InstallerCategorySound", "Sound"); public static string Category_Social => Loc.Localize("InstallerCategorySocial", "Social"); public static string Category_Utility => Loc.Localize("InstallerCategoryUtility", "Utility"); public static string Category_Plugins => Loc.Localize("InstallerCategoryPlugins", "Plugins"); public static string Category_Dalamud => Loc.Localize("InstallerCategoryDalamud", "Dalamud"); #endregion } }