diff --git a/Dalamud/Interface/Internal/PluginCategoryManager.cs b/Dalamud/Interface/Internal/PluginCategoryManager.cs new file mode 100644 index 000000000..ff8a583c9 --- /dev/null +++ b/Dalamud/Interface/Internal/PluginCategoryManager.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; + +using CheapLoc; +using Dalamud.Plugin.Internal.Types; + +namespace Dalamud.Interface.Internal +{ + /// + /// Manage category filters for PluginInstallerWindow. + /// + internal class PluginCategoryManager + { + /// + /// First categoryId for tag based categories. + /// + public const int FirstTagBasedCategoryId = 100; + + private readonly CategoryInfo[] categoryList = + { + new(0, "special.all", () => Locs.Category_All), + new(10, "special.devInstalled", () => Locs.Category_DevInstalled), + new(11, "special.devIconTester", () => Locs.Category_IconTester), + new(FirstTagBasedCategoryId + 0, "other", () => Locs.Category_Other), + new(FirstTagBasedCategoryId + 1, "jobs", () => Locs.Category_Jobs), + new(FirstTagBasedCategoryId + 2, "ui", () => Locs.Category_UI), + new(FirstTagBasedCategoryId + 3, "minigame", () => Locs.Category_MiniGames), + new(FirstTagBasedCategoryId + 4, "inventory", () => Locs.Category_Inventory), + new(FirstTagBasedCategoryId + 5, "sound", () => Locs.Category_Sound), + new(FirstTagBasedCategoryId + 6, "social", () => Locs.Category_Social), + + // order doesn't matter, all tag driven categories should have Id >= FirstTagBasedCategoryId + }; + + private GroupInfo[] groupList = + { + new(GroupKind.DevTools, () => Locs.Group_DevTools, 10, 11), + new(GroupKind.Installed, () => Locs.Group_Installed, 0), + new(GroupKind.Available, () => Locs.Group_Available, 0), + + // order important, used for drawing, keep in sync with defaults for currentGroupIdx + }; + + private int currentGroupIdx = 2; + private int currentCategoryIdx = 0; + private bool isContentDirty; + + private Dictionary mapPluginCategories = new(); + private List highlightedCategoryIds = 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, + } + + /// + /// 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 current group. + /// + public int CurrentGroupIdx + { + get => this.currentGroupIdx; + set + { + if (this.currentGroupIdx != value) + { + this.currentGroupIdx = value; + this.currentCategoryIdx = 0; + this.isContentDirty = true; + } + } + } + + /// + /// Gets or sets current category. + /// + public int CurrentCategoryIdx + { + get => this.currentCategoryIdx; + set + { + if (this.currentCategoryIdx != value) + { + this.currentCategoryIdx = value; + this.isContentDirty = true; + } + } + } + + /// + /// Gets a value indicating whether category content needs to be rebuild with BuildCategoryContent() function. + /// + 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.currentCategoryIdx >= 0) && + (this.currentCategoryIdx < this.categoryList.Length); + + /// + /// 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 categoryList = new List(); + var allCategoryIndices = new List(); + + foreach (var plugin in availablePlugins) + { + categoryList.Clear(); + + var pluginCategoryTags = this.GetCategoryTagsForManifest(plugin); + if (pluginCategoryTags != null) + { + foreach (var tag in pluginCategoryTags) + { + // only tags from whitelist can be accepted + int matchIdx = Array.FindIndex(this.CategoryList, x => x.Tag.Equals(tag, StringComparison.InvariantCultureIgnoreCase)); + if (matchIdx >= 0) + { + int categoryId = this.CategoryList[matchIdx].CategoryId; + if (categoryId >= FirstTagBasedCategoryId) + { + categoryList.Add(categoryId); + + if (!allCategoryIndices.Contains(matchIdx)) + { + allCategoryIndices.Add(matchIdx); + } + } + } + } + } + + // always add, even if empty + this.mapPluginCategories.Add(plugin, categoryList.ToArray()); + } + + // sort all categories by their loc name + allCategoryIndices.Sort((idxX, idxY) => this.CategoryList[idxX].Name.CompareTo(this.CategoryList[idxY].Name)); + + // rebuild all categories in group, leaving first entry = All intact and always on top + var groupAvail = Array.Find(this.groupList, x => x.GroupKind == GroupKind.Available); + if (groupAvail.Categories.Count > 1) + { + groupAvail.Categories.RemoveRange(1, groupAvail.Categories.Count - 1); + } + + foreach (var categoryIdx in allCategoryIndices) + { + groupAvail.Categories.Add(this.CategoryList[categoryIdx].CategoryId); + } + + 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]; + + bool includeAll = (this.currentCategoryIdx == 0) || (groupInfo.GroupKind != GroupKind.Available); + if (includeAll) + { + result.AddRange(plugins); + } + else + { + var selectedCategoryInfo = Array.Find(this.categoryList, x => x.CategoryId == groupInfo.Categories[this.currentCategoryIdx]); + + foreach (var plugin in plugins) + { + if (this.mapPluginCategories.TryGetValue(plugin, out var pluginCategoryIds)) + { + int matchIdx = Array.IndexOf(pluginCategoryIds, selectedCategoryInfo.CategoryId); + if (matchIdx >= 0) + { + result.Add(plugin); + } + } + } + } + } + + this.isContentDirty = false; + return result; + } + + /// + /// Sets category highlight based on list of plugins. Used for searching. + /// + /// List of plugins whose categories should be highlighted. + public void SetCategoryHighlightsForPlugins(IEnumerable plugins) + { + this.highlightedCategoryIds.Clear(); + + if (plugins != null) + { + foreach (var entry in plugins) + { + if (this.mapPluginCategories.TryGetValue(entry, out var pluginCategories)) + { + foreach (var categoryId in pluginCategories) + { + if (!this.highlightedCategoryIds.Contains(categoryId)) + { + this.highlightedCategoryIds.Add(categoryId); + } + } + } + } + } + } + + /// + /// Checks if category should be highlighted. + /// + /// CategoryId to check. + /// true if highlight is needed. + public bool IsCategoryHighlighted(int categoryId) => this.highlightedCategoryIds.Contains(categoryId); + + 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 int CategoryId; + + /// + /// Tag from plugin manifest to match. + /// + public string Tag; + + private Func nameFunc; + + /// + /// Initializes a new instance of the struct. + /// + /// Unique id of category. + /// Tag to match. + /// Function returning localized name of category. + public CategoryInfo(int categoryId, string tag, Func nameFunc) + { + this.CategoryId = categoryId; + this.Tag = tag; + this.nameFunc = nameFunc; + } + + /// + /// 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 int[] 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(); + } + + private 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("InstallerAvailablePlugins", "Available Plugins"); + + #endregion + + #region Categories + + public static string Category_All => Loc.Localize("InstallerCategoryAll", "All"); + + public static string Category_DevInstalled => Loc.Localize("InstallerInstalledDevPlugins", "Installed Dev Plugins"); + + public static string Category_IconTester => "Image/Icon Tester"; + + 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"); + + #endregion + } + } +} diff --git a/Dalamud/Interface/Internal/Windows/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstallerWindow.cs index cd4380e20..ba95fe623 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstallerWindow.cs @@ -53,6 +53,7 @@ namespace Dalamud.Interface.Internal.Windows private readonly TextureWrap updateIcon; private readonly HttpClient httpClient = new(); + private readonly PluginCategoryManager categoryManager = new(); #region Image Tester State @@ -194,7 +195,8 @@ namespace Dalamud.Interface.Internal.Windows public override void Draw() { this.DrawHeader(); - this.DrawPluginTabBar(); + // this.DrawPluginTabBar(); + this.DrawPluginCategories(); this.DrawFooter(); this.DrawErrorModal(); this.DrawFeedbackModal(); @@ -242,7 +244,10 @@ namespace Dalamud.Interface.Internal.Windows ImGui.SetCursorPosX(windowSize.X - sortSelectWidth - style.ItemSpacing.X - searchInputWidth); ImGui.SetNextItemWidth(searchInputWidth); - ImGui.InputTextWithHint("###XlPluginInstaller_Search", Locs.Header_SearchPlaceholder, ref this.searchText, 100); + if (ImGui.InputTextWithHint("###XlPluginInstaller_Search", Locs.Header_SearchPlaceholder, ref this.searchText, 100)) + { + this.UpdateCategoriesOnSearchChange(); + } ImGui.SameLine(); ImGui.SetCursorPosX(windowSize.X - sortSelectWidth); @@ -603,6 +608,168 @@ namespace Dalamud.Interface.Internal.Windows } } + private void DrawPluginCategories() + { + float useContentHeight = -40; // button height + spacing + float useMenuWidth = 180; // works fine as static value, table can be resized by user + + float useContentWidth = ImGui.GetContentRegionAvail().X; + + if (ImGui.BeginChild("InstallerCategories", new Vector2(useContentWidth, useContentHeight * ImGuiHelpers.GlobalScale))) + { + ImGui.PushStyleVar(ImGuiStyleVar.CellPadding, ImGuiHelpers.ScaledVector2(5, 0)); + if (ImGui.BeginTable("##InstallerCategoriesCont", 2, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.Resizable | ImGuiTableFlags.BordersInnerV)) + { + ImGui.TableSetupColumn("##InstallerCategoriesSelector", ImGuiTableColumnFlags.WidthFixed, useMenuWidth * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("##InstallerCategoriesBody", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + this.DrawPluginCategorySelectors(); + + ImGui.TableNextColumn(); + if (ImGui.BeginChild($"ScrollingPlugins", new Vector2(useContentWidth, 0), false, ImGuiWindowFlags.HorizontalScrollbar | ImGuiWindowFlags.NoBackground)) + { + this.DrawPluginCategoryContent(); + ImGui.EndChild(); + } + + ImGui.EndTable(); + } + + ImGui.PopStyleVar(); + ImGui.EndChild(); + } + } + + private void DrawPluginCategorySelectors() + { + var colorSearchHighlight = Vector4.One; + unsafe + { + var colorPtr = ImGui.GetStyleColorVec4(ImGuiCol.NavHighlight); + if (colorPtr != null) + { + colorSearchHighlight = *colorPtr; + } + } + + for (int groupIdx = 0; groupIdx < this.categoryManager.GroupList.Length; groupIdx++) + { + var groupInfo = this.categoryManager.GroupList[groupIdx]; + var canShowGroup = (groupInfo.GroupKind != PluginCategoryManager.GroupKind.DevTools) || this.hasDevPlugins; + if (!canShowGroup) + { + continue; + } + + ImGui.SetNextItemOpen(groupIdx == this.categoryManager.CurrentGroupIdx); + if (ImGui.CollapsingHeader(groupInfo.Name)) + { + if (this.categoryManager.CurrentGroupIdx != groupIdx) + { + this.categoryManager.CurrentGroupIdx = groupIdx; + } + + ImGui.Indent(); + var categoryItemSize = new Vector2(ImGui.GetContentRegionAvail().X - (5 * ImGuiHelpers.GlobalScale), ImGui.GetTextLineHeight()); + for (int categoryIdx = 0; categoryIdx < groupInfo.Categories.Count; categoryIdx++) + { + var categoryInfo = Array.Find(this.categoryManager.CategoryList, x => x.CategoryId == groupInfo.Categories[categoryIdx]); + + bool hasSearchHighlight = this.categoryManager.IsCategoryHighlighted(categoryInfo.CategoryId); + if (hasSearchHighlight) + { + ImGui.PushStyleColor(ImGuiCol.Text, colorSearchHighlight); + } + + if (ImGui.Selectable(categoryInfo.Name, this.categoryManager.CurrentCategoryIdx == categoryIdx, ImGuiSelectableFlags.None, categoryItemSize)) + { + this.categoryManager.CurrentCategoryIdx = categoryIdx; + } + + if (hasSearchHighlight) + { + ImGui.PopStyleColor(); + } + } + + ImGui.Unindent(); + } + } + } + + private void DrawPluginCategoryContent() + { + var ready = this.DrawPluginListLoading(); + if (!this.categoryManager.IsSelectionValid || !ready) + { + return; + } + + var groupInfo = this.categoryManager.GroupList[this.categoryManager.CurrentGroupIdx]; + if (groupInfo.GroupKind == PluginCategoryManager.GroupKind.DevTools) + { + // this one is never sorted and remains in hardcoded order from group ctor + switch (this.categoryManager.CurrentCategoryIdx) + { + case 0: + this.DrawInstalledDevPluginList(); + break; + + case 1: + this.DrawImageTester(); + break; + + default: + // umm, there's nothing else, keep handled set and just skip drawing... + break; + } + } + else if (groupInfo.GroupKind == PluginCategoryManager.GroupKind.Installed) + { + this.DrawInstalledPluginList(); + } + else + { + var pluginList = this.pluginListAvailable; + if (pluginList.Count > 0) + { + // reset opened list of collapsibles when switching between categories + if (this.categoryManager.IsContentDirty) + { + this.openPluginCollapsibles.Clear(); + } + + var filteredManifests = pluginList.Where(rm => !this.IsManifestFiltered(rm)); + var categoryManifestsList = this.categoryManager.GetCurrentCategoryContent(filteredManifests); + + if (categoryManifestsList.Count > 0) + { + var i = 0; + foreach (var manifest in categoryManifestsList) + { + var rmManifest = manifest as RemotePluginManifest; + if (rmManifest != null) + { + ImGui.PushID($"{rmManifest.InternalName}{rmManifest.AssemblyVersion}"); + this.DrawAvailablePlugin(rmManifest, i++); + ImGui.PopID(); + } + } + } + else + { + ImGui.Text(Locs.TabBody_SearchNoMatching); + } + } + else + { + ImGui.Text(Locs.TabBody_SearchNoCompatible); + } + } + } + private void DrawImageTester() { var sectionSize = ImGuiHelpers.GlobalScale * 66; @@ -1763,6 +1930,8 @@ namespace Dalamud.Interface.Internal.Windows .ToList(); this.pluginListUpdatable = pluginManager.UpdatablePlugins.ToList(); this.ResortPlugins(); + + this.UpdateCategoriesOnPluginsChange(); } private void OnInstalledPluginsChanged() @@ -1773,6 +1942,8 @@ namespace Dalamud.Interface.Internal.Windows this.pluginListUpdatable = pluginManager.UpdatablePlugins.ToList(); this.hasDevPlugins = this.pluginListInstalled.Any(plugin => plugin.IsDev); this.ResortPlugins(); + + this.UpdateCategoriesOnPluginsChange(); } private void ResortPlugins() @@ -2111,6 +2282,25 @@ namespace Dalamud.Interface.Internal.Windows return output; } + private void UpdateCategoriesOnSearchChange() + { + if (string.IsNullOrEmpty(this.searchText)) + { + this.categoryManager.SetCategoryHighlightsForPlugins(null); + } + else + { + var pluginsMatchingSearch = this.pluginListAvailable.Where(rm => !this.IsManifestFiltered(rm)); + this.categoryManager.SetCategoryHighlightsForPlugins(pluginsMatchingSearch); + } + } + + private void UpdateCategoriesOnPluginsChange() + { + this.categoryManager.BuildCategories(this.pluginListAvailable); + this.UpdateCategoriesOnSearchChange(); + } + [SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "Disregard here")] private static class Locs { diff --git a/Dalamud/Plugin/Internal/Types/PluginManifest.cs b/Dalamud/Plugin/Internal/Types/PluginManifest.cs index 0076ddfc7..ddacb66de 100644 --- a/Dalamud/Plugin/Internal/Types/PluginManifest.cs +++ b/Dalamud/Plugin/Internal/Types/PluginManifest.cs @@ -47,6 +47,12 @@ namespace Dalamud.Plugin.Internal.Types [JsonProperty] public List? Tags { get; init; } + /// + /// Gets a list of category tags defined on the plugin. + /// + [JsonProperty] + public List? CategoryTags { get; init; } + /// /// Gets a value indicating whether or not the plugin is hidden in the plugin installer. /// This value comes from the plugin master and is in addition to the list of hidden names kept by Dalamud.