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.