Merge pull request #607 from MgAl2O4/featPluginCategories

This commit is contained in:
goaaats 2021-09-30 19:28:54 +02:00 committed by GitHub
commit 5bdb7d4903
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 583 additions and 2 deletions

View file

@ -0,0 +1,385 @@
using System;
using System.Collections.Generic;
using CheapLoc;
using Dalamud.Plugin.Internal.Types;
namespace Dalamud.Interface.Internal
{
/// <summary>
/// Manage category filters for PluginInstallerWindow.
/// </summary>
internal class PluginCategoryManager
{
/// <summary>
/// First categoryId for tag based categories.
/// </summary>
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<PluginManifest, int[]> mapPluginCategories = new();
private List<int> highlightedCategoryIds = new();
/// <summary>
/// Type of category group.
/// </summary>
public enum GroupKind
{
/// <summary>
/// UI group: dev mode only.
/// </summary>
DevTools,
/// <summary>
/// UI group: installed plugins.
/// </summary>
Installed,
/// <summary>
/// UI group: plugins that can be installed.
/// </summary>
Available,
}
/// <summary>
/// Gets the list of all known categories.
/// </summary>
public CategoryInfo[] CategoryList => this.categoryList;
/// <summary>
/// Gets the list of all known UI groups.
/// </summary>
public GroupInfo[] GroupList => this.groupList;
/// <summary>
/// Gets or sets current group.
/// </summary>
public int CurrentGroupIdx
{
get => this.currentGroupIdx;
set
{
if (this.currentGroupIdx != value)
{
this.currentGroupIdx = value;
this.currentCategoryIdx = 0;
this.isContentDirty = true;
}
}
}
/// <summary>
/// Gets or sets current category.
/// </summary>
public int CurrentCategoryIdx
{
get => this.currentCategoryIdx;
set
{
if (this.currentCategoryIdx != value)
{
this.currentCategoryIdx = value;
this.isContentDirty = true;
}
}
}
/// <summary>
/// Gets a value indicating whether category content needs to be rebuild with BuildCategoryContent() function.
/// </summary>
public bool IsContentDirty => this.isContentDirty;
/// <summary>
/// Gets a value indicating whether <see cref="CurrentCategoryIdx"/> and <see cref="CurrentGroupIdx"/> are valid.
/// </summary>
public bool IsSelectionValid =>
(this.currentGroupIdx >= 0) &&
(this.currentGroupIdx < this.groupList.Length) &&
(this.currentCategoryIdx >= 0) &&
(this.currentCategoryIdx < this.categoryList.Length);
/// <summary>
/// Rebuild available categories based on currently available plugins.
/// </summary>
/// <param name="availablePlugins">list of all available plugin manifests to install.</param>
public void BuildCategories(IEnumerable<PluginManifest> availablePlugins)
{
// rebuild map plugin name -> categoryIds
this.mapPluginCategories.Clear();
var categoryList = new List<int>();
var allCategoryIndices = new List<int>();
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;
}
/// <summary>
/// Filters list of available plugins based on currently selected category.
/// Resets <see cref="isContentDirty"/>.
/// </summary>
/// <param name="plugins">List of available plugins to install.</param>
/// <returns>Filtered list of plugins.</returns>
public List<PluginManifest> GetCurrentCategoryContent(IEnumerable<PluginManifest> plugins)
{
var result = new List<PluginManifest>();
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;
}
/// <summary>
/// Sets category highlight based on list of plugins. Used for searching.
/// </summary>
/// <param name="plugins">List of plugins whose categories should be highlighted.</param>
public void SetCategoryHighlightsForPlugins(IEnumerable<PluginManifest> 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);
}
}
}
}
}
}
/// <summary>
/// Checks if category should be highlighted.
/// </summary>
/// <param name="categoryId">CategoryId to check.</param>
/// <returns>true if highlight is needed.</returns>
public bool IsCategoryHighlighted(int categoryId) => this.highlightedCategoryIds.Contains(categoryId);
private IEnumerable<string> GetCategoryTagsForManifest(PluginManifest pluginManifest)
{
if (pluginManifest.CategoryTags != null)
{
return pluginManifest.CategoryTags;
}
return null;
}
/// <summary>
/// Plugin installer category info.
/// </summary>
public struct CategoryInfo
{
/// <summary>
/// Unique Id number of category, tag match based should be greater of equal <see cref="FirstTagBasedCategoryId"/>.
/// </summary>
public int CategoryId;
/// <summary>
/// Tag from plugin manifest to match.
/// </summary>
public string Tag;
private Func<string> nameFunc;
/// <summary>
/// Initializes a new instance of the <see cref="CategoryInfo"/> struct.
/// </summary>
/// <param name="categoryId">Unique id of category.</param>
/// <param name="tag">Tag to match.</param>
/// <param name="nameFunc">Function returning localized name of category.</param>
public CategoryInfo(int categoryId, string tag, Func<string> nameFunc)
{
this.CategoryId = categoryId;
this.Tag = tag;
this.nameFunc = nameFunc;
}
/// <summary>
/// Gets the name of category.
/// </summary>
public string Name => this.nameFunc();
}
/// <summary>
/// Plugin installer UI group, a container for categories.
/// </summary>
public struct GroupInfo
{
/// <summary>
/// Type of group.
/// </summary>
public GroupKind GroupKind;
/// <summary>
/// List of categories in container.
/// </summary>
public List<int> Categories;
private Func<string> nameFunc;
/// <summary>
/// Initializes a new instance of the <see cref="GroupInfo"/> struct.
/// </summary>
/// <param name="groupKind">Type of group.</param>
/// <param name="nameFunc">Function returning localized name of category.</param>
/// <param name="categories">List of category Ids to hardcode.</param>
public GroupInfo(GroupKind groupKind, Func<string> nameFunc, params int[] categories)
{
this.GroupKind = groupKind;
this.nameFunc = nameFunc;
this.Categories = new();
this.Categories.AddRange(categories);
}
/// <summary>
/// Gets the name of UI group.
/// </summary>
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
}
}
}

View file

@ -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
{

View file

@ -47,6 +47,12 @@ namespace Dalamud.Plugin.Internal.Types
[JsonProperty]
public List<string>? Tags { get; init; }
/// <summary>
/// Gets a list of category tags defined on the plugin.
/// </summary>
[JsonProperty]
public List<string>? CategoryTags { get; init; }
/// <summary>
/// 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.